diff --git a/.changeset/account-holders.md b/.changeset/account-holders.md new file mode 100644 index 0000000000..988b9a6e12 --- /dev/null +++ b/.changeset/account-holders.md @@ -0,0 +1,5 @@ +--- +"@parity/truapi-host": major +--- + +Product account operations on both host roles go through `HostAccounts` over an `AccountHolder`: `WalletAccountHolder` on a signing host, or `RemoteAccountHolder` on a paired host, which forwards each request to the phone. `ProductAuthority` is removed. `HostAccounts` binds each call to the calling product and the current wallet activation, confirms signing, transaction, VRF and allocation requests with the user before the account holder acts on them, and keeps the grants it was issued. Bulletin submissions check the authorization and ask for an increase before submitting instead of retrying after a rejection. Raw signing through the legacy-account API tells the product's own slot-zero account apart from the active identity. diff --git a/.changeset/bulletin-capacity-check.md b/.changeset/bulletin-capacity-check.md new file mode 100644 index 0000000000..0cc2178dcc --- /dev/null +++ b/.changeset/bulletin-capacity-check.md @@ -0,0 +1,5 @@ +--- +"@parity/truapi-host": patch +--- + +Before signing a Bulletin preimage submission, check that the product's Bulletin authorization can hold the whole payload, and ask for more first when it cannot, instead of waiting for the chain to reject the transaction. diff --git a/.changeset/host-grant-store.md b/.changeset/host-grant-store.md new file mode 100644 index 0000000000..9721de3289 --- /dev/null +++ b/.changeset/host-grant-store.md @@ -0,0 +1,5 @@ +--- +"@parity/truapi-host": patch +--- + +Keep the pairing host's and the signing host's grants in one `HostGrantStore`. Resetting a product no longer ends other products' calls on a signing host or a session activation in progress on a paired host; it still discards grants that a request began issuing before the reset, including an allocation confirmed while the reset happened. Disconnecting a paired host now also deletes the product subtrees it stored, and a deletion that fails is retried before the next session is installed. diff --git a/.changeset/native-allowance-storage.md b/.changeset/native-allowance-storage.md new file mode 100644 index 0000000000..42942187e8 --- /dev/null +++ b/.changeset/native-allowance-storage.md @@ -0,0 +1,5 @@ +--- +"@parity/truapi-host": major +--- + +Native hosts keep issued allowance keys across restarts in protected platform storage, as `CoreStorageKey::NativeAllowanceKey` records listed through `coreStorageKeys`. A host that matches exhaustively over `CoreStorageKey` must handle the new key. Product reset deletes the product's records, and `clearWalletState` deletes a removed wallet's records. Native hosts expose `clearProductState`, and uninstalling a product clears its state. On iOS, secret core values saved before this version are moved into the Keychain on first read. diff --git a/.changeset/product-consent.md b/.changeset/product-consent.md new file mode 100644 index 0000000000..1c419b903a --- /dev/null +++ b/.changeset/product-consent.md @@ -0,0 +1,5 @@ +--- +"@parity/truapi-host": minor +--- + +Account access, operation reviews and cross-product signature approvals go through one `ProductConsent` per runtime. An Allow once answer to an account-access prompt now covers that product's later requests until restart, lock, a wallet switch or that product's reset, only one account-access prompt is shown at a time, and a request that waited for it reuses the answer. Requests from a paired host share the phone's stored decisions and are always reviewed on the phone. diff --git a/.changeset/refused-allocation-rejected.md b/.changeset/refused-allocation-rejected.md new file mode 100644 index 0000000000..d575d82de1 --- /dev/null +++ b/.changeset/refused-allocation-rejected.md @@ -0,0 +1,5 @@ +--- +"@parity/truapi-host": major +--- + +When the user refuses a resource allocation on the product's own host, the product receives a response whose outcomes are all `Rejected`, as RFC 0010 defines, instead of an error. A refusal on the phone for a paired host already answered this way. diff --git a/.changeset/relayed-vrf-auto-signing.md b/.changeset/relayed-vrf-auto-signing.md new file mode 100644 index 0000000000..575c8eec6b --- /dev/null +++ b/.changeset/relayed-vrf-auto-signing.md @@ -0,0 +1,5 @@ +--- +"@parity/truapi-host": patch +--- + +A VRF request relayed from a paired host is always confirmed on the phone, even when the product it names holds a local AutoSigning grant. diff --git a/.changeset/remote-account-holder.md b/.changeset/remote-account-holder.md new file mode 100644 index 0000000000..ab3155cda7 --- /dev/null +++ b/.changeset/remote-account-holder.md @@ -0,0 +1,5 @@ +--- +"@parity/truapi-host": patch +--- + +A paired host sends each wallet operation through `RemoteAccountHolder`, which turns it into an SSO request and keeps the paired copy of the phone's ring-VRF registrations. Paired session changes hold back the host's grant writes: a login, a restored or external session and a sign-out finish revoking the previous session's grants and clearing its stored auth session before the next session is installed, and a deletion that failed is retried first. A login that another activation replaced is not installed, and activating an external session reports an error when the previous session's cleanup fails. A cancelled SSO request is withdrawn from the phone even when the wait for its answer is dropped. diff --git a/.changeset/sso-peer-services.md b/.changeset/sso-peer-services.md new file mode 100644 index 0000000000..5936b36c12 --- /dev/null +++ b/.changeset/sso-peer-services.md @@ -0,0 +1,5 @@ +--- +"@parity/truapi-host": major +--- + +A native wallet with its own SSO transport opens one service per peer with `openSsoService`, which replaces `handleSsoRequest` on the host runtime. Each service is bound to the wallet activation that authenticated the peer, so after a lock or wallet switch it answers not-connected instead of using the new wallet. `handleSsoControl` applies a `Cancel` at once, so it reaches the request it withdraws instead of waiting behind it. diff --git a/.changeset/sso-services.md b/.changeset/sso-services.md new file mode 100644 index 0000000000..bd84aa3d9b --- /dev/null +++ b/.changeset/sso-services.md @@ -0,0 +1,5 @@ +--- +"@parity/truapi-host": patch +--- + +Run the pairing host's session selection, login and request channel in an `SsoRequestService`, and the signing host's pairing answers and served sessions in an `SsoResponderService`. diff --git a/.changeset/statement-sponsorship-renewal.md b/.changeset/statement-sponsorship-renewal.md new file mode 100644 index 0000000000..e6ce7f518c --- /dev/null +++ b/.changeset/statement-sponsorship-renewal.md @@ -0,0 +1,5 @@ +--- +"@parity/truapi-host": minor +--- + +A kept Statement Store key is reused across allowance periods instead of being looked up again each UTC day, and a statement the store rejects no longer discards it. Before each submit signed with a kept key, a signing host renews that key's sponsorship, so every such submit first runs the renewal check against the chain: it opens a chain client, reads the network suffix, takes the renewal registration lock and scans the personhood collections, and registers the key again when the period changed. A paired host sends nothing, because the phone renews the sponsorships it issued. diff --git a/.changeset/wallet-account-holder-modules.md b/.changeset/wallet-account-holder-modules.md new file mode 100644 index 0000000000..5b4e8d6510 --- /dev/null +++ b/.changeset/wallet-account-holder-modules.md @@ -0,0 +1,5 @@ +--- +"@parity/truapi-host": patch +--- + +Group the signing host's wallet code under `signing_host/wallet_account_holder`: account operations, allowance issuance and the renewal ledger with its native driver. Behavior is unchanged. diff --git a/.changeset/wallet-account-holder.md b/.changeset/wallet-account-holder.md new file mode 100644 index 0000000000..9a7271e919 --- /dev/null +++ b/.changeset/wallet-account-holder.md @@ -0,0 +1,5 @@ +--- +"@parity/truapi-host": patch +--- + +A signing host's wallet lives in one `WalletAccountHolder`, the only reader of the root entropy, and it checks that the wallet activation a call began under is still installed each time it uses the keys. A wallet lock or switch now fails a VRF signature confirmed under the previous wallet, a transaction whose chain metadata was still loading, an alias or proof still preparing its ring, and a pairing answer still reading the device key, instead of completing them with the new wallet's keys or the old one's. A lock during a resource allocation fails the request instead of marking the remaining resources unavailable. diff --git a/README.md b/README.md index 16d4ef3151..5340e4b65b 100644 --- a/README.md +++ b/README.md @@ -182,13 +182,11 @@ The [container permission boundary](js/container/README.md) documents the protec operations and the built-ins that remain mutable for product compatibility. Native bindings expose the canonical Rust domain and protocol value types; native-only adapter types are limited to lifecycle and callback behavior. -On iOS, a wallet host that manages its own statement-store SSO session can call -`handleSsoRequest` (routes one decrypted remote message through the core, -returning a typed outcome: response bytes to post back, a disconnect marker, or -ignored; a `Cancel` returns at once, so the wallet passes it on without queueing -it behind the request it withdraws) and `prepareDisconnectRequest` (builds the SCALE-encoded wire message -for a wallet-initiated disconnect) on `TrUAPIHostRuntime`. Response posting and -session-record cleanup remain on the wallet side. +A native wallet that runs its own SSO transport opens one SSO service per peer with `openSsoService` and passes it each incoming message; `prepareDisconnectRequest` builds a wallet-initiated disconnect. The core binds each service to the wallet activation that authenticated the peer. The [iOS SDK guide](ios/truapi-host/README.md#sso-session-handling) gives the call sequence. + +`HostAccounts` serves every product account operation on both host roles: it checks access, confirms signing and allocation requests with the user, keeps the grants it was issued in its `HostGrantStore`, and signs with delegated keys. Wallet work sits behind an `AccountHolder`: `WalletAccountHolder` on a signing host, or `RemoteAccountHolder` on a paired host, which forwards each request to the phone, where the user confirms it again. One `ProductConsent` per runtime holds account-access decisions and runs their prompts. Native hosts keep issued allowance keys in protected platform storage. `PairingHost` and `SigningHost` build these parts for their role and own login, logout and the wallet lifecycle; see [runtime ownership and lifecycle](rust/crates/truapi/RUNTIME.md#the-two-roles). + +Each product connection is served by a `ProductRuntimeHost`, which combines `HostAccounts`, a `HostSession` (`SigningHost` or `SsoRequestService`) for login, disconnect and identity lookup, and a per-product `ProductConnection` for adapters, permissions and action streams. See the core's [inter-host SSO design](rust/crates/truapi/RUNTIME.md#inter-host-sso) for typed handlers, canonical resource types, and consent bound to the signing session. SSO session, responder identity and handshake payload structs zeroize their fields on drop; debug output redacts their secret fields. diff --git a/android/truapi-host/README.md b/android/truapi-host/README.md index 5f29e4545d..c864589a05 100644 --- a/android/truapi-host/README.md +++ b/android/truapi-host/README.md @@ -153,7 +153,7 @@ Two steps around them are the host's. `establishPairing` signs its answer with t The core prompts for nothing along the way, so asking the user is the host's too. `parsePairingDeeplink` returns the peer's `metadata` alongside it for that prompt: the host name, version, icon and platform the peer put in its QR, trimmed and stripped of the control characters and bidirectional overrides that would otherwise rewrite the prompt's own text around them, capped at 512 characters, and null where nothing renderable was sent. Safe to render is not verified: nothing signs that metadata, so a prompt built from it says what the peer calls itself, never who it is. -The `NativeAnnouncedPairing` that `notifyPairingAllowanceAllocation` returns holds the responder statement secret its notice was signed with, and nothing consumes it, so `destroy()` it once the pairing settles, on the succeeding path as well as the failing one; wrapping the whole pairing in `use { }` covers both. +The `NativeAnnouncedPairing` that `notifyPairingAllowanceAllocation` returns retains the wallet activation that sent the notice, so `destroy()` it once the pairing settles, on the succeeding path as well as the failing one; wrapping the whole pairing in `use { }` covers both. `devicePaired` on the runtime bridge reports a device that finished pairing with this signing host, carrying the `PairedSsoPeer` the pairing produced. The core has no chat of its own, so announcing the new device to the user's existing contacts is the host's to do. It fires at least once per pairing, so a device that pairs again reports again; a resumed pairing reports nothing, so the host keeps its own record of which devices it has already seen. It arrives on the thread answering the handshake, so marshal the work off rather than announcing it inline. Defaults to a no-op for a host that answers no pairing. diff --git a/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt b/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt index 941e3f0322..467773c7e8 100644 --- a/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt +++ b/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt @@ -125,6 +125,9 @@ interface HostStorage { * [HostRejection] on failure. */ interface HostCoreStorage { + @Throws(HostRejection::class) + suspend fun keys(): List = throw HostRejection.Rejected("Core storage enumeration is unavailable") + @Throws(HostRejection::class) suspend fun read(key: ByteArray): ByteArray? @@ -552,6 +555,9 @@ private class HostCallbackAdapter(private val bridge: HostBridge) : HostCallback } } + override suspend fun coreStorageKeys(): List = + withHostRejection { bridge.coreStorage.keys() } + override suspend fun coreStorageRead(key: ByteArray): ByteArray? = withHostRejection { bridge.coreStorage.read(key) } @@ -955,6 +961,14 @@ class TrUAPIHostRuntime @Throws(NativeRuntimeConfigException::class) constructor @Throws(NativeCoreDatabaseException::class) suspend fun coreDatabaseStatus(): DbStatus = inner.coreDatabaseStatus() + /** Revoke retained grants when a product is uninstalled. */ + @Throws(HostRejection::class) + suspend fun clearProductState(productId: String) = inner.clearProductState(productId) + + /** Revoke retained grants owned by a deleted wallet. */ + @Throws(HostRejection::class) + suspend fun clearWalletState(owner: ByteArray) = inner.clearWalletState(owner) + /** * Decide durable transactions from the chain until none is live, then * return. Await it from the background task [HostBridge.durableWorkChanged] diff --git a/docs/rfcs/0002-permission-model.md b/docs/rfcs/0002-permission-model.md index f66c97fbbb..7a448c7c1a 100644 --- a/docs/rfcs/0002-permission-model.md +++ b/docs/rfcs/0002-permission-model.md @@ -188,7 +188,7 @@ later request for one of those domains alone still gets its own prompt. ### Permission Lifecycle 1. **First request** — When a permission is requested for the first time (either via an explicit permission API call or implicitly by a business method), the Host prompts the user with an approval dialog. -2. **Decision lifetime**: `AllowAlways` and `Deny` are stored for the product and survive restarts. `AllowOnce` stays in memory for the execution and authorizes one operation. It is never converted into a durable grant. +2. **Decision lifetime**: `AllowAlways` and `Deny` are stored for the product and survive restarts. `AllowOnce` stays in memory for the execution and authorizes one operation. It is never converted into a durable grant. For cross-product account access, `AllowOnce` answers the same requester and target until the runtime restarts, the wallet locks or changes, or the requesting product is reset. 3. **Subsequent requests**: Saved decisions resolve without another prompt. An upfront permission request can observe a one-use grant without consuming it; the operation atomically consumes it. After consumption, the next operation needs another decision unless a lasting grant or an automatic exemption covers it. 4. **Device capabilities are also gated by the OS** — A device permission has a second gate the persisted decision does not describe: the OS grant held by the host application, which the user can revoke in system settings, device policy can suspend, and the platform can reset on its own. A host that can read that state serves `PermissionStatusHost`, and a device capability then resolves as usable only while both gates are open. An OS refusal denies the request without a prompt, since only system settings can reach it, and leaves the persisted product decision in place for when the user restores the OS grant. An OS grant that is merely undetermined does not change the answer: the OS puts its own dialog up when the capability is used, and the core cannot reach that dialog without also re-asking the product's question, which step 3 forbids. Reading a status without prompting resolves the same two gates, so a host settings screen and a request cannot disagree about whether a capability is usable. A host that cannot report OS state resolves from the persisted decision alone. 5. **Revocation** — Revocation of the product-scoped decision is out of scope for this RFC. Hosts MAY provide a settings interface for users to revoke permissions, but the protocol does not define a revocation notification to the product. diff --git a/docs/rfcs/sso-request-cancellation.md b/docs/rfcs/sso-request-cancellation.md index 4ea2cb7256..846a57882a 100644 --- a/docs/rfcs/sso-request-cancellation.md +++ b/docs/rfcs/sso-request-cancellation.md @@ -38,8 +38,7 @@ and reads the next message only after the current one is answered. one lock; otherwise it would replace another request, and nothing is sent. - **Reading it in time.** The Rust responder reads statements while it serves a request and applies a `Cancel` at once. Requests are still served one at a time, reading pauses once 64 are queued (a page already read is queued - whole), and `Disconnected` keeps its place in the queue. A native wallet passes `Cancel` to `handle_sso_request`, - which returns at once, and must do so as the message arrives rather than behind the request it withdraws. + whole), and `Disconnected` keeps its place in the queue. A native wallet calls its retained peer service's synchronous `handle_sso_control` before enqueueing a message. Cancel is applied immediately; ordinary requests keep their FIFO order. - **What it stops.** A running request has its token fired and posts no response. A request not yet started never runs, and the responder's replay ledger records it as handled so redelivery does not revive it. A `Cancel` for a request not yet seen is remembered, up to 64 of them; one pushed out by a newer one is logged, and its request is @@ -65,9 +64,7 @@ and reads the next message only after the current one is answered. ## Open questions -1. iOS queues every SSO message behind the one it is serving, so a `Cancel` reaches `handle_sso_request` only after its - target has finished. Its dispatcher needs to pass `Cancel` through at once. -2. Android decodes SSO in Kotlin and does not use the core's responder. It needs its own `Cancel` handling and prompt +1. Android decodes SSO in Kotlin and does not use the core's responder. It needs its own `Cancel` handling and prompt dismissal. [cancel]: request-cancellation.md diff --git a/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/data/storage/preferences/Preferences.kt b/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/data/storage/preferences/Preferences.kt index 169c9ee2ca..100a1d4742 100644 --- a/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/data/storage/preferences/Preferences.kt +++ b/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/data/storage/preferences/Preferences.kt @@ -5,6 +5,8 @@ import kotlinx.coroutines.flow.Flow typealias InitialValueProducer = suspend () -> T interface Preferences { + fun keys(): Set = error("Preference enumeration is unavailable") + fun contains(field: String): Boolean fun putString( @@ -119,4 +121,6 @@ interface Editor { fun remove(field: String) fun apply() + + fun commit(): Boolean } diff --git a/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/data/storage/preferences/RealPreferences.kt b/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/data/storage/preferences/RealPreferences.kt index 62569b356a..0bdca23007 100644 --- a/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/data/storage/preferences/RealPreferences.kt +++ b/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/data/storage/preferences/RealPreferences.kt @@ -18,6 +18,8 @@ class RealPreferences @Inject constructor( ) : Preferences { private val sharedPreferences = context.getSharedPreferences(SHARED_PREFERENCES_FILE, Context.MODE_PRIVATE) + override fun keys(): Set = sharedPreferences.all.keys + /* SharedPreferencesImpl stores listeners in a WeakHashMap, meaning listener is subject to GC if it is not kept anywhere else. @@ -209,5 +211,7 @@ class RealPreferences @Inject constructor( override fun apply() { editor.apply() } + + override fun commit(): Boolean = editor.commit() } } diff --git a/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/data/storage/preferences/encrypted/EncryptedPreferences.kt b/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/data/storage/preferences/encrypted/EncryptedPreferences.kt index b07dc4427b..a702279c8f 100644 --- a/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/data/storage/preferences/encrypted/EncryptedPreferences.kt +++ b/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/data/storage/preferences/encrypted/EncryptedPreferences.kt @@ -8,8 +8,14 @@ interface EncryptedPreferences { value: String, ) + fun putEncryptedStringCommitted(field: String, value: String) + fun getDecryptedString(field: String): String? + fun getDecryptedStringOrThrow(field: String): String? + + fun removeKeyCommitted(field: String) + fun hasKey(field: String): Boolean fun removeKey(field: String) diff --git a/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/data/storage/preferences/encrypted/EncryptionUtil.kt b/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/data/storage/preferences/encrypted/EncryptionUtil.kt index 85611310f1..8eb6f1414b 100644 --- a/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/data/storage/preferences/encrypted/EncryptionUtil.kt +++ b/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/data/storage/preferences/encrypted/EncryptionUtil.kt @@ -119,6 +119,12 @@ class EncryptionUtil @Inject constructor( keyPairGenerator.generateKeyPair() } + internal fun encryptOrThrow(cleartext: String): String = + Base64.toBase64String(encrypt(getPrerenceAesKey().encoded, cleartext.toByteArray())) + + internal fun decryptOrThrow(encryptedBase64: String): String = + String(decrypt(getPrerenceAesKey().encoded, Base64.decode(encryptedBase64))) + fun encrypt(cleartext: String?): String { if (!cleartext.isNullOrEmpty()) { try { diff --git a/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/data/storage/preferences/encrypted/RealEncryptedPreferences.kt b/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/data/storage/preferences/encrypted/RealEncryptedPreferences.kt index 4529214687..a4114faf46 100644 --- a/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/data/storage/preferences/encrypted/RealEncryptedPreferences.kt +++ b/hosts/android/common/src/main/java/io/paritytech/polkadotapp/common/data/storage/preferences/encrypted/RealEncryptedPreferences.kt @@ -18,6 +18,22 @@ internal class RealEncryptedPreferences @Inject constructor( preferences.putString(field, encryptionUtil.encrypt(value)) } + override fun putEncryptedStringCommitted(field: String, value: String) { + val ciphertext = encryptionUtil.encryptOrThrow(value) + val editor = preferences.edit() + editor.putString(field, ciphertext) + check(editor.commit()) { "Encrypted preference write was not committed" } + } + + override fun getDecryptedStringOrThrow(field: String): String? = + preferences.getString(field)?.let(encryptionUtil::decryptOrThrow) + + override fun removeKeyCommitted(field: String) { + val editor = preferences.edit() + editor.remove(field) + check(editor.commit()) { "Encrypted preference removal was not committed" } + } + override fun getDecryptedString(field: String): String? { val encryptedString = preferences.getString(field) return encryptedString?.let { encryptionUtil.decrypt(it) } diff --git a/hosts/android/common/src/test/java/io/paritytech/polkadotapp/common/data/storage/preferences/encrypted/RealEncryptedPreferencesTest.kt b/hosts/android/common/src/test/java/io/paritytech/polkadotapp/common/data/storage/preferences/encrypted/RealEncryptedPreferencesTest.kt new file mode 100644 index 0000000000..dea6dc6a78 --- /dev/null +++ b/hosts/android/common/src/test/java/io/paritytech/polkadotapp/common/data/storage/preferences/encrypted/RealEncryptedPreferencesTest.kt @@ -0,0 +1,36 @@ +package io.paritytech.polkadotapp.common.data.storage.preferences.encrypted + +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import io.paritytech.polkadotapp.common.data.storage.preferences.Editor +import io.paritytech.polkadotapp.common.data.storage.preferences.Preferences +import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows +import org.junit.Test + +class RealEncryptedPreferencesTest { + private val editor = mockk(relaxed = true) + private val preferences = mockk { every { edit() } returns editor } + private val cipher = mockk() + private val store = RealEncryptedPreferences(preferences, cipher) + + @Test + fun `a failed disk commit fails secret writes and removals`() { + every { cipher.encryptOrThrow("secret") } returns "ciphertext" + every { editor.commit() } returns false + assertThrows(IllegalStateException::class.java) { store.putEncryptedStringCommitted("key", "secret") } + assertThrows(IllegalStateException::class.java) { store.removeKeyCommitted("key") } + verify(exactly = 2) { editor.commit() } + verify(exactly = 0) { editor.apply() } + } + + @Test + fun `protected storage corruption cannot become absence`() { + val corrupt = IllegalStateException("decryption failed") + every { preferences.getString("key") } returns "corrupt" + every { cipher.decryptOrThrow("corrupt") } throws corrupt + val failure = runCatching { store.getDecryptedStringOrThrow("key") }.exceptionOrNull() + assertSame(corrupt, failure) + } +} diff --git a/hosts/android/database/src/androidTest/java/io/paritytech/polkadotapp/database/migrations/MigrationTest.kt b/hosts/android/database/src/androidTest/java/io/paritytech/polkadotapp/database/migrations/MigrationTest.kt index ae870e55e2..aacb25fe19 100644 --- a/hosts/android/database/src/androidTest/java/io/paritytech/polkadotapp/database/migrations/MigrationTest.kt +++ b/hosts/android/database/src/androidTest/java/io/paritytech/polkadotapp/database/migrations/MigrationTest.kt @@ -84,4 +84,5 @@ private class NoOpEditor : Editor { override fun putLong(field: String, value: Long) = Unit override fun remove(field: String) = Unit override fun apply() = Unit + override fun commit(): Boolean = true } diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/product/UninstallProductUseCase.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/product/UninstallProductUseCase.kt index 8674fe4760..ff32b443ae 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/product/UninstallProductUseCase.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/product/UninstallProductUseCase.kt @@ -2,6 +2,8 @@ package io.paritytech.polkadotapp.feature_products_impl.domain.product import io.paritytech.polkadotapp.feature_products_api.domain.game.ProductGameReminder import io.paritytech.polkadotapp.feature_products_api.model.ProductId +import io.paritytech.polkadotapp.feature_products_api.domain.runtime.ProductRuntimeSettings +import io.paritytech.polkadotapp.feature_products_impl.domain.truapi.TrUAPIHostRuntimeProvider import io.paritytech.polkadotapp.feature_products_impl.data.repository.ProductRepository import io.paritytech.polkadotapp.feature_products_impl.domain.notifications.ProductNotificationScheduler import javax.inject.Inject @@ -10,10 +12,15 @@ class UninstallProductUseCase @Inject constructor( private val productRepository: ProductRepository, private val notificationScheduler: ProductNotificationScheduler, private val productGameReminder: ProductGameReminder, + private val runtimeSettings: ProductRuntimeSettings, + private val runtimeProvider: TrUAPIHostRuntimeProvider, ) { suspend operator fun invoke(productId: ProductId): Result { return notificationScheduler.cancelAllForProduct(productId) - .map { + .mapCatching { + if (runtimeSettings.isTrUAPIRuntimeEnabled()) { + runtimeProvider.runtime().getOrThrow().clearProductState(productId.value) + } productGameReminder.cancel(productId) productRepository.deleteProduct(productId) } diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/EncryptedTrUAPIStorage.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/EncryptedTrUAPIStorage.kt index 949680865e..328d65f348 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/EncryptedTrUAPIStorage.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/EncryptedTrUAPIStorage.kt @@ -2,10 +2,17 @@ package io.paritytech.polkadotapp.feature_products_impl.domain.truapi import io.parity.truapi.HostCoreStorage import io.parity.truapi.HostStorage +import io.paritytech.polkadotapp.common.data.storage.preferences.Preferences import io.paritytech.polkadotapp.common.data.storage.preferences.encrypted.EncryptedPreferences +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import uniffi.truapi.HostLocalStorageReadException import uniffi.truapi.HostRejection import java.text.Normalizer +import javax.inject.Inject +import javax.inject.Singleton /** * Product-scoped storage for the Rust core, encrypted at rest. @@ -38,35 +45,49 @@ class EncryptedHostStorage( productStorageKeyOwner(key)?.takeUnless { it == normalizedProductId } ?: productId } -/** - * Core-owned storage: auth session, pairing identity, and persisted permission - * decisions. - * - * Deliberately *not* product-scoped. The pairing identity belongs to the user, - * not to a product, and scoping it per product would make every product demand - * its own pairing. The core disambiguates internally through the SCALE-encoded - * `CoreStorageKey` it passes here. - */ -class EncryptedHostCoreStorage( +/** Retains core secrets with committed writes and strict read failures. */ +@Singleton +class EncryptedHostCoreStorage @Inject constructor( private val preferences: EncryptedPreferences, + private val backing: Preferences, ) : HostCoreStorage { - override suspend fun read(key: ByteArray): ByteArray? = readValue(preferences, qualify(key)) + private val mutex = Mutex() - override suspend fun write(key: ByteArray, value: ByteArray) { - writeValue(preferences, qualify(key), value) - ?.let { throw HostRejection.Rejected("core storage: $it") } + override suspend fun keys(): List = withStorage { + backing.keys().filter { it.startsWith("truapi/core/") } + .mapNotNull { decodeOrNull(it.removePrefix("truapi/core/")) } } - override suspend fun clear(key: ByteArray) { - runCatching { preferences.removeKey(qualify(key)) } - .getOrElse { throw HostRejection.Rejected("failed to clear core storage key: ${it.message}") } + override suspend fun read(key: ByteArray): ByteArray? = withStorage { + val stored = preferences.getDecryptedStringOrThrow(qualify(key)) + if (stored == null) { + null + } else { + require(stored.startsWith(VALUE_TAG)) { "Invalid core storage value encoding" } + requireNotNull(decodeOrNull(stored.removePrefix(VALUE_TAG))) { "Invalid core storage bytes" } + } } - private fun qualify(key: ByteArray) = "$CORE_NAMESPACE/${key.toHex()}" + override suspend fun write(key: ByteArray, value: ByteArray) = withStorage { + preferences.putEncryptedStringCommitted(qualify(key), VALUE_TAG + value.toHex()) + } - private companion object { - const val CORE_NAMESPACE = "truapi/core" + override suspend fun clear(key: ByteArray) = withStorage { + preferences.removeKeyCommitted(qualify(key)) } + + private suspend fun withStorage(operation: () -> T): T = mutex.withLock { + currentCoroutineContext().ensureActive() + try { + // A failed commit can leave newer data visible in SharedPreferences memory. + check(backing.edit().commit()) { "Core storage persistence is unavailable" } + operation() + } catch (error: Exception) { + throw HostRejection.Rejected("Core storage: ${error.message}") + } + } + + private fun qualify(key: ByteArray) = "truapi/core/${key.toHex()}" } /** Namespace for one product's core-facing local storage. */ @@ -89,14 +110,7 @@ internal fun productStorageKeyOwner(key: String): String? { internal fun normalizeProductId(productId: String): String = Normalizer.normalize(productId.trim(), Normalizer.Form.NFC).lowercase() -/** - * `EncryptionUtil` reports both a failed encrypt and a failed decrypt by - * returning an empty string, and it also refuses to encrypt an empty input, so - * a write that silently failed is otherwise indistinguishable from a value that - * is legitimately empty. Tagging the plaintext makes the two tellable apart: an - * untagged read is a failure and must surface as a miss rather than as empty - * bytes the core would treat as real. - */ +/** Distinguishes encoded core bytes from unrelated preference values. */ private const val VALUE_TAG = "v1:" private fun readValue(preferences: EncryptedPreferences, key: String): ByteArray? { diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridge.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridge.kt index 1a7d5aa4a3..f1505d04ab 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridge.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridge.kt @@ -76,6 +76,7 @@ class ProductTrUAPIHostBridge @AssistedInject constructor( private val hostApiInteractor: HostApiInteractor, @param:TrUAPIChainHttpClient private val chainHttpClient: OkHttpClient, private val encryptedPreferences: EncryptedPreferences, + private val coreStorage: EncryptedHostCoreStorage, private val confirmationLauncher: TrUAPIConfirmationLauncher, private val appLifecycleObserver: AppLifecycleObserver, private val dotNsTldProvider: DotNsTldProvider, @@ -130,7 +131,7 @@ class ProductTrUAPIHostBridge @AssistedInject constructor( override val storage: HostStorage = EncryptedHostStorage(encryptedPreferences, callingProductId.value) - override val coreStorage: HostCoreStorage = EncryptedHostCoreStorage(encryptedPreferences) + override val coreStorage: HostCoreStorage = this@ProductTrUAPIHostBridge.coreStorage override fun onCoreLog(marker: String, detail: String) { Timber.tag("truapi.core").d("%s: %s", marker, detail) diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIHostRuntimeProvider.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIHostRuntimeProvider.kt index 6def0af59b..4e527516cd 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIHostRuntimeProvider.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIHostRuntimeProvider.kt @@ -13,7 +13,6 @@ import io.parity.truapi.WebSocketChainProvider import io.paritytech.polkadotapp.chains.multiNetwork.ChainRegistry import io.paritytech.polkadotapp.chains.multiNetwork.KnownChains import io.paritytech.polkadotapp.common.data.app.AppLifecycleState -import io.paritytech.polkadotapp.common.data.storage.preferences.encrypted.EncryptedPreferences import io.paritytech.polkadotapp.common.presentation.AppLifecycleObserver import io.paritytech.polkadotapp.common.utils.CoroutineDispatchers import io.paritytech.polkadotapp.common.utils.logFailure @@ -69,7 +68,7 @@ class TrUAPIHostRuntimeProvider @Inject constructor( private val localSessionSource: TrUAPILocalSessionSource, private val accountRepository: AccountRepository, private val dotNsTldProvider: DotNsTldProvider, - private val encryptedPreferences: EncryptedPreferences, + private val coreStorage: EncryptedHostCoreStorage, @param:TrUAPIChainHttpClient private val chainHttpClient: OkHttpClient, private val confirmationLauncher: TrUAPIConfirmationLauncher, private val appLifecycleObserver: AppLifecycleObserver, @@ -213,7 +212,7 @@ class TrUAPIHostRuntimeProvider @Inject constructor( private inner class HostRuntimeBridge : HostBridge { override val storage: HostStorage = HostLevelStorage - override val coreStorage: HostCoreStorage = EncryptedHostCoreStorage(encryptedPreferences) + override val coreStorage: HostCoreStorage = this@TrUAPIHostRuntimeProvider.coreStorage override fun onCoreLog(marker: String, detail: String) { Timber.tag("truapi.core").d("%s: %s", marker, detail) diff --git a/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/product/UninstallProductUseCaseTest.kt b/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/product/UninstallProductUseCaseTest.kt index 805d058909..0f64046a7d 100644 --- a/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/product/UninstallProductUseCaseTest.kt +++ b/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/product/UninstallProductUseCaseTest.kt @@ -1,77 +1,64 @@ package io.paritytech.polkadotapp.feature_products_impl.domain.product +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.every +import io.mockk.mockk +import io.parity.truapi.TrUAPIHostRuntime import io.paritytech.polkadotapp.feature_products_api.domain.game.ProductGameReminder +import io.paritytech.polkadotapp.feature_products_api.domain.runtime.ProductRuntimeSettings import io.paritytech.polkadotapp.feature_products_api.model.ProductId import io.paritytech.polkadotapp.feature_products_impl.data.repository.ProductRepository import io.paritytech.polkadotapp.feature_products_impl.domain.notifications.ProductNotificationScheduler -import io.paritytech.polkadotapp.test_shared.whenever -import kotlinx.coroutines.runBlocking +import io.paritytech.polkadotapp.feature_products_impl.domain.truapi.TrUAPIHostRuntimeProvider +import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue import org.junit.Test -import org.mockito.Mockito.inOrder -import org.mockito.Mockito.mock -import org.mockito.Mockito.never -import org.mockito.Mockito.verify class UninstallProductUseCaseTest { - private val productRepository: ProductRepository = mock() - private val scheduler: ProductNotificationScheduler = mock() - private val productGameReminder: ProductGameReminder = mock() - private val useCase = UninstallProductUseCase(productRepository, scheduler, productGameReminder) - - private val productId = ProductId.fromStoredValue("acme.dot") + private val repository = mockk(relaxed = true) + private val scheduler = mockk() + private val reminder = mockk(relaxed = true) + private val settings = mockk() + private val provider = mockk() + private val runtime = mockk(relaxed = true) + private val useCase = UninstallProductUseCase(repository, scheduler, reminder, settings, provider) + private val product = ProductId.fromStoredValue("acme.dot") @Test - fun `cancels scheduled notifications before deleting product`() = runBlocking { - givenSchedulerCancelsSuccessfully() - - val result = uninstallProduct() - - assertTrue(result.isSuccess) - assertProductDeletedAfterAlarmsCancelled() + fun `notifications grants and reminders are cleared before deleting a Rust product`() = runTest { + coEvery { scheduler.cancelAllForProduct(product) } returns Result.success(Unit) + every { settings.isTrUAPIRuntimeEnabled() } returns true + coEvery { provider.runtime() } returns Result.success(runtime) + assertEquals(Result.success(Unit), useCase(product)) + coVerifyOrder { + scheduler.cancelAllForProduct(product) + runtime.clearProductState(product.value) + reminder.cancel(product) + repository.deleteProduct(product) + } } @Test - fun `cancels the game reminder before deleting product`() = runBlocking { - givenSchedulerCancelsSuccessfully() - - uninstallProduct() - - val order = inOrder(productGameReminder, productRepository) - order.verify(productGameReminder).cancel(productId) - order.verify(productRepository).deleteProduct(productId) + fun `native product removal does not boot the Rust runtime`() = runTest { + coEvery { scheduler.cancelAllForProduct(product) } returns Result.success(Unit) + every { settings.isTrUAPIRuntimeEnabled() } returns false + assertEquals(Result.success(Unit), useCase(product)) + coVerify(exactly = 0) { provider.runtime() } + coVerifyOrder { reminder.cancel(product); repository.deleteProduct(product) } } @Test - fun `does not delete product when scheduler cancel fails`() = runBlocking { - val failure = IllegalStateException("alarm cancel failed") - givenSchedulerCancelFails(failure) - - val result = uninstallProduct() - - assertTrue(result.isFailure) - assertEquals(failure, result.exceptionOrNull()) - assertProductNotDeleted() - } - - private suspend fun givenSchedulerCancelsSuccessfully() { - whenever(scheduler.cancelAllForProduct(productId)).thenReturn(Result.success(Unit)) - } - - private suspend fun givenSchedulerCancelFails(failure: Exception) { - whenever(scheduler.cancelAllForProduct(productId)).thenReturn(Result.failure(failure)) - } - - private suspend fun uninstallProduct(): Result = useCase(productId) - - private suspend fun assertProductDeletedAfterAlarmsCancelled() { - val order = inOrder(scheduler, productRepository) - order.verify(scheduler).cancelAllForProduct(productId) - order.verify(productRepository).deleteProduct(productId) - } - - private suspend fun assertProductNotDeleted() { - verify(productRepository, never()).deleteProduct(productId) + fun `a cleanup failure leaves the product available to retry removal`() = runTest { + val failure = IllegalStateException("cleanup failed") + for (failsAtGrant in listOf(false, true)) { + every { settings.isTrUAPIRuntimeEnabled() } returns true + coEvery { scheduler.cancelAllForProduct(product) } returns + if (failsAtGrant) Result.success(Unit) else Result.failure(failure) + coEvery { provider.runtime() } returns Result.failure(failure) + assertEquals(failure, useCase(product).exceptionOrNull()) + } + coVerify(exactly = 0) { repository.deleteProduct(any()) } } } diff --git a/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/EncryptedHostCoreStorageTest.kt b/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/EncryptedHostCoreStorageTest.kt new file mode 100644 index 0000000000..4ac7fb37be --- /dev/null +++ b/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/EncryptedHostCoreStorageTest.kt @@ -0,0 +1,50 @@ +package io.paritytech.polkadotapp.feature_products_impl.domain.truapi + +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import io.paritytech.polkadotapp.common.data.storage.preferences.Preferences +import io.paritytech.polkadotapp.common.data.storage.preferences.encrypted.EncryptedPreferences +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import uniffi.truapi.HostRejection + +class EncryptedHostCoreStorageTest { + private val encrypted = mockk() + private val backing = mockk { every { edit().commit() } returns true } + private val storage = EncryptedHostCoreStorage(encrypted, backing) + private val key = byteArrayOf(13) + + @Test + fun `enumeration and deletion do not decrypt a corrupt value`() = runTest { + every { backing.keys() } returns setOf("truapi/core/0d", "unrelated") + every { encrypted.removeKeyCommitted("truapi/core/0d") } returns Unit + assertEquals(listOf(listOf(13)), storage.keys().map { it.toList() }) + storage.clear(key) + verify(exactly = 0) { encrypted.getDecryptedStringOrThrow(any()) } + verify { encrypted.removeKeyCommitted("truapi/core/0d") } + } + + @Test + fun `a failed commit cannot expose the value still visible in memory`() = runTest { + every { backing.edit().commit() } returnsMany listOf(true, false) + every { encrypted.putEncryptedStringCommitted(any(), any()) } throws IllegalStateException("disk full") + every { encrypted.getDecryptedStringOrThrow(any()) } returns "v1:01" + val write = runCatching { storage.write(key, byteArrayOf(1)) }.exceptionOrNull() + val read = runCatching { storage.read(key) }.exceptionOrNull() + assertEquals(listOf(true, true), listOf(write is HostRejection, read is HostRejection)) + verify(exactly = 0) { encrypted.getDecryptedStringOrThrow(any()) } + } + + @Test + fun `corruption and failed removals propagate to the core`() = runTest { + every { encrypted.getDecryptedStringOrThrow(any()) } throws IllegalStateException("invalid ciphertext") + val decryption = runCatching { storage.read(key) }.exceptionOrNull() + every { encrypted.getDecryptedStringOrThrow(any()) } returns "invalid" + val encoding = runCatching { storage.read(key) }.exceptionOrNull() + every { encrypted.removeKeyCommitted(any()) } throws IllegalStateException("disk full") + val removal = runCatching { storage.clear(key) }.exceptionOrNull() + assertEquals(listOf(true, true, true), listOf(decryption is HostRejection, encoding is HostRejection, removal is HostRejection)) + } +} diff --git a/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/EncryptedTrUAPIStorageTest.kt b/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/EncryptedTrUAPIStorageTest.kt index 1d0118ef6e..1d971500ea 100644 --- a/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/EncryptedTrUAPIStorageTest.kt +++ b/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/EncryptedTrUAPIStorageTest.kt @@ -1,5 +1,8 @@ package io.paritytech.polkadotapp.feature_products_impl.domain.truapi +import io.mockk.every +import io.mockk.mockk +import io.paritytech.polkadotapp.common.data.storage.preferences.Preferences import io.paritytech.polkadotapp.common.data.storage.preferences.encrypted.EncryptedPreferences import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf @@ -14,6 +17,7 @@ import uniffi.truapi.HostLocalStorageReadException class EncryptedTrUAPIStorageTest { private val prefs = FakeEncryptedPreferences() + private val backing = mockk { every { edit().commit() } returns true } @Test fun `an empty value is a value, not a miss`() = runTest { @@ -98,16 +102,20 @@ class EncryptedTrUAPIStorageTest { @Test fun `core storage is shared across products by design`() = runTest { - val first = EncryptedHostCoreStorage(prefs) + val first = EncryptedHostCoreStorage(prefs, backing) first.write(byteArrayOf(1), byteArrayOf(42)) - assertArrayEquals(byteArrayOf(42), EncryptedHostCoreStorage(prefs).read(byteArrayOf(1))) + assertArrayEquals(byteArrayOf(42), EncryptedHostCoreStorage(prefs, backing).read(byteArrayOf(1))) } } private class FakeEncryptedPreferences( private val dropWrites: Boolean = false, ) : EncryptedPreferences { + override fun putEncryptedStringCommitted(field: String, value: String) = putEncryptedString(field, value) + override fun getDecryptedStringOrThrow(field: String): String? = getDecryptedString(field) + override fun removeKeyCommitted(field: String) = removeKey(field) + private val values = mutableMapOf() /** Mirrors EncryptionUtil storing "" when it cannot encrypt. */ diff --git a/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridgeTest.kt b/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridgeTest.kt index 71ea09d86b..b008ac4a30 100644 --- a/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridgeTest.kt +++ b/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridgeTest.kt @@ -46,6 +46,7 @@ class ProductTrUAPIHostBridgeTest { hostApiInteractor = hostApiInteractor, chainHttpClient = OkHttpClient(), encryptedPreferences = mock(EncryptedPreferences::class.java), + coreStorage = mockk(), confirmationLauncher = mock(TrUAPIConfirmationLauncher::class.java), appLifecycleObserver = mockk { every { subscribe() } returns emptyFlow() }, dotNsTldProvider = mock(DotNsTldProvider::class.java), diff --git a/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIHostRuntimeProviderTest.kt b/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIHostRuntimeProviderTest.kt index 202a1041e1..65f5769b9c 100644 --- a/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIHostRuntimeProviderTest.kt +++ b/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/TrUAPIHostRuntimeProviderTest.kt @@ -53,7 +53,7 @@ class TrUAPIHostRuntimeProviderTest { localSessionSource = mockk { coEvery { resolve() } returns Result.failure(IllegalStateException()) }, accountRepository = mockk { every { walletAccountFlow() } returns emptyFlow() }, dotNsTldProvider = mockk { coEvery { getTld() } returns Result.success(DotNsTld.parse("paseo")!!) }, - encryptedPreferences = mockk(relaxed = true), + coreStorage = mockk(relaxed = true), chainHttpClient = mockk(relaxed = true), confirmationLauncher = mockk(relaxed = true), appLifecycleObserver = mockk { every { subscribe() } returns emptyFlow() }, diff --git a/hosts/android/tools/jwt-auth/impl/src/test/java/io/paritytech/polkadotapp/tools_jwt_auth_impl/data/JwtAuthTestHelpers.kt b/hosts/android/tools/jwt-auth/impl/src/test/java/io/paritytech/polkadotapp/tools_jwt_auth_impl/data/JwtAuthTestHelpers.kt index 3558618353..a88a5c9443 100644 --- a/hosts/android/tools/jwt-auth/impl/src/test/java/io/paritytech/polkadotapp/tools_jwt_auth_impl/data/JwtAuthTestHelpers.kt +++ b/hosts/android/tools/jwt-auth/impl/src/test/java/io/paritytech/polkadotapp/tools_jwt_auth_impl/data/JwtAuthTestHelpers.kt @@ -27,6 +27,9 @@ internal class FakeTimeProvider(initialNowSeconds: Long = 0L) : TimeProvider { internal fun createFakeTokenStore(): JWTTokenStore { val prefs = object : io.paritytech.polkadotapp.common.data.storage.preferences.encrypted.EncryptedPreferences { + override fun putEncryptedStringCommitted(field: String, value: String) = putEncryptedString(field, value) + override fun getDecryptedStringOrThrow(field: String): String? = getDecryptedString(field) + override fun removeKeyCommitted(field: String) = removeKey(field) private val storage = mutableMapOf() override fun putEncryptedString(field: String, value: String) { storage[field] = value } override fun getDecryptedString(field: String): String? = storage[field] diff --git a/hosts/android/tools/jwt-auth/impl/src/test/java/io/paritytech/polkadotapp/tools_jwt_auth_impl/data/store/JWTTokenStoreTest.kt b/hosts/android/tools/jwt-auth/impl/src/test/java/io/paritytech/polkadotapp/tools_jwt_auth_impl/data/store/JWTTokenStoreTest.kt index a0d718a05f..7ccac8aff8 100644 --- a/hosts/android/tools/jwt-auth/impl/src/test/java/io/paritytech/polkadotapp/tools_jwt_auth_impl/data/store/JWTTokenStoreTest.kt +++ b/hosts/android/tools/jwt-auth/impl/src/test/java/io/paritytech/polkadotapp/tools_jwt_auth_impl/data/store/JWTTokenStoreTest.kt @@ -118,6 +118,10 @@ class JWTTokenStoreTest { } private class FakeEncryptedPreferences : EncryptedPreferences { + override fun putEncryptedStringCommitted(field: String, value: String) = putEncryptedString(field, value) + override fun getDecryptedStringOrThrow(field: String): String? = getDecryptedString(field) + override fun removeKeyCommitted(field: String) = removeKey(field) + private val storage = mutableMapOf() override fun putEncryptedString(field: String, value: String) { diff --git a/hosts/ios/README.md b/hosts/ios/README.md index cc4bc8a8e7..aa700ff1a3 100644 --- a/hosts/ios/README.md +++ b/hosts/ios/README.md @@ -112,6 +112,8 @@ Polkadot iOS is a self-custodial superapp: your keys are created on your phone, Built with **UIKit** and programmatic layout (no Storyboards), using **VIPER** for every feature module: code is split between the main app target and 28 local Swift packages under [`Packages/`](./Packages) with `AppDependencies` as the root package, chain access goes through [substrate-sdk-ios](https://github.com/novasamatech/substrate-sdk-ios) (JSON-RPC, storage subscriptions, extrinsics), and local data lives in CoreData. +With the Rust runtime enabled, the SSO coordinator binds its transport keys to one wallet activation and retains a separate account-holder service for each peer. Ordinary requests share one FIFO queue; cancellation reaches its peer's service immediately. Replies validate the same wallet activation before posting. + This repository ships a **GitHub Actions + Fastlane CI/CD setup** — PR build and tests, plus maintainer-gated TestFlight and Firebase App Distribution. Build-time configuration, signing, the required secrets, and the pipeline itself are diff --git a/hosts/ios/polkadot-app/Common/Services/ServiceCoordinator.swift b/hosts/ios/polkadot-app/Common/Services/ServiceCoordinator.swift index 44f994b3d6..ca813484ce 100644 --- a/hosts/ios/polkadot-app/Common/Services/ServiceCoordinator.swift +++ b/hosts/ios/polkadot-app/Common/Services/ServiceCoordinator.swift @@ -301,7 +301,7 @@ extension ServiceCoordinator { chainRegistry: ChainRegistryFacade.sharedRegistry, entropyManager: RootEntropyManager.shared, settingsManager: SettingsManager.shared, - coreStorage: TrUAPILocalStorage.createCoreLocalStorage(), + coreStorage: CoreStorageBackend.create(), confirmationRouterFacade: ProductRoutersFacade.sso(), logger: logger ) diff --git a/hosts/ios/polkadot-app/Modules/Chat/Service/MessageExchangeCoordinatorFactory.swift b/hosts/ios/polkadot-app/Modules/Chat/Service/MessageExchangeCoordinatorFactory.swift index f0748afe04..46d33134d9 100644 --- a/hosts/ios/polkadot-app/Modules/Chat/Service/MessageExchangeCoordinatorFactory.swift +++ b/hosts/ios/polkadot-app/Modules/Chat/Service/MessageExchangeCoordinatorFactory.swift @@ -8,6 +8,7 @@ import Keystore_iOS import Operation_iOS import Products import Individuality +import StatementStore protocol MessageExchangeCoordinatorMaking { func makeChatCoordinator() throws -> MessageExchangeChatCoordinating @@ -113,10 +114,31 @@ extension MessageExchangeCoordinatorFactory: MessageExchangeCoordinatorMaking { func makeTrUAPIHostCoordinator( runtimeProvider: TrUAPIHostRuntimeProviding ) throws -> MessageExchangeSignInHostCoordinating { - try SSOTruAPICoordinator( - ownKeyId: Chat.Contact.Own.sso(), - serviceFactory: makeSSOServiceFactory(), - runtimeProvider: runtimeProvider + let ownKeyId = try Chat.Contact.Own.sso() + let signer = try ChatSignerManager(entropyManager: entropyManager) + .makeSigner(for: ownKeyId.signKeyId) + let encryptor = try ChatEncryptionManager(entropyManager: entropyManager) + .makeEncryptorFactory(ownEncryptionKeyId: ownKeyId.encryptionKeyId) + + return SSOTruAPICoordinator( + ownKeyId: ownKeyId, + serviceFactory: MessageExchangeServiceFactory( + messageExchangeModeProvider: FixedMessageExchangeModeProvider(mode: .identity), + signManager: ClosureSignerManager { _ in signer }, + encryptionManager: ClosureEncryptionManager { _ in encryptor }, + deviceEncryptionKeyFactory: nil, + messageRouteSelector: { _ in .identity }, + maxStatementSize: Constants.maxSSOStatementSize, + operationQueue: operationQueue, + logger: logger + ), + runtimeProvider: runtimeProvider, + makeAccountHolderService: { + try runtimeProvider.sharedRuntime().openSsoService( + ownStatementAccountId: signer.accountId, + ownEncryptionPublicKey: encryptor.localPublicKey + ) + } ) } diff --git a/hosts/ios/polkadot-app/Modules/DebugSettings/Products/DebugProductsViewModel.swift b/hosts/ios/polkadot-app/Modules/DebugSettings/Products/DebugProductsViewModel.swift index 3f37825833..52a2770851 100644 --- a/hosts/ios/polkadot-app/Modules/DebugSettings/Products/DebugProductsViewModel.swift +++ b/hosts/ios/polkadot-app/Modules/DebugSettings/Products/DebugProductsViewModel.swift @@ -1,3 +1,4 @@ +import Keystore_iOS import Foundation import Operation_iOS import Products @@ -62,6 +63,18 @@ final class DebugProductsViewModel { ) Task { @MainActor [notificationScheduler] in + if SettingsManager.shared.isTrUAPIRuntimeEnabled { + do { + guard let provider: TrUAPIHostRuntimeProviding = RootDependencyLocator.getDependency() else { + downloadError = "Product runtime is unavailable" + return + } + try await provider.sharedRuntime().clearProductState(productId: product.identifier) + } catch { + downloadError = "Product removal failed: \(error.localizedDescription)" + return + } + } try? await notificationScheduler.cancelAll(forProductId: product.identifier) try? await productRepository.saveOperation({ [] }, { [product.identifier] }).asyncExecute() try? await productChatRepository.deleteAllOperation().asyncExecute() diff --git a/hosts/ios/polkadot-app/Modules/PolkadotSignIn/Services/RequestHandlers/SSOTrUAPIRequestHandler.swift b/hosts/ios/polkadot-app/Modules/PolkadotSignIn/Services/RequestHandlers/SSOTrUAPIRequestHandler.swift index 8592de0cd4..56e138a58e 100644 --- a/hosts/ios/polkadot-app/Modules/PolkadotSignIn/Services/RequestHandlers/SSOTrUAPIRequestHandler.swift +++ b/hosts/ios/polkadot-app/Modules/PolkadotSignIn/Services/RequestHandlers/SSOTrUAPIRequestHandler.swift @@ -1,78 +1,48 @@ import Foundation import TrUAPIHost -// MARK: - Runtime Abstraction +struct SSOTrUAPIRequest: HostMessageIdentifiable { + let message: SSORawHostMessage + let service: any SsoAccountHolderServiceProtocol -protocol SSOTruAPIRuntimeHandling: AnyObject, Sendable { - func handleSsoRequest(message: Data) async throws -> SsoRequestOutcome - func prepareDisconnectRequest() -> Data + var messageId: String { message.messageId } } -extension TrUAPIHostRuntime: SSOTruAPIRuntimeHandling {} - -// MARK: - Request Handler - final class SSOTrUAPIRequestHandler: SSORequestHandling { - private let runtimeProvider: TrUAPIHostRuntimeProviding private let sender: any PolkadotHostMessageSending private let disconnectApplier: SSORemoteDisconnectApplying private let logger: LoggerProtocol init( - runtimeProvider: TrUAPIHostRuntimeProviding, sender: any PolkadotHostMessageSending, disconnectApplier: SSORemoteDisconnectApplying, logger: LoggerProtocol = Logger.shared ) { - self.runtimeProvider = runtimeProvider self.sender = sender self.disconnectApplier = disconnectApplier self.logger = logger } - func canHandle(_: SSORawHostMessage) -> Bool { + func canHandle(_: SSOTrUAPIRequest) -> Bool { true } - func handle(message: SSORawHostMessage, from host: PolkadotSignInHost) async { - let messageId = message.messageId - - let runtime: any SSOTruAPIRuntimeHandling - do { - runtime = try runtimeProvider.sharedRuntime() - } catch { - logger.error("sharedRuntime unavailable (pre-onboarding?); dropping \(messageId): \(error)") - return - } - + func handle(message request: SSOTrUAPIRequest, from host: PolkadotSignInHost) async { + let messageId = request.messageId do { - let outcome = try await runtime.handleSsoRequest(message: message.rawBytes) - await apply(outcome: outcome, messageId: messageId, to: host) + let outcome = try await request.service.handleSsoRequest(message: request.message.rawBytes) + switch outcome { + case let .response(responseBytes): + let response = try SSORawHostMessage(rawBytes: responseBytes) + try await sender.postMessage(response, to: host) + case .disconnected: + logger.info("Runtime disconnected for host \(host.name); running teardown") + await disconnectApplier.applyDisconnect(from: host) + case .ignored: + logger.debug("Runtime ignored message \(messageId)") + } } catch { logger.error("handleSsoRequest error for \(messageId): \(error)") } } } - -private extension SSOTrUAPIRequestHandler { - func apply(outcome: SsoRequestOutcome, messageId: String, to host: PolkadotSignInHost) async { - switch outcome { - case let .response(responseBytes): - await postResponse(responseBytes, messageId: messageId, to: host) - case .disconnected: - logger.info("Runtime disconnected for host \(host.name); running teardown") - await disconnectApplier.applyDisconnect(from: host) - case .ignored: - logger.debug("Runtime ignored message \(messageId)") - } - } - - func postResponse(_ bytes: Data, messageId: String, to host: PolkadotSignInHost) async { - logger.debug("Runtime response for \(messageId); posting back to \(host.name)") - do { - try await sender.postMessage(SSORawHostMessage(rawBytes: bytes), to: host) - } catch { - logger.error("Failed to post response for \(messageId): \(error)") - } - } -} diff --git a/hosts/ios/polkadot-app/Modules/PolkadotSignIn/Services/SSOTrUAPIMessageHandler.swift b/hosts/ios/polkadot-app/Modules/PolkadotSignIn/Services/SSOTrUAPIMessageHandler.swift index 814f0fe6ae..04071cbedd 100644 --- a/hosts/ios/polkadot-app/Modules/PolkadotSignIn/Services/SSOTrUAPIMessageHandler.swift +++ b/hosts/ios/polkadot-app/Modules/PolkadotSignIn/Services/SSOTrUAPIMessageHandler.swift @@ -1,13 +1,14 @@ import Foundation import Operation_iOS +import TrUAPIHost final class SSOTrUAPIMessageHandler { - private let processingContext: SSORequestProcessingContext + private let processingContext: SSORequestProcessingContext private let handledRequestRepository: AnyDataProviderRepository private let logger: LoggerProtocol init( - processingContext: SSORequestProcessingContext, + processingContext: SSORequestProcessingContext, handledRequestRepositoryFactory: SSOHandledRequestRepositoryMaking = SSOHandledRequestRepositoryFactory(), logger: LoggerProtocol = Logger.shared ) { @@ -17,17 +18,26 @@ final class SSOTrUAPIMessageHandler { } } -extension SSOTrUAPIMessageHandler: PolkadotHostMessageHandling { +extension SSOTrUAPIMessageHandler { func handleMessages( _ messages: [SSORawHostMessage], - from host: PolkadotSignInHost + from host: PolkadotSignInHost, + service: any SsoAccountHolderServiceProtocol ) async { let newMessages = await filterAlreadyHandled(messages) logger.info("New raw messages: \(newMessages.count)") for message in newMessages { - await processingContext.enqueue(message: message, from: host) + do { + guard try service.handleSsoControl(message: message.rawBytes) == nil else { continue } + await processingContext.enqueue( + message: SSOTrUAPIRequest(message: message, service: service), + from: host + ) + } catch { + logger.error("Invalid SSO control for \(message.messageId): \(error)") + } } await markMessagesAsHandled(newMessages) diff --git a/hosts/ios/polkadot-app/Modules/PolkadotSignIn/Services/SSOTruAPICoordinator.swift b/hosts/ios/polkadot-app/Modules/PolkadotSignIn/Services/SSOTruAPICoordinator.swift index b80ec63a65..beb7f46213 100644 --- a/hosts/ios/polkadot-app/Modules/PolkadotSignIn/Services/SSOTruAPICoordinator.swift +++ b/hosts/ios/polkadot-app/Modules/PolkadotSignIn/Services/SSOTruAPICoordinator.swift @@ -21,15 +21,16 @@ final class SSOTruAPICoordinator { private let hostRepository: AnyDataProviderRepository private let runtimeProvider: TrUAPIHostRuntimeProviding private let rawSender: PolkadotHostMessageSender - private let messageHandler: any PolkadotHostMessageHandling + private let messageHandler: SSOTrUAPIMessageHandler private let logger: LoggerProtocol - private let state = State() + private let state: State init( ownKeyId: Chat.Contact.Own, serviceFactory: MessageExchageServiceMaking, runtimeProvider: TrUAPIHostRuntimeProviding, + makeAccountHolderService: @escaping () throws -> SsoAccountHolderService, chainId: ChainModel.Id = AppConfig.Chains.chatChain, chainRegistry: ChainRegistryProtocol = ChainRegistryFacade.sharedRegistry, hostsDataProviderFactory: PolkadotSignInHostDataProviderMaking = PolkadotSignInHostDataProviderFactory(), @@ -40,6 +41,7 @@ final class SSOTruAPICoordinator { self.ownKeyId = ownKeyId self.serviceFactory = serviceFactory self.runtimeProvider = runtimeProvider + state = State(makeService: makeAccountHolderService, logger: logger) self.chainId = chainId self.chainRegistry = chainRegistry self.hostsDataProviderFactory = hostsDataProviderFactory @@ -50,13 +52,12 @@ final class SSOTruAPICoordinator { rawSender = sender let requestHandler = SSOTrUAPIRequestHandler( - runtimeProvider: runtimeProvider, sender: sender, disconnectApplier: disconnectApplier, logger: logger ) - let processingContext = SSORequestProcessingContext( + let processingContext = SSORequestProcessingContext( handlers: [requestHandler], logger: logger ) @@ -126,14 +127,18 @@ extension SSOTruAPICoordinator { ) async { completion(.success) - guard let host = await state.host(forAccountId: peer.accountId) else { + guard let entry = await state.peer(matching: peer) else { logger.warning("Missing active host for peer \(peer.accountId.toHex())") return } - logger.info("Will handle \(messages.count) raw message(s) for host \(host.name)") + logger.info("Will handle \(messages.count) raw message(s) for host \(entry.host.name)") - await messageHandler.handleMessages(messages.map(\.message), from: host) + await messageHandler.handleMessages( + messages.map(\.message), + from: entry.host, + service: entry.service + ) } func handleDidPostMessages( @@ -168,7 +173,6 @@ private extension SSOTruAPICoordinator { func handleNewHosts(_ hosts: [PolkadotSignInHost]) async { var requests = Set() - var hostsByAccountId = [Data: PolkadotSignInHost]() for host in hosts { let request = MessageExchange.SessionRequest( @@ -181,33 +185,67 @@ private extension SSOTruAPICoordinator { ) ) requests.insert(request) - hostsByAccountId[host.accountId] = host } logger.debug("Setting \(requests.count) host(s) to TrUAPI exchange service") - await state.setHostsByAccountId(hostsByAccountId) + await state.setHosts(hosts) await state.updateSessionRequests(requests) } } // MARK: - State -extension SSOTruAPICoordinator { +private extension SSOTruAPICoordinator { actor State { - private var exchangeService: AnyMessageExchangeService? + struct Peer { + let host: PolkadotSignInHost + let service: SsoAccountHolderService + } + + private let makeService: () throws -> SsoAccountHolderService + private let logger: LoggerProtocol private var hostsByAccountId = [Data: PolkadotSignInHost]() + private var exchangeService: AnyMessageExchangeService? + private var peersByAccountId = [Data: Peer]() private var hostSubscriptionTask: Task? + init(makeService: @escaping () throws -> SsoAccountHolderService, logger: LoggerProtocol) { + self.makeService = makeService + self.logger = logger + } + func host(forAccountId accountId: Data) -> PolkadotSignInHost? { hostsByAccountId[accountId] } + func peer(matching peer: MessageExchange.Peer) -> Peer? { + guard let host = hostsByAccountId[peer.accountId], host.publicKey == peer.publicKey else { + return nil + } + if let entry = peersByAccountId[peer.accountId] { + return entry + } + do { + let entry = Peer(host: host, service: try makeService()) + peersByAccountId[peer.accountId] = entry + return entry + } catch { + logger.error("SSO binding failed for \(host.name): \(error)") + return nil + } + } + func setExchangeService(_ value: AnyMessageExchangeService?) { exchangeService = value } - func setHostsByAccountId(_ value: [Data: PolkadotSignInHost]) { - hostsByAccountId = value + func setHosts(_ hosts: [PolkadotSignInHost]) { + hostsByAccountId = hosts.reduce(into: [:]) { $0[$1.accountId] = $1 } + peersByAccountId = peersByAccountId.reduce(into: [:]) { peers, entry in + if let host = hostsByAccountId[entry.key], host.publicKey == entry.value.host.publicKey { + peers[entry.key] = Peer(host: host, service: entry.value.service) + } + } } func setHostSubscriptionTask(_ value: Task?) { @@ -220,7 +258,7 @@ extension SSOTruAPICoordinator { func reset() { exchangeService?.updateSessions([]) - hostsByAccountId = [:] + peersByAccountId = [:] hostSubscriptionTask?.cancel() hostSubscriptionTask = nil } diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustHostRuntimeBridge.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustHostRuntimeBridge.swift index 7b4959d11e..a7bc72e1dc 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustHostRuntimeBridge.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustHostRuntimeBridge.swift @@ -25,7 +25,7 @@ final class RustHostRuntimeBridge: HostBridge, @unchecked Sendable { init( chainRegistry: ChainRegistryProtocol, - coreStorage: TrUAPILocalStoring, + coreStorage: HostCoreStorageBackend, chainConnections: TrUAPIChainConnecting, confirmationPresenter: TrUAPIConfirmationPresenting, workerManager: (any TrUAPIWorkerManaging)? = nil, @@ -36,7 +36,7 @@ final class RustHostRuntimeBridge: HostBridge, @unchecked Sendable { self.confirmationPresenter = confirmationPresenter self.workerManager = workerManager self.logger = logger - self.coreStorage = CoreStorageBackend(storage: coreStorage) + self.coreStorage = coreStorage storage = EmptyHostStorageBackend() chainConnections.eventHandler = self } diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustProductExecutionBridge.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustProductExecutionBridge.swift index 9b4f9c2806..3acfb90d49 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustProductExecutionBridge.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustProductExecutionBridge.swift @@ -28,7 +28,7 @@ class RustProductExecutionBridge: HostBridge, @unchecked Sendable { let chainRegistry: ChainRegistryProtocol let chainConnections: TrUAPIChainConnecting let productStorage: TrUAPILocalStoring - let coreStorage: TrUAPILocalStoring + let coreStorage: HostCoreStorageBackend let confirmationPresenter: TrUAPIConfirmationPresenting let preimageCache: TrUAPIPreimageLookuping let hostProvider: ProductHostProviding @@ -51,7 +51,7 @@ class RustProductExecutionBridge: HostBridge, @unchecked Sendable { self.dependencies = dependencies self.cardFace = cardFace storage = ProductStorageBackend(storage: dependencies.productStorage) - coreStorage = CoreStorageBackend(storage: dependencies.coreStorage) + coreStorage = dependencies.coreStorage } /// Attach the opened execution so callbacks can notify it in place. diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeEnvironment.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeEnvironment.swift index 2250328679..e3957ea01c 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeEnvironment.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeEnvironment.swift @@ -142,7 +142,7 @@ private extension RustRuntimeEnvironment { chainRegistry: chainRegistry, chainConnections: chainConnections, productStorage: TrUAPILocalStorage.createProductLocalStorage(productId: productId), - coreStorage: TrUAPILocalStorage.createCoreLocalStorage(), + coreStorage: CoreStorageBackend.create(), confirmationPresenter: TrUAPIConfirmationPresenter(routerFacade: routers), preimageCache: TrUAPIPreimageCache { [logger, ipfsFetcher] key in do { diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Storage/HostStorageBackends.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Storage/HostStorageBackends.swift index 1a57e1c0fc..84c3ab344b 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Storage/HostStorageBackends.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Storage/HostStorageBackends.swift @@ -1,4 +1,7 @@ import Foundation +import Keystore_iOS +import Products +import Security import TrUAPIHost import SubstrateSdk @@ -25,27 +28,111 @@ final class ProductStorageBackend: HostStorageBackend, @unchecked Sendable { } } -/// Adapts the host-global core ``TrUAPILocalStoring`` to -/// `HostCoreStorageBackend`. Core keys are SCALE-encoded `Data`; they are -/// hex-encoded for the underlying String-keyed store. Plain Swift errors -/// surface as `HostRejection`. +/// Protects secret slots in the installation’s Keychain namespace. final class CoreStorageBackend: HostCoreStorageBackend, @unchecked Sendable { + private static let lock = NSLock() private let storage: TrUAPILocalStoring + private let keychain: KeystoreProtocol + private let defaults: UserDefaults + private let keychainPrefix: String - init(storage: TrUAPILocalStoring) { - self.storage = storage + private init(defaults: UserDefaults, keychain: KeystoreProtocol) { + self.storage = TrUAPILocalStorage.createCoreLocalStorage(defaults: defaults) + self.keychain = keychain + self.defaults = defaults + let installId = ProductResourceStoreIdStore(userDefaults: defaults).getStoreId() + self.keychainPrefix = "io.polkadotapp.truapi.core.\(installId)." + } + + static func create( + defaults: UserDefaults = .standard, + keychain: KeystoreProtocol = Keychain() + ) -> CoreStorageBackend { + Self.lock.withLock { CoreStorageBackend(defaults: defaults, keychain: keychain) } } func read(key: Data) throws -> Data? { - try withHostRejection { try storage.read(key: key.toHex()) } + try withStorage { + guard isProtected(key) else { return try storage.read(key: key.toHex()) } + do { + return try keychain.fetchKey(for: identifier(key)) + } catch KeystoreError.noKeyFound { + return try moveLegacySecret(key) + } + } } func write(key: Data, value: Data) throws { - try withHostRejection { try storage.write(key: key.toHex(), value: value) } + try withStorage { + if isProtected(key) { + try keychain.saveKey(value, with: identifier(key)) + try storage.clear(key: key.toHex()) + } else { + try storage.write(key: key.toHex(), value: value) + } + } } func clear(key: Data) throws { - try withHostRejection { try storage.clear(key: key.toHex()) } + try withStorage { + if isProtected(key) { + try keychain.deleteKeyIfExists(for: identifier(key)) + } + try storage.clear(key: key.toHex()) + } + } + + private func withStorage(_ operation: () throws -> T) throws -> T { + try withHostRejection { + try Self.lock.withLock { + try Task.checkCancellation() + return try operation() + } + } + } + + private func isProtected(_ key: Data) -> Bool { + (try? coreStorageKeyDescription(encoded: key).protection) != .public + } + + private func identifier(_ key: Data) -> String { + "\(keychainPrefix)\(key.toHex())" + } + + /// Secrets written before they were protected stay in UserDefaults until first read. + private func moveLegacySecret(_ key: Data) throws -> Data? { + guard let value = try storage.read(key: key.toHex()) else { return nil } + try keychain.saveKey(value, with: identifier(key)) + try storage.clear(key: key.toHex()) + return value + } + + func keys() throws -> [Data] { + try withStorage { + var keys = defaults.dictionaryRepresentation().keys.compactMap { name -> Data? in + let prefix = "io.polkadotapp.truapi.core." + guard name.hasPrefix(prefix) else { return nil } + return try? Data(hexString: String(name.dropFirst(prefix.count))) + } + let query: [String: Any] = [ + kSecClass as String: kSecClassKey, + kSecMatchLimit as String: kSecMatchLimitAll, + kSecReturnAttributes as String: true + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status != errSecItemNotFound else { return keys } + guard status == errSecSuccess else { throw KeystoreError.unexpectedFail } + for item in result as? [[String: Any]] ?? [] { + guard let tag = item[kSecAttrApplicationTag as String] as? Data, + let name = String(data: tag, encoding: .utf8), + name.hasPrefix(keychainPrefix), + let key = try? Data(hexString: String(name.dropFirst(keychainPrefix.count))) + else { continue } + keys.append(key) + } + return keys + } } } diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIHostRuntimeProvider.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIHostRuntimeProvider.swift index 7efa8853c2..63c1118174 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIHostRuntimeProvider.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIHostRuntimeProvider.swift @@ -40,7 +40,7 @@ final class TrUAPIHostRuntimeProvider: TrUAPIHostRuntimeProviding, @unchecked Se private let chainRegistry: ChainRegistryProtocol private let entropyManager: RootEntropyManaging private let settingsManager: SettingsManagerProtocol - private let coreStorage: TrUAPILocalStoring + private let coreStorage: HostCoreStorageBackend private let confirmationRouterFacade: ProductRoutersFacadeProtocol private let tldProvider: DotNsTldProviding private let logger: LoggerProtocol @@ -57,7 +57,7 @@ final class TrUAPIHostRuntimeProvider: TrUAPIHostRuntimeProviding, @unchecked Se chainRegistry: ChainRegistryProtocol, entropyManager: RootEntropyManaging, settingsManager: SettingsManagerProtocol, - coreStorage: TrUAPILocalStoring, + coreStorage: HostCoreStorageBackend, confirmationRouterFacade: ProductRoutersFacadeProtocol, tldProvider: DotNsTldProviding = DotNsTldProviderFacade.shared, logger: LoggerProtocol diff --git a/hosts/ios/polkadot-appTests/PolkadotSignIn/SSOTrUAPIMessageHandlerTests.swift b/hosts/ios/polkadot-appTests/PolkadotSignIn/SSOTrUAPIMessageHandlerTests.swift index 60966b0dd4..107ab700ac 100644 --- a/hosts/ios/polkadot-appTests/PolkadotSignIn/SSOTrUAPIMessageHandlerTests.swift +++ b/hosts/ios/polkadot-appTests/PolkadotSignIn/SSOTrUAPIMessageHandlerTests.swift @@ -2,6 +2,8 @@ import Foundation import Testing import Operation_iOS import SubstrateSdk +import TrUAPIHost +import MessageExchangeKit @testable import polkadot_app @@ -29,15 +31,19 @@ private final class InMemoryHandledRequestRepositoryFactory: SSOHandledRequestRe /// suspend until a target count has been handled (the context processes on a /// detached task, so `handleMessages` returning does not imply completion). private actor SpyRequestHandler: SSORequestHandling { - typealias Message = SSORawHostMessage + typealias Message = SSOTrUAPIRequest private(set) var handledIds: [String] = [] private var waiters: [(threshold: Int, continuation: CheckedContinuation)] = [] - nonisolated func canHandle(_: SSORawHostMessage) -> Bool { true } + nonisolated func canHandle(_: SSOTrUAPIRequest) -> Bool { true } - func handle(message: SSORawHostMessage, from _: PolkadotSignInHost) async { - handledIds.append(message.messageId) + func handle(message: SSOTrUAPIRequest, from _: PolkadotSignInHost) async { + record(message.messageId) + } + + func record(_ messageId: String) { + handledIds.append(messageId) resumeSatisfiedWaiters() } @@ -57,6 +63,39 @@ private actor SpyRequestHandler: SSORequestHandling { } } +private final class StubAccountHolderService: SsoAccountHolderServiceProtocol, @unchecked Sendable { + var control: (Data) throws -> SsoRequestOutcome? = { _ in nil } + var request: (Data) async throws -> SsoRequestOutcome = { _ in .ignored } + + func handleSsoControl(message: Data) throws -> SsoRequestOutcome? { + try control(message) + } + + func handleSsoRequest(message: Data) async throws -> SsoRequestOutcome { + try await request(message) + } +} + +private actor RawMessageSender: PolkadotHostMessageSending { + private(set) var postedMessages: [SSORawHostMessage] = [] + + func setExchangeService(_: AnyMessageExchangeService) async {} + + func postMessage(_ message: SSORawHostMessage, to _: PolkadotSignInHost) async throws { + postedMessages.append(message) + } + + func handleDidPostMessages(_: [SSORawHostMessage], withError _: Error?) async {} + + func cancelPendingMessages(excluding _: Set) async {} +} + +private struct UnexpectedDisconnectApplier: SSORemoteDisconnectApplying { + func applyDisconnect(from _: PolkadotSignInHost) async { + Issue.record("The request must not disconnect its peer") + } +} + // MARK: - Helpers private func makeHost(accountId: Data = Data(repeating: 0x01, count: 32)) -> PolkadotSignInHost { @@ -80,7 +119,7 @@ private func makeMessageHandler( spy: SpyRequestHandler, repositoryFactory: InMemoryHandledRequestRepositoryFactory ) -> SSOTrUAPIMessageHandler { - let context = SSORequestProcessingContext( + let context = SSORequestProcessingContext( handlers: [spy], logger: MockLogger() ) @@ -104,7 +143,7 @@ struct SSOTrUAPIMessageHandlerTests { let first = try makeRawMessage(messageId: "fresh-1") let second = try makeRawMessage(messageId: "fresh-2") - await handler.handleMessages([first, second], from: makeHost()) + await handler.handleMessages([first, second], from: makeHost(), service: StubAccountHolderService()) await spy.waitForHandled(count: 2) let handledIds = await spy.handledIds @@ -125,10 +164,102 @@ struct SSOTrUAPIMessageHandlerTests { let duplicate = try makeRawMessage(messageId: "dup") let fresh = try makeRawMessage(messageId: "fresh") - await handler.handleMessages([duplicate, fresh], from: makeHost()) + await handler.handleMessages([duplicate, fresh], from: makeHost(), service: StubAccountHolderService()) await spy.waitForHandled(count: 1) let handledIds = await spy.handledIds #expect(handledIds == ["fresh"]) } + + @Test( + "Cancel reaches an active request while ordinary requests keep FIFO order across peers", + .timeLimit(.minutes(1)) + ) + func cancellationBypassesTheSharedQueue() async throws { + let events = SpyRequestHandler() + let sender = RawMessageSender() + let firstService = StubAccountHolderService() + let secondService = StubAccountHolderService() + let cancellation = AsyncStream.makeStream(of: Void.self) + var cancelWasHandled = false + firstService.control = { message in + guard try SSORawHostMessage(rawBytes: message).messageId == "cancel" else { return nil } + cancelWasHandled = true + cancellation.continuation.yield(()) + cancellation.continuation.finish() + return .ignored + } + firstService.request = { message in + let id = try SSORawHostMessage(rawBytes: message).messageId + if id == "first" { + await events.record("first-started") + var iterator = cancellation.stream.makeAsyncIterator() + _ = await iterator.next() + await events.record("first-cancelled") + } else { + await events.record("first-peer:\(id)") + } + return .ignored + } + secondService.request = { message in + let id = try SSORawHostMessage(rawBytes: message).messageId + await events.record("second-peer:\(id)") + return .ignored + } + let requestHandler = SSOTrUAPIRequestHandler( + sender: sender, + disconnectApplier: UnexpectedDisconnectApplier(), + logger: MockLogger() + ) + let repository = InMemoryHandledRequestRepositoryFactory() + let handler = SSOTrUAPIMessageHandler( + processingContext: SSORequestProcessingContext(handlers: [requestHandler], logger: MockLogger()), + handledRequestRepositoryFactory: repository, + logger: MockLogger() + ) + try await handler.handleMessages( + [makeRawMessage(messageId: "first")], + from: makeHost(), + service: firstService + ) + await events.waitForHandled(count: 1) + try await handler.handleMessages( + [makeRawMessage(messageId: "second")], + from: makeHost(accountId: Data(repeating: 3, count: 32)), + service: secondService + ) + try await handler.handleMessages( + [makeRawMessage(messageId: "third"), makeRawMessage(messageId: "cancel")], + from: makeHost(), + service: firstService + ) + #expect(cancelWasHandled) + if !cancelWasHandled { cancellation.continuation.finish() } + await events.waitForHandled(count: 4) + #expect(await events.handledIds == [ + "first-started", + "first-cancelled", + "second-peer:second", + "first-peer:third" + ]) + let marked = try await repository.fetchAll().map(\.messageId) + #expect(Set(marked) == ["first", "second", "third", "cancel"]) + } + + @Test("Wallet results, including not-connected replies, reach the peer") + func walletResponseIsPosted() async throws { + let service = StubAccountHolderService() + let message = try makeRawMessage(messageId: "response") + service.request = { _ in .response(message: message.rawBytes) } + let sender = RawMessageSender() + let handler = SSOTrUAPIRequestHandler( + sender: sender, + disconnectApplier: UnexpectedDisconnectApplier(), + logger: MockLogger() + ) + + await handler.handle(message: SSOTrUAPIRequest(message: message, service: service), from: makeHost()) + + #expect(await sender.postedMessages == [message]) + } } diff --git a/hosts/ios/polkadot-appTests/TrUAPI/CoreStorageBackendTests.swift b/hosts/ios/polkadot-appTests/TrUAPI/CoreStorageBackendTests.swift new file mode 100644 index 0000000000..c840370907 --- /dev/null +++ b/hosts/ios/polkadot-appTests/TrUAPI/CoreStorageBackendTests.swift @@ -0,0 +1,98 @@ +import Foundation +import Keystore_iOS +import SubstrateSdk +import Testing +import TrUAPIHost +@testable import polkadot_app + +struct CoreStorageBackendTests { + @Test func protectedGrantsUseKeychainAcrossBackendInstances() throws { + let suite = "io.polkadotapp.tests.core-storage.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let keychain = InMemoryKeychain() + let storage = CoreStorageBackend.create(defaults: defaults, keychain: keychain) + let secret = Data([9]) + let ordinary = Data([4]) + let value = Data([1, 2, 3]) + try storage.write(key: secret, value: value) + try storage.write(key: ordinary, value: value) + let reopened = CoreStorageBackend.create(defaults: defaults, keychain: keychain) + #expect(try reopened.read(key: secret) == value) + #expect(defaults.data(forKey: "io.polkadotapp.truapi.core.\(secret.toHex())") == nil) + #expect(defaults.data(forKey: "io.polkadotapp.truapi.core.\(ordinary.toHex())") == value) + try reopened.clear(key: secret) + #expect(try storage.read(key: secret) == nil) + #expect(try storage.read(key: ordinary) == value) + } + + @Test func secretsStoredBeforeProtectionSurviveTheUpgradeAndLeaveUserDefaults() throws { + let suite = "io.polkadotapp.tests.core-storage.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let keychain = InMemoryKeychain() + let secret = Data([9]) + let legacyName = "io.polkadotapp.truapi.core.\(secret.toHex())" + defaults.set(Data([1, 2, 3]), forKey: legacyName) + let storage = CoreStorageBackend.create(defaults: defaults, keychain: keychain) + #expect(try storage.read(key: secret) == Data([1, 2, 3])) + #expect(defaults.data(forKey: legacyName) == nil) + let reopened = CoreStorageBackend.create(defaults: defaults, keychain: keychain) + #expect(try reopened.read(key: secret) == Data([1, 2, 3])) + } + + @Test func clearingASecretAlsoRemovesItsUnmigratedCopy() throws { + let suite = "io.polkadotapp.tests.core-storage.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let secret = Data([9]) + let legacyName = "io.polkadotapp.truapi.core.\(secret.toHex())" + defaults.set(Data([1]), forKey: legacyName) + let storage = CoreStorageBackend.create(defaults: defaults, keychain: InMemoryKeychain()) + try storage.clear(key: secret) + #expect(defaults.data(forKey: legacyName) == nil) + #expect(try storage.read(key: secret) == nil) + } + + @Test func reinstallCannotRestoreAnotherInstallSecret() throws { + let keychain = InMemoryKeychain() + let suite = "io.polkadotapp.tests.core-storage.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let original = CoreStorageBackend.create(defaults: defaults, keychain: keychain) + let key = Data([9]) + try original.write(key: key, value: Data([1])) + defaults.removePersistentDomain(forName: suite) + let reinstalled = CoreStorageBackend.create(defaults: defaults, keychain: keychain) + #expect(try reinstalled.read(key: key) == nil) + #expect(try original.read(key: key) == Data([1])) + } + + @Test func enumerationIncludesProtectedKeysWithoutReadingTheirValues() throws { + let suite = "io.polkadotapp.tests.core-storage.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let storage = CoreStorageBackend.create(defaults: defaults) + let key = Data([9]) + try storage.write(key: key, value: Data([255])) + defer { try? storage.clear(key: key) } + #expect(try storage.keys() == [key]) + } + + @Test func keychainFailuresCannotBecomeMissingOrSuccessfulWrites() { + let storage = CoreStorageBackend.create(keychain: UnavailableKeychain()) + let key = Data([255]) + #expect(throws: HostRejection.self) { try storage.read(key: key) } + #expect(throws: HostRejection.self) { try storage.write(key: key, value: Data([1])) } + #expect(throws: HostRejection.self) { try storage.clear(key: key) } + } +} + +private final class UnavailableKeychain: KeystoreProtocol { + enum Failure: Error { case unavailable } + func addKey(_: Data, with _: String) throws { throw Failure.unavailable } + func updateKey(_: Data, with _: String) throws { throw Failure.unavailable } + func fetchKey(for _: String) throws -> Data { throw Failure.unavailable } + func checkKey(for _: String) throws -> Bool { throw Failure.unavailable } + func deleteKey(for _: String) throws { throw Failure.unavailable } +} diff --git a/hosts/ios/polkadot-appTests/TrUAPI/Mocks/ChatBridgeDependenciesFactory.swift b/hosts/ios/polkadot-appTests/TrUAPI/Mocks/ChatBridgeDependenciesFactory.swift index 48709954e2..6c1f06b3f4 100644 --- a/hosts/ios/polkadot-appTests/TrUAPI/Mocks/ChatBridgeDependenciesFactory.swift +++ b/hosts/ios/polkadot-appTests/TrUAPI/Mocks/ChatBridgeDependenciesFactory.swift @@ -1,3 +1,4 @@ +import Keystore_iOS import ChainRegistry import Foundation import Products @@ -29,7 +30,7 @@ func makeChatBridgeDependencies( productId: productId, defaults: defaults ), - coreStorage: TrUAPILocalStorage.createCoreLocalStorage(defaults: defaults), + coreStorage: CoreStorageBackend.create(defaults: defaults, keychain: InMemoryKeychain()), confirmationPresenter: MockConfirmationPresenter(), preimageCache: TrUAPIPreimageCache { _ in nil }, hostProvider: InertHostProvider(), diff --git a/hosts/ios/polkadot-appTests/TrUAPI/RustHostRuntimeBridgeTests.swift b/hosts/ios/polkadot-appTests/TrUAPI/RustHostRuntimeBridgeTests.swift index 6f4e26571a..e95f7d7b90 100644 --- a/hosts/ios/polkadot-appTests/TrUAPI/RustHostRuntimeBridgeTests.swift +++ b/hosts/ios/polkadot-appTests/TrUAPI/RustHostRuntimeBridgeTests.swift @@ -1,3 +1,4 @@ +import Keystore_iOS import Foundation import Testing import ChainRegistry @@ -25,7 +26,7 @@ private func makeHostBridge( ) return RustHostRuntimeBridge( chainRegistry: chainRegistry, - coreStorage: TrUAPILocalStorage.createCoreLocalStorage(defaults: makeHostDefaults()), + coreStorage: CoreStorageBackend.create(defaults: makeHostDefaults(), keychain: InMemoryKeychain()), chainConnections: chainConnections, confirmationPresenter: confirmationPresenter, logger: Logger.shared @@ -111,16 +112,6 @@ struct RustHostRuntimeBridgeTests { #expect(presenter.receivedRequesterName == "host") } - /// Core storage is the real host-global backend: writes round-trip. - @Test func coreStorageRoundTrips() throws { - let bridge = makeHostBridge() - let key = Data([0x0A, 0x0B]) - let value = Data([0x10, 0x20, 0x30]) - - try bridge.coreStorage.write(key: key, value: value) - #expect(try bridge.coreStorage.read(key: key) == value) - } - /// Product KV has no host-level scope: reads miss and writes are dropped. @Test func productStorageIsEmptyAtHostLevel() throws { let bridge = makeHostBridge() diff --git a/hosts/ios/polkadot-appTests/TrUAPI/RustRuntimeBridgeTests.swift b/hosts/ios/polkadot-appTests/TrUAPI/RustRuntimeBridgeTests.swift index ba86c9cf1d..1202e13fd7 100644 --- a/hosts/ios/polkadot-appTests/TrUAPI/RustRuntimeBridgeTests.swift +++ b/hosts/ios/polkadot-appTests/TrUAPI/RustRuntimeBridgeTests.swift @@ -108,7 +108,7 @@ private func makeBridge( chainRegistry: chainRegistry, chainConnections: pool, productStorage: productStorage, - coreStorage: TrUAPILocalStorage.createCoreLocalStorage(defaults: makeTestDefaults()), + coreStorage: CoreStorageBackend.create(defaults: makeTestDefaults(), keychain: InMemoryKeychain()), confirmationPresenter: confirmationPresenter, preimageCache: preimageCache, hostProvider: hostProvider, @@ -605,7 +605,7 @@ struct RustRuntimeBridgeTests { productId: "test.dot", defaults: makeTestDefaults() ), - coreStorage: TrUAPILocalStorage.createCoreLocalStorage(defaults: makeTestDefaults()), + coreStorage: CoreStorageBackend.create(defaults: makeTestDefaults(), keychain: InMemoryKeychain()), confirmationPresenter: MockConfirmationPresenter(), preimageCache: TrUAPIPreimageCache { _ in nil }, hostProvider: StubHostProvider(), @@ -675,17 +675,4 @@ struct RustRuntimeBridgeTests { #expect(execution.themeChanges.last?.name == ThemeName.custom(themeManager.theme.id)) #expect(current.variant == ThemeVariant.light) } - - /// Core storage keys (`Data`) are hex-encoded for the underlying store; - /// a write then read round-trips through the hex key. - @Test func coreStorageBackendHexEncodesKeys() throws { - let bridge = makeBridge() - let key = Data([0xDE, 0xAD]) - let value = Data([0x01, 0x02, 0x03]) - - try bridge.coreStorage.write(key: key, value: value) - let read = try bridge.coreStorage.read(key: key) - - #expect(read == value) - } } diff --git a/ios/truapi-host/README.md b/ios/truapi-host/README.md index dffd786cba..4c55bac491 100644 --- a/ios/truapi-host/README.md +++ b/ios/truapi-host/README.md @@ -380,22 +380,26 @@ The container enforces product consent, while native media delegates resolve OS ## SSO session handling -`TrUAPIHostRuntime` exposes two methods for wallet-owned SSO sessions. Meaningful request answering requires `activateLocalSession` to have been called first; `prepareDisconnectRequest` needs no session. +An externally owned SSO transport calls `openSsoService` for each peer with its own statement-account and encryption public keys. Rust verifies both against the active wallet and binds the returned service to that activation. Locking or reactivating the wallet invalidates the binding, including reactivation with the same secret. ```swift -func handleSsoRequest(message: Data) async throws -> SsoRequestOutcome -func prepareDisconnectRequest() -> Data +let service = try runtime.openSsoService( + ownStatementAccountId: statementAccountId, + ownEncryptionPublicKey: encryptionPublicKey +) ``` -`handleSsoRequest(message:)` takes one SCALE-encoded `RemoteMessage` exactly as decrypted from the statement-store session and routes it through the Rust core. The returned `SsoRequestOutcome` is the generated UniFFI enum (no Swift mirror): +Retain one generated service per authenticated peer. Before queueing each decrypted SCALE message, call `service.handleSsoControl(message:)`: a non-nil outcome handles the message immediately, allowing Cancel to reach a running request. Queue only nil results, in arrival order, for `service.handleSsoRequest(message:)`. Both calls use the same service and withdrawal state. Malformed messages throw. + +The generated `SsoRequestOutcome` distinguishes these results: -- `.response(message:)` — SCALE-encoded reply; post it back over the same session. +- `.response(message:)`: SCALE-encoded reply to post over the same authenticated transport, including not-connected errors after wallet replacement. - `.disconnected`: the peer ended the session; tear down its transport and records. - `.ignored`: nothing to post. -Confirmation-gated requests suspend on `confirmUserAction` or `confirmPermission`, so `handleSsoRequest` can take arbitrarily long. Always call it from a `Task`, never the main thread. +Confirmation-gated requests suspend on `confirmUserAction` or `confirmPermission`. Release each service with its peer transport. An invalid binding cannot be reused after wallet activation; recreate the coordinator and verify its transport keys again. -`prepareDisconnectRequest()` returns the SCALE-encoded `Disconnected` message to post when the wallet is ending the session. Posting and record cleanup (host entry, device record, device-removed broadcast) stay with the wallet. +`runtime.prepareDisconnectRequest()` builds the SCALE-encoded `Disconnected` message without requiring an active wallet. Posting and record cleanup stay with the host. ## Statement-store allowance renewal diff --git a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift index e851e26d79..d23f6d1b84 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift @@ -41,11 +41,18 @@ public protocol HostStorageBackend: AnyObject, Sendable { /// `truapi::platform::CoreStorageKey` values, so embedders can persist them /// opaquely or decode them to choose a secure backing store per slot. public protocol HostCoreStorageBackend: AnyObject, Sendable { + func keys() throws -> [Data] func read(key: Data) throws -> Data? func write(key: Data, value: Data) throws func clear(key: Data) throws } +public extension HostCoreStorageBackend { + func keys() throws -> [Data] { + throw HostRejection.Rejected(reason: "Core storage enumeration is unavailable") + } +} + /// Host-side callback bundle that the Rust core invokes for capabilities the /// native shell owns. The permission split mirrors the Rust `Permissions` /// trait: @@ -604,6 +611,10 @@ private final class HostCallbackAdapter: HostCallbacks, @unchecked Sendable { bridge.authStateChanged(state: state) } + func coreStorageKeys() throws -> [Data] { + try withHostRejection { try bridge.coreStorage.keys() } + } + func coreStorageRead(key: Data) throws -> Data? { try withHostRejection { try bridge.coreStorage.read(key: key) @@ -944,6 +955,16 @@ public final class TrUAPIHostRuntime: @unchecked Sendable { try await inner.coreDatabaseStatus() } + /// Revoke retained grants when a product is uninstalled. + public func clearProductState(productId: String) async throws { + try await inner.clearProductState(productId: productId) + } + + /// Revoke retained grants owned by a deleted wallet. + public func clearWalletState(owner: Data) async throws { + try await inner.clearWalletState(owner: owner) + } + /// Decides durable transactions from the chain until none is live, then /// returns. Await it from the background task /// ``HostBridge/durableWorkChanged(pending:)`` asks for; a throw means @@ -959,16 +980,15 @@ public final class TrUAPIHostRuntime: @unchecked Sendable { try inner.activateLocalSession(secret: secret, liteUsername: liteUsername) } - /// Answer one decrypted SSO remote message from the wallet-managed - /// statement-store session. `message` is one SCALE-encoded - /// `RemoteMessage` exactly as decrypted. `.response` carries the - /// SCALE-encoded reply to post back over the same session; - /// `.disconnected` means the peer ended the session (perform native - /// teardown); `.ignored` means the message was not a request. - /// Confirmation-gated requests await `confirmUserAction`, so this can - /// take arbitrarily long — call from a `Task`, never the main thread. - public func handleSsoRequest(message: Data) async throws -> SsoRequestOutcome { - try await inner.handleSsoRequest(message: message) + /// Bind one peer to the wallet matching the transport's public keys. + public func openSsoService( + ownStatementAccountId: Data, + ownEncryptionPublicKey: Data + ) throws -> SsoAccountHolderService { + try inner.openSsoService( + ownStatementAccountId: ownStatementAccountId, + ownEncryptionPublicKey: ownEncryptionPublicKey + ) } /// Build the SCALE-encoded `Disconnected` message to post over a diff --git a/js/packages/truapi-host/src/test-support.ts b/js/packages/truapi-host/src/test-support.ts index e186fd87b3..a74761cb09 100644 --- a/js/packages/truapi-host/src/test-support.ts +++ b/js/packages/truapi-host/src/test-support.ts @@ -40,6 +40,7 @@ export function makeHostCallbacks( endOperation: async () => {}, }, coreStorage: { + coreStorageKeys: async () => ({ encodedKeys: [] }), readCoreStorage: async () => undefined, writeCoreStorage: async () => {}, clearCoreStorage: async () => {}, diff --git a/js/packages/truapi-host/src/web/create-mock-host.ts b/js/packages/truapi-host/src/web/create-mock-host.ts index f5adf8714b..234df1fdc6 100644 --- a/js/packages/truapi-host/src/web/create-mock-host.ts +++ b/js/packages/truapi-host/src/web/create-mock-host.ts @@ -48,7 +48,8 @@ import type { RequiredHostCallbacks, UserConfirmationReview, } from "../generated/host-callbacks.js"; -import type { ProductRuntimeConfig } from "../runtime.js"; +import { encodeCoreStorageKey, type ProductRuntimeConfig } from "../runtime.js"; +import { CoreStorageKey as CoreStorageKeyCodec } from "../generated/host-callbacks.js"; /** How the mock answers a permission prompt for one capability. */ export type PermissionPolicy = "allow-all" | "deny-all"; @@ -976,25 +977,27 @@ export function createMockHost(config: MockHostConfig = {}): MockHost { const productKey = (key: string): string => `product:${key}`; const coreKey = (key: CoreStorageKey): string => - key.value === undefined - ? `core:${key.tag}` - : // Entries sorted, so a payload built field-by-field in a different - // order still addresses the slot it addressed before. - `core:${key.tag}:${JSON.stringify(key.value, (_, inner: unknown) => - inner !== null && typeof inner === "object" && !Array.isArray(inner) - ? Object.fromEntries( - Object.entries(inner as Record).sort( - ([left], [right]) => left.localeCompare(right), - ), - ) - : inner, - )}`; + `core:${Array.from(encodeCoreStorageKey(key), (byte) => byte.toString(16).padStart(2, "0")).join("")}`; /** Replacing a mock decision must invalidate the core's settled answer. */ const forgetStoredAuthorization = (permission: string): void => { - const prefix = "core:PermissionAuthorization:"; - const needle = JSON.stringify(permission); for (const key of storage.keys()) { - if (key.startsWith(prefix) && key.includes(needle)) storage.delete(key); + if (!key.startsWith("core:")) continue; + let decoded: CoreStorageKey; + try { + decoded = CoreStorageKeyCodec.dec( + scale.hexToBytes(`0x${key.slice(5)}`), + ); + } catch { + continue; + } + if (decoded.tag !== "PermissionAuthorization") continue; + const request = decoded.value.request; + if ( + (request.tag === "Device" && request.value === permission) || + (request.tag === "Remote" && + request.value.permission.tag === permission) + ) + storage.delete(key); } }; @@ -1117,6 +1120,18 @@ export function createMockHost(config: MockHostConfig = {}): MockHost { }, coreStorage: { + async coreStorageKeys() { + if (faults.storageError) throw new Error(faults.storageError); + return { + encodedKeys: Array.from(storage.keys()) + .filter((key) => key.startsWith("core:")) + .map((key) => + Uint8Array.from(key.slice(5).match(/.{2}/g) ?? [], (byte) => + Number.parseInt(byte, 16), + ), + ), + }; + }, async readCoreStorage(key) { if (faults.storageError) throw new Error(faults.storageError); return storage.get(coreKey(key)); diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts index 9942a07422..da54875702 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts @@ -32,6 +32,7 @@ import type { GenericError } from "@parity/truapi"; import { AuthState, CoreStorageKey, + CoreStorageKeys, DevicePermissionStatus, HostChainSet, HostContactLookup, @@ -75,6 +76,7 @@ export interface RawCallbacks { ): (() => void) | void; contacts?(lookup: Uint8Array): Promise; pickContact?(product: Uint8Array): Promise; + coreStorageKeys(): Promise; readCoreStorage(key: Uint8Array): Promise; writeCoreStorage(key: Uint8Array, value: Uint8Array): Promise; clearCoreStorage(key: Uint8Array): Promise; @@ -190,6 +192,8 @@ export function createWasmRawCallbacks( ), } : {}), + coreStorageKeys: async () => + CoreStorageKeys.enc(await callbacks.coreStorage.coreStorageKeys()), readCoreStorage: async (key) => await callbacks.coreStorage.readCoreStorage(CoreStorageKey.dec(key)), writeCoreStorage: async (key, value) => diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 52fe3cebe2..46b12a196b 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -92,6 +92,11 @@ export interface AccountAliasReview { ringLocation: RingLocation; } +/** + * Chain resource an allowance key grants access to. + */ +export type AllowanceResource = "Bulletin" | "StatementStore"; + /** * Auth/session lifecycle state the core projects for host UI. The core owns * every transition and emits states in order; hosts render the current state @@ -213,7 +218,28 @@ export type CoreStorageKey = * core honours it for a bounded lifetime, which is what makes a revoked * trust grant eventually take effect. */ - | { tag: "ProductManifest"; value: { productId: string } }; + | { tag: "ProductManifest"; value: { productId: string } } + /** + * One wallet's retained allowance for a product and chain resource. + */ + | { + tag: "NativeAllowanceKey"; + value: { + rootPublicKey: Uint8Array; + productId: string; + resource: AllowanceResource; + }; + }; + +/** + * Stored key inventory, independent of value decoding or decryption. + */ +export interface CoreStorageKeys { + /** + * Opaque keys, including keys unknown to this core version. + */ + encodedKeys: Array; +} /** * Review shown before a product creates a ring-VRF proof (RFC 0004). @@ -741,6 +767,13 @@ export const AccountAliasReview: S.Codec = S.lazy( }) as S.Codec, ); +/** + * Chain resource an allowance key grants access to. + */ +export const AllowanceResource: S.Codec = S.lazy( + (): S.Codec => S.Status("Bulletin", "StatementStore"), +); + /** * Auth/session lifecycle state the core projects for host UI. The core owns * every transition and emits states in order; hosts render the current state @@ -808,9 +841,26 @@ export const CoreStorageKey: S.Codec = S.lazy( ProductManifest: S.Struct({ productId: S.str }) as S.Codec<{ productId: string; }>, + NativeAllowanceKey: S.Struct({ + rootPublicKey: S.Bytes(32), + productId: S.str, + resource: AllowanceResource, + }) as S.Codec<{ + rootPublicKey: Uint8Array; + productId: string; + resource: AllowanceResource; + }>, }), ); +/** + * Stored key inventory, independent of value decoding or decryption. + */ +export const CoreStorageKeys: S.Codec = S.lazy( + (): S.Codec => + S.Struct({ encodedKeys: S.Vector(S.Bytes()) }) as S.Codec, +); + /** * Review shown before a product creates a ring-VRF proof (RFC 0004). */ @@ -1456,6 +1506,10 @@ export interface CoreAdmin { /** * Host-private persistence for core-owned state. * + * Secret slots require protected storage. Only absence returns ``undefined``; failures + * must propagate. Successful secret writes and clears are durable and ordered + * after earlier writes, including writes whose awaiting task was cancelled. + * * Clearing product-indexed slots is the host's job. The core drops the ones * it is holding when a session ends, but a product it never opened this run * has no entry to drop, so those slots outlive the disconnect. A host that @@ -1469,6 +1523,11 @@ export interface CoreAdmin { * makes the sweep a prefix delete rather than a scan. */ export interface CoreStorage { + /** + * Enumerate encoded keys without reading values, including undecodable records. + */ + coreStorageKeys?(): Promise; + /** * Read a core-owned value by typed slot. */ diff --git a/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs b/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs index 42e66af694..dcc538d8ea 100644 --- a/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs +++ b/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs @@ -35,6 +35,7 @@ pub struct JsBridge { pub subscribe_chat_rooms: Function, pub contacts: Function, pub pick_contact: Function, + pub core_storage_keys: Function, pub read_core_storage: Function, pub write_core_storage: Function, pub clear_core_storage: Function, @@ -89,6 +90,7 @@ impl JsBridge { .unwrap_or_else(|| missing_callback("contacts")), pick_contact: get_optional_function(callbacks, "pickContact")? .unwrap_or_else(|| missing_callback("pickContact")), + core_storage_keys: get_function(callbacks, "coreStorageKeys")?, read_core_storage: get_function(callbacks, "readCoreStorage")?, write_core_storage: get_function(callbacks, "writeCoreStorage")?, clear_core_storage: get_function(callbacks, "clearCoreStorage")?, @@ -313,6 +315,19 @@ impl crate::platform::ContactsPlatform for WasmPlatform { #[crate::platform::async_trait] impl crate::platform::CoreStorage for WasmPlatform { + async fn core_storage_keys( + &self, + ) -> Result { + let bytes = invoke_bytes_return(&self.bridge.core_storage_keys, Vec::new()) + .await + .map_err(generic)?; + decode_bytes::( + bytes, + "coreStorageKeys response did not decode", + ) + .map_err(generic) + } + async fn read_core_storage( &self, key: crate::platform::CoreStorageKey, diff --git a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts index 1aa3fdc432..29afa037b7 100644 --- a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts @@ -18,6 +18,7 @@ export const CALLBACK_NAMES = [ "setChatRoomFooter", "contacts", "pickContact", + "coreStorageKeys", "readCoreStorage", "writeCoreStorage", "clearCoreStorage", @@ -73,6 +74,7 @@ function rawCallbacks( Pick< RawCallbacks, | "authStateChanged" + | "coreStorageKeys" | "readCoreStorage" | "writeCoreStorage" | "clearCoreStorage" @@ -95,6 +97,10 @@ function rawCallbacks( return { authStateChanged: (state) => void bridge.callbackRequest("authStateChanged", [state]).catch(() => {}), + coreStorageKeys: () => + bridge.callbackRequest("coreStorageKeys", []) as ReturnType< + Required["coreStorageKeys"] + >, readCoreStorage: (key) => bridge.callbackRequest("readCoreStorage", [key]) as ReturnType< Required["readCoreStorage"] diff --git a/rust/crates/truapi-host-cli/src/platform.rs b/rust/crates/truapi-host-cli/src/platform.rs index e93c8eb93d..96c5366edd 100644 --- a/rust/crates/truapi-host-cli/src/platform.rs +++ b/rust/crates/truapi-host-cli/src/platform.rs @@ -496,6 +496,26 @@ impl ProductOperations for CliPlatform { #[async_trait] impl CoreStorage for CliPlatform { + async fn core_storage_keys( + &self, + ) -> Result { + let mut keys: Vec<_> = self + .core_storage + .lock() + .expect("core storage mutex poisoned") + .keys() + .cloned() + .collect(); + keys.extend( + self.device_storage + .lock() + .expect("device storage mutex poisoned") + .keys() + .cloned(), + ); + Ok(truapi::platform::CoreStorageKeys { encoded_keys: keys }) + } + async fn read_core_storage( &self, key: CoreStorageKey, diff --git a/rust/crates/truapi-macros/README.md b/rust/crates/truapi-macros/README.md index 7ade3c270d..1ea2f06323 100644 --- a/rust/crates/truapi-macros/README.md +++ b/rust/crates/truapi-macros/README.md @@ -23,7 +23,7 @@ the response's `Result` payload: pub type GetAccountAliasResponse = Result; #[truapi_macros::sso_service] -impl SigningHostSsoService { +impl SsoAccountHolderService { async fn get_account_alias( &self, cx: &SsoRequestContext, diff --git a/rust/crates/truapi-macros/src/sso_service.rs b/rust/crates/truapi-macros/src/sso_service.rs index 7768feb12d..c959d6c48c 100644 --- a/rust/crates/truapi-macros/src/sso_service.rs +++ b/rust/crates/truapi-macros/src/sso_service.rs @@ -145,7 +145,7 @@ fn expand_sso_service(mut item: ItemImpl) -> syn::Result { /// Answer one wire message with this service. /// /// `cx` is the context for `message`, built by the caller around the - /// signing host's current session; without one every request is + /// transport's bound wallet session; without one every request is /// answered with its error type's `not_connected()`. pub async fn dispatch( &self, diff --git a/rust/crates/truapi/RUNTIME.md b/rust/crates/truapi/RUNTIME.md index be4fbb3ce7..74e95c247a 100644 --- a/rust/crates/truapi/RUNTIME.md +++ b/rust/crates/truapi/RUNTIME.md @@ -19,80 +19,38 @@ protocol API into a working host. It owns: ## Architecture -Two ownership bands. A **per-product-connection** band (byte frames → -dispatcher → role-neutral product runtime) is minted once per host↔product -connection by the role handle and lives for that product's whole session; a -**shared per-host** band owns role-neutral infrastructure (`RuntimeServices`) -and the role object (`PairingHost` or `SigningHost`), which is itself the -`ProductAuthority` and the `HostSession`. Pure `host_logic` is a no-I/O library -both bands call, not a stage in the frame path; the host's `Platform` impl is -the syscall floor. +Each product connection has its own `ProductRuntime`, `ProductRuntimeHost` and `ProductConnection`, which hold that product's adapters, permissions, action streams and its own chain connection, and a `ProductAccounts` that gives it the shared `HostAccounts` bound to that product, without the host-level operations. Everything else is shared per host: `HostAccounts` with its `AccountHolder`, the `HostSession`, and `RuntimeServices`. `host_logic` is a pure library both call, and the host's `Platform` implementation performs all I/O. ```text - ┌───────────────────────────────────────────────────────┐ - │ product sandboxed iframe · native WebView │ - └───────────────────────────────────────────────────────┘ - │ ▲ - SCALE frames │ │ MessageChannel · loopback - both directions ▼ │ WS - ┌───────────────────────────────────────────────────────┐ - │ binding layer : host shell / transport adapter │ - │ thin byte bridge · no protocol logic │ - └───────────────────────────────────────────────────────┘ - - ══ per host→product connection ( one per connected product ) ══ - ┌───────────────────────────────────────────────────────┐ - │ ProductRuntime frame endpoint │ - │ decode each SCALE frame → dispatch one typed call │ - └───────────────────────────────────────────────────────┘ - │ typed method call - ▼ - ┌───────────────────────────────────────────────────────┐ - │ ProductRuntimeHost role-neutral │ - │ validate · permission-gate · confirm │ - └───────────────────────────────────────────────────────┘ - │ wallet-authority tail : - │ sign · alias · entropy · alloc - │ via ProductAccounts, the role - │ bound to this product - ▼ - - ══ shared per host app ( one per host, all connections ) ══════ - the PairingHostRuntime | SigningHostRuntime handle owns both: - ┌─────────────────────────────┐ ┌────────────────────────┐ - │ role = ProductAuthority │ │ RuntimeServices │ - │ + HostSession │ │ platform · chain · RPC │ - │ PairingHost | SigningHost │ │ │ - └─────────────────────────────┘ └────────────────────────┘ - │ - │ PairingHost only : encrypted SSO channel - ▼ - ┌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐ - ╎ remote signing host ( external wallet ) ╎ - └╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘ - - both bands call host_logic for pure work, never traverse it : - ┌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐ - ╎ host_logic pure library ( no I/O ) ╎ - ╎ crypto · codecs · derivation · policy ╎ - └╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘ - - ══ host-owned floor · where every I/O above bottoms out ═══════ - ┌───────────────────────────────────────────────────────┐ - │ Platform impl ( TS / Swift / Kotlin ) │ - │ storage · prompts · chain RPC · navigation │ - └───────────────────────────────────────────────────────┘ +Per product connection +┌─────────────────────────────────────────────────────────────┐ +│ ProductRuntime: frames → Dispatcher │ +│ ProductRuntimeHost: validation and protocol adapters │ +│ ProductConnection: permissions, platform adapters, demand │ +│ ProductAccounts: HostAccounts bound to this product │ +└────────────────────┬──────────────────────┬─────────────────┘ + │ account operations │ login / identity +Shared per host ▼ ▼ +┌──────────────────────────────┐ ┌────────────────────────────┐ +│ HostAccounts │ │ HostSession │ +│ acquire, retain, use grants │ │ SigningHost │ +│ HostGrantStore + registry │ │ or SsoRequestService │ +└──────────────────┬───────────┘ └────────────────────────────┘ + │ AccountHolder + ┌─────────┴─────────────────────┐ + ▼ ▼ + WalletAccountHolder RemoteAccountHolder + wallet secrets and issuance canonical calls ↔ SSO messages + ▲ │ + │ ▼ + SsoAccountHolderService SsoRequestService + confirms peer requests selected channel and transport + +Shared RuntimeServices: platform, chain access and RPC clients +Host Platform: storage, prompts, chain transport and navigation ``` -`ProductRuntimeHost` handles everything role-neutral (id normalization, -permission gating, confirmation, soft product-key derivation), then delegates -the wallet-authority tail (`sign_*`, `create_transaction`, `account_alias`, -`create_proof`, `allocate_resources`, `derive_entropy`) through a -`ProductAccounts` handle bound to its product, with an `AuthoritySession` -snapshot the role revalidates before touching key material. Its -per-connection state (adapters, permissions, action channels, open operations -and its own chain connection) lives in a `ProductConnection`, and login, logout -and connection state go through the role's `HostSession`. +`HostAccounts` binds each call to the calling product and the current wallet activation, keeps the grants it was issued, and signs with delegated keys itself. It confirms signing, transaction, VRF and allocation requests with the user before the `AccountHolder` acts on them; allowances it allocates in the background need no confirmation. The `AccountHolder` owns wallet secrets and grant issuance and records ring-VRF registrations; `HostAccounts` records the provider the user selects, and `RingVrfRegistryStore` serializes both writes. `runtime.rs` owns the product runtime and shared helpers. The trait adapters are grouped by surface under `runtime/capabilities/`; cross-capability fixtures @@ -243,17 +201,8 @@ awaiting `run_durable_recovery()`, which returns once nothing is live. ### The two roles -Both implement the role-neutral **`ProductAuthority`** and **`HostSession`** -traits; each owns its role-specific lifecycle, so no method exists on a role -that can't mean it: - -- **`PairingHost`** (seedless): the user's keys live in an external wallet, so - signing/aliases/entropy relay over an encrypted SSO channel (statement store - on the People chain; the channel lives in `pairing_host/sso_channel.rs`). The - v2 wire protocol uses raw X25519 keys, HKDF-SHA256, and - ChaCha20-Poly1305. It owns pairing/login state, persisted auth-session reload, - and remote signing-host liveness monitoring. -- **`SigningHost`** (wallet-local): signs on device from local BIP-39 entropy, +- **`PairingHost`** (seedless): runs `HostAccounts` and owns logout and the paired session's grants. `SsoRequestService` owns pairing and login state, persisted session reload and remote signing-host liveness, and carries account operations to the phone over the encrypted SSO channel (People-chain statement store; v2 wire protocol with raw X25519 keys, HKDF-SHA256 and ChaCha20-Poly1305). The paired host confirms a signing, transaction, VRF or allocation request before forwarding it, and the phone confirms it again. +- **`SigningHost`** (wallet-local): runs `HostAccounts`. `WalletAccountHolder` owns local entropy, derivation, grant issuance and renewal, and asks for account access before a product uses another product's ring-VRF keys; `SigningHost` routes activation, lock, product reset and wallet removal through `HostAccounts`. It signs on device from local BIP-39 entropy, no pairing flow. `signing_host/local_activation.rs` establishes a session from host-held secret material. Its public identity is the RFC-0022 `uid.` index-0 product account of the configured network. RFC-0024 ring-VRF keys are explicit, @@ -268,6 +217,8 @@ that can't mean it: builds report them as unavailable) and do need metadata, which they take from the `RuntimeServices`-owned per-chain cache rather than re-reading it per call. +- `ProductConsent`: one per runtime. It holds account-access decisions and review rules, and runs account-access prompts one at a time for local product calls and paired requests alike. A paired caller never skips a review. A signature with another product's account is confirmed once per kind and owning product for the rest of the product execution. +- `HostGrantStore`: holds the keys and public subtrees issued to `HostAccounts`, per wallet on a signing host and per pairing on a paired host. Native hosts keep allowance keys in protected platform storage across restarts, check them on chain before use, and renew them through the account holder. `host_logic` stays pure: the orchestrators above call into it for codecs, session/SSO crypto, key derivation, and permission policy, while all I/O @@ -285,12 +236,9 @@ the page. ### Inter-host SSO -`PairingHost::call(request)` sends typed requests to -[`SigningHostSsoService`](src/runtime/signing_host/sso_service.rs). Handlers own -consent and business logic; `sso_responder.rs` owns the transport loop and shared -allowance helpers. Resource consent is bound to the request's signing session: -account changes, disconnects, and reactivation invalidate pending approval before -allocation or key return. Allocation failure details stay in local transcripts. +On the signing host, [`SsoResponderService`](src/runtime/sso_responder_service.rs) owns the transport: it answers pairing, runs the serve loop, drops duplicate requests, and gives each peer its own [`SsoAccountHolderService`](src/runtime/sso_account_holder_service.rs). That service is bound to the wallet activation that authenticated the peer's channel and calls `WalletAccountHolder` directly; once that activation ends it answers only not-connected. A native wallet with its own transport opens the same per-peer service from the native runtime. + +On the paired host, `SsoRequestService::call(request)` sends typed requests to the peer's `SsoAccountHolderService`. That service confirms each signing, transaction, VRF and allocation request with the user before `WalletAccountHolder` acts on it, and approval is bound to the request's signing session, so account changes, disconnects and reactivation invalidate a pending one. Allocation failure details stay in local transcripts. Allocation requests use the canonical `truapi::latest::AllocatableResource` type. Signing uses canonical request and result types. Product-scoped VRF requests use `ProductRequest

` to attach the caller to a canonical payload. Both product and @@ -304,7 +252,7 @@ a withdrawn request posts no response. See the [SSO request cancellation RFC](../../../docs/rfcs/sso-request-cancellation.md). When a device finishes pairing, the signing host reports it to the embedder's -[`DevicePairingObserver`](src/runtime/signing_host/sso_responder.rs), installed +[`DevicePairingObserver`](src/runtime/sso_responder_service.rs), installed once through `SigningHostRuntime::set_device_pairing_observer`, and on a native host to `HostCallbacks::device_paired`. It carries the `PairedSsoPeer` that pairing produced, which is also what `resume_pairing` and diff --git a/rust/crates/truapi/src/host_core.rs b/rust/crates/truapi/src/host_core.rs index 96f17c3888..3de0c4e780 100644 --- a/rust/crates/truapi/src/host_core.rs +++ b/rust/crates/truapi/src/host_core.rs @@ -36,15 +36,12 @@ use truapi::{CallContext, CancellationReason, v01}; use crate::{ frame::ProtocolMessage, - host_internal::sso_messages::{RemoteMessage, SsoRequestOutcome}, host_logic::worker::WorkerLedger, runtime::{ - ActionChannel, DEFAULT_REMOTE_AUTHORITY_RESPONSE_TIMEOUT, DevicePairingObserver, - HostSession, LocalActivation, PairedSsoPeer, PairingHostRole, ProductAuthority, - ProductConnection, ProductRuntimeHost, ResponderExit, RuntimeServices, SigningHostRole, - SigningHostSsoService, disconnect_paired_host, establish_pairing, - notify_pairing_allowance_allocation, notify_pairing_failed, respond_to_pairing, - resume_pairing, sso_service::Dispatch, + AccountHolder, ActionChannel, AuthorityError, DEFAULT_REMOTE_AUTHORITY_RESPONSE_TIMEOUT, + DevicePairingObserver, HostAccounts, HostSession, LocalActivation, PairedSsoPeer, + PairingHost, ProductConnection, ProductRuntimeHost, ResponderExit, RuntimeServices, + SigningHostRole, SsoAccountHolderService, SsoResponderService, }, subscription::{HostInitiatedSubscriptionManager, Spawner}, transport::Transport, @@ -187,7 +184,7 @@ fn product_context(product_id: &str) -> Result, - pairing_host: Arc, + pairing: PairingHost, } impl PairingHostRuntime { @@ -196,7 +193,7 @@ impl PairingHostRuntime { /// For test hosts only, with the `test-host` feature enabled. #[cfg(feature = "test-host")] pub fn set_submit_preimages_locally(&self, local: bool) { - self.pairing_host.set_submit_preimages_locally(local); + self.pairing.accounts().set_submit_preimages_locally(local); } /// Build a long-lived pairing-host runtime around a platform implementation. @@ -251,9 +248,9 @@ impl PairingHostRuntime { if let Some(contacts_platform) = contacts_platform { services.install_contacts_platform(contacts_platform); } - let pairing_host = PairingHostRole::new(services.clone(), config); - pairing_host.clone().start_session_store_sync(spawner); - Self { services, pairing_host } + let pairing = PairingHost::new(services.clone(), config); + pairing.session().clone().start_session_store_sync(spawner); + Self { services, pairing } } /// Install the host's [`PermissionStatusHost`], which carries the reasoning @@ -346,8 +343,8 @@ impl PairingHostRuntime { ) -> ProductRuntime { ProductRuntime::new( self.services.clone(), - self.pairing_host.clone(), - self.pairing_host.clone(), + self.pairing.accounts().clone(), + self.pairing.session().clone(), product, ConnectionAdapters::from_services(&self.services), sink, @@ -359,8 +356,8 @@ impl PairingHostRuntime { pub fn product_admin(&self, product: ProductContext) -> HostAdmin { HostAdmin::new( self.services.clone(), - self.pairing_host.clone(), - self.pairing_host.clone(), + self.pairing.accounts().clone(), + self.pairing.session().clone(), product, ConnectionAdapters::from_services(&self.services), ) @@ -369,7 +366,7 @@ impl PairingHostRuntime { /// Disconnect the active account-authority session. #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.disconnect_session"))] pub async fn disconnect_session(&self) { - self.pairing_host.disconnect().await; + self.pairing.session().disconnect().await; } /// Log out and discard the old pairing keypair. @@ -378,17 +375,15 @@ impl PairingHostRuntime { /// presents a new deeplink suitable for another signing host. #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.logout"))] pub async fn logout(&self) -> Result<(), v01::GenericError> { - self.pairing_host - .logout_and_reset_pairing() - .await - .map_err(|reason| v01::GenericError { reason }) + self.pairing.logout().await.map_err(|reason| v01::GenericError { reason }) } /// Clear one product's capability state while preserving the active /// session and unrelated products. #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.clear_product_state", %product_id))] pub async fn clear_product_state(&self, product_id: &str) -> Result<(), v01::GenericError> { - self.pairing_host + self.pairing + .accounts() .clear_product_state(product_id) .await .map_err(|reason| v01::GenericError { reason }) @@ -399,7 +394,11 @@ impl PairingHostRuntime { &self, ring: &v01::RingLocation, ) -> Result, v01::GenericError> { - self.pairing_host.ring_vrf_providers(ring).await.map_err(ring_vrf_admin_error) + self.pairing + .accounts() + .ring_vrf_providers(ring) + .await + .map_err(ring_vrf_admin_error) } /// Current provider selected for an internal well-known-ring feature. @@ -407,7 +406,8 @@ impl PairingHostRuntime { &self, ring: &v01::RingLocation, ) -> Result, v01::GenericError> { - self.pairing_host + self.pairing + .accounts() .selected_ring_vrf_provider(ring) .await .map_err(ring_vrf_admin_error) @@ -419,7 +419,8 @@ impl PairingHostRuntime { ring: v01::RingLocation, handle: v01::ProductAccountId, ) -> Result<(), v01::GenericError> { - self.pairing_host + self.pairing + .accounts() .select_ring_vrf_provider(ring, handle) .await .map_err(ring_vrf_admin_error) @@ -435,14 +436,14 @@ impl PairingHostRuntime { /// running their own P2P chat channel for the paired identity. #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.session_chat_identity_key"))] pub fn session_chat_identity_key(&self) -> Option<[u8; 32]> { - self.pairing_host.session_state().current()?.identity_chat_private_key + self.pairing.session().session_state().current()?.identity_chat_private_key } /// Read the active session's sr25519 statement-store secret, for hosts /// running their own statement-store traffic against the advertised account. #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.device_statement_key"))] pub fn device_statement_key(&self) -> Option<[u8; 64]> { - Some(self.pairing_host.session_state().current()?.sso?.ss_secret) + Some(self.pairing.session().session_state().current()?.sso?.ss_secret) } /// Read this device's X25519 encryption secret, for hosts running device @@ -463,14 +464,14 @@ impl PairingHostRuntime { product_id: &str, timeout_ms: Option, ) -> Result, v01::GenericError> { - product_subtree_public_key(self.pairing_host.as_ref(), product_id, timeout_ms).await + product_subtree_public_key(self.pairing.accounts().as_ref(), product_id, timeout_ms).await } /// Clear the canonical paired session and all capability caches/storage /// without sending a peer-disconnect notice. #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.reset_session_state"))] pub async fn reset_session_state(&self) { - self.pairing_host.reset_session_state().await; + self.pairing.session().reset_session_state().await; } /// Start or join the pairing-host login flow for one product. @@ -480,7 +481,7 @@ impl PairingHostRuntime { product_id: &str, ) -> Result { let product = product_context(product_id)?; - match self.pairing_host.request_login(&product).await { + match self.pairing.session().request_login(&product).await { Ok(response) => Ok(response), Err(error) => Err(v01::GenericError { reason: pairing_login_error_reason(error) }), } @@ -490,7 +491,7 @@ impl PairingHostRuntime { /// active. #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.cancel_pairing"))] pub fn cancel_pairing(&self) { - self.pairing_host.cancel_login(); + self.pairing.session().cancel_login(); } /// Activate a canonical session blob supplied by an external encrypted @@ -500,7 +501,8 @@ impl PairingHostRuntime { /// connected-session installation have completed. #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.activate_external_session"))] pub async fn activate_external_session(&self, blob: &[u8]) -> Result<(), v01::GenericError> { - self.pairing_host + self.pairing + .session() .activate_external_session(blob) .await .map_err(|reason| v01::GenericError { reason }) @@ -513,7 +515,8 @@ impl PairingHostRuntime { /// immediately use the restored authority session. #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.activate_stored_session"))] pub async fn activate_stored_session(&self) -> Result<(), v01::GenericError> { - self.pairing_host + self.pairing + .session() .activate_stored_session() .await .map_err(|reason| v01::GenericError { reason }) @@ -523,7 +526,7 @@ impl PairingHostRuntime { /// have changed and should be re-read. #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.notify_session_store_changed"))] pub fn notify_session_store_changed(&self) { - self.pairing_host.notify_session_store_changed(); + self.pairing.session().notify_session_store_changed(); } /// Read a stored permission authorization status for a product without prompting. @@ -606,6 +609,7 @@ impl PairingHostAdmin for PairingHostRuntime { pub struct SigningHostRuntime { services: Arc, signing_host: Arc, + sso_responder: Arc, } impl SigningHostRuntime { @@ -622,7 +626,7 @@ impl SigningHostRuntime { /// For test hosts only, with the `test-host` feature enabled. #[cfg(feature = "test-host")] pub fn set_submit_preimages_locally(&self, local: bool) { - self.signing_host.set_submit_preimages_locally(local); + self.signing_host.accounts().set_submit_preimages_locally(local); } /// The product's hard-subtree public key, derived from the active session @@ -635,7 +639,7 @@ impl SigningHostRuntime { product_id: &str, ) -> Result, v01::GenericError> { self.signing_host - .derive_subtree_public_key(product_id) + .product_subtree_public_key(product_id) .map_err(|err| v01::GenericError { reason: err.to_string() }) } @@ -711,7 +715,7 @@ impl SigningHostRuntime { ); } let signing_host = SigningHostRole::new(services.clone(), config.network_suffix); - Self { services, signing_host } + Self { services, sso_responder: signing_host.sso_responder(), signing_host } } /// Install the host's [`PermissionStatusHost`], which carries the reasoning @@ -863,7 +867,7 @@ impl SigningHostRuntime { ) -> ProductRuntime { ProductRuntime::new( self.services.clone(), - self.signing_host.clone(), + self.signing_host.accounts().clone(), self.signing_host.clone(), product, ConnectionAdapters::from_services(&self.services), @@ -871,54 +875,18 @@ impl SigningHostRuntime { ) } - /// Build one product connection with adapters scoped to one native - /// executable while sharing this runtime's authentication and services. - #[cfg(not(target_arch = "wasm32"))] - pub fn product_runtime_with( - &self, - product: ProductContext, - adapters: ConnectionAdapters, - sink: Arc, - ) -> ProductRuntime { - ProductRuntime::new( - self.services.clone(), - self.signing_host.clone(), - self.signing_host.clone(), - product, - adapters, - sink, - ) - } - /// Build a product-scoped administration handle from this signing host. #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.product_admin"))] pub fn product_admin(&self, product: ProductContext) -> HostAdmin { HostAdmin::new( self.services.clone(), - self.signing_host.clone(), + self.signing_host.accounts().clone(), self.signing_host.clone(), product, ConnectionAdapters::from_services(&self.services), ) } - /// Build a product administration handle with adapters scoped to one - /// native executable connection. - #[cfg(not(target_arch = "wasm32"))] - pub fn product_admin_with( - &self, - product: ProductContext, - adapters: ConnectionAdapters, - ) -> HostAdmin { - HostAdmin::new( - self.services.clone(), - self.signing_host.clone(), - self.signing_host.clone(), - product, - adapters, - ) - } - /// Reference counts per product worker, shared by every connection of /// this host. pub fn worker_ledger(&self) -> &WorkerLedger { @@ -942,7 +910,16 @@ impl SigningHostRuntime { pub async fn clear_product_state(&self, product_id: &str) -> Result<(), v01::GenericError> { self.signing_host .clear_product_state(product_id) - .map_err(|error| v01::GenericError { reason: error.to_string() }) + .await + .map_err(|reason| v01::GenericError { reason }) + } + + /// Remove grants owned by a deleted wallet, preserving other wallets' records. + pub async fn clear_wallet_state(&self, owner: [u8; 32]) -> Result<(), v01::GenericError> { + self.signing_host + .clear_wallet_state(owner) + .await + .map_err(|reason| v01::GenericError { reason }) } /// Read a stored permission authorization status for a product without prompting. @@ -995,7 +972,11 @@ impl SigningHostRuntime { &self, ring: &v01::RingLocation, ) -> Result, v01::GenericError> { - self.signing_host.ring_vrf_providers(ring).await.map_err(ring_vrf_admin_error) + self.signing_host + .accounts() + .ring_vrf_providers(ring) + .await + .map_err(ring_vrf_admin_error) } /// Current provider selected for an internal well-known-ring feature. @@ -1004,6 +985,7 @@ impl SigningHostRuntime { ring: &v01::RingLocation, ) -> Result, v01::GenericError> { self.signing_host + .accounts() .selected_ring_vrf_provider(ring) .await .map_err(ring_vrf_admin_error) @@ -1016,6 +998,7 @@ impl SigningHostRuntime { handle: v01::ProductAccountId, ) -> Result<(), v01::GenericError> { self.signing_host + .accounts() .select_ring_vrf_provider(ring, handle) .await .map_err(ring_vrf_admin_error) @@ -1052,7 +1035,8 @@ impl SigningHostRuntime { &self, deeplink: &str, ) -> Result { - respond_to_pairing(self.services.clone(), self.signing_host.clone(), deeplink) + self.sso_responder + .respond_to_pairing(deeplink) .await .map_err(|reason| v01::GenericError { reason }) } @@ -1067,13 +1051,10 @@ impl SigningHostRuntime { &self, deeplink: &str, ) -> Result { - notify_pairing_allowance_allocation( - self.services.clone(), - self.signing_host.clone(), - deeplink, - ) - .await - .map_err(|reason| v01::GenericError { reason }) + self.sso_responder + .notify_pairing_allowance_allocation(deeplink) + .await + .map_err(|reason| v01::GenericError { reason }) } /// Tell a pairing host that pairing failed, so it reports `reason` and @@ -1090,7 +1071,8 @@ impl SigningHostRuntime { announced: &crate::runtime::AnnouncedPairing, reason: String, ) -> Result<(), v01::GenericError> { - notify_pairing_failed(self.services.clone(), announced, reason) + self.sso_responder + .notify_pairing_failed(announced, reason) .await .map_err(|reason| v01::GenericError { reason }) } @@ -1098,7 +1080,8 @@ impl SigningHostRuntime { /// Answer a pairing host's handshake without entering its long-lived serve loop. #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.establish_pairing"))] pub async fn establish_pairing(&self, deeplink: &str) -> Result<(), v01::GenericError> { - establish_pairing(self.services.clone(), self.signing_host.clone(), deeplink) + self.sso_responder + .establish_pairing(deeplink) .await .map_err(|reason| v01::GenericError { reason }) } @@ -1112,7 +1095,8 @@ impl SigningHostRuntime { &self, peer: PairedSsoPeer, ) -> Result { - resume_pairing(self.services.clone(), self.signing_host.clone(), peer) + self.sso_responder + .resume_pairing(peer) .await .map_err(|reason| v01::GenericError { reason }) } @@ -1123,27 +1107,59 @@ impl SigningHostRuntime { &self, peer: PairedSsoPeer, ) -> Result<(), v01::GenericError> { - disconnect_paired_host(self.services.clone(), self.signing_host.clone(), peer) + self.sso_responder + .disconnect_paired_host(peer) .await .map_err(|reason| v01::GenericError { reason }) } - /// Answer one SSO request using this signing host. - #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.answer_sso_request"))] - pub async fn answer_sso_request(&self, message: RemoteMessage) -> SsoRequestOutcome { - let service = SigningHostSsoService::new(self.signing_host.clone()); - match service.answer(message).await { - Dispatch::Response(answer) => { - SsoRequestOutcome::Response { message: answer.message.encode() } - }, - Dispatch::Disconnected => SsoRequestOutcome::Disconnected, - Dispatch::NotARequest(_) | Dispatch::Withdraw(_) | Dispatch::Withdrawn => { - SsoRequestOutcome::Ignored - }, - } + /// Bind an external SSO transport to the wallet matching both of its keys. + pub fn open_sso_service( + &self, + own_statement_account_id: [u8; 32], + own_encryption_public_key: [u8; 32], + ) -> Result { + self.sso_responder + .open_service(own_statement_account_id, own_encryption_public_key) } } +/// Build one product connection with adapters scoped to one native +/// executable while sharing this runtime's authentication and services. +#[cfg(not(target_arch = "wasm32"))] +pub fn product_runtime_with_adapters( + host: &SigningHostRuntime, + product: ProductContext, + adapters: ConnectionAdapters, + sink: Arc, +) -> ProductRuntime { + ProductRuntime::new( + host.services.clone(), + host.signing_host.accounts().clone(), + host.signing_host.clone(), + product, + adapters, + sink, + ) +} + +/// Build a product administration handle with adapters scoped to one +/// native executable connection. +#[cfg(not(target_arch = "wasm32"))] +pub fn product_admin_with_adapters( + host: &SigningHostRuntime, + product: ProductContext, + adapters: ConnectionAdapters, +) -> HostAdmin { + HostAdmin::new( + host.services.clone(), + host.signing_host.accounts().clone(), + host.signing_host.clone(), + product, + adapters, + ) +} + /// The durable transaction engine over `db`, reading and submitting through /// the runtime's chain connections. #[cfg(not(target_arch = "wasm32"))] @@ -1328,28 +1344,66 @@ fn ring_vrf_admin_error( } } +type ProductSubtreeRequest = Arc< + dyn Fn( + String, + Option, + ) -> futures::future::BoxFuture<'static, Result, v01::GenericError>> + + Send + + Sync, +>; + /// Product-scoped administration handle for host UI. /// /// Host UI should use this when it needs to inspect or update core-owned state /// without owning a product frame endpoint. pub struct HostAdmin { - authority: Arc, + #[cfg(not(target_arch = "wasm32"))] + product_runtime: Arc, + connection: Arc, host_session: Arc, - product_runtime: Arc, + product_subtree: ProductSubtreeRequest, } impl HostAdmin { - /// Product runtime managed by this admin handle. - #[cfg(any(test, not(target_arch = "wasm32")))] - pub fn product_runtime(&self) -> &Arc { - &self.product_runtime + /// Authorize one operation using this execution's saved and one-use permissions. + #[cfg(not(target_arch = "wasm32"))] + pub async fn authorize_remote_permission( + &self, + request: truapi::latest::RemotePermissionRequest, + ) -> Result { + use truapi::versioned::IntoLatest; + + self.product_runtime + .authorize_remote_permission( + &CallContext::default(), + truapi::versioned::permissions::RemotePermissionRequest::V1(request), + ) + .await + .map(|response| response.into_latest().granted) + .map_err(|error| truapi::latest::GenericError { reason: format!("{error:?}") }) + } + + /// Authorize one device capability the host's web view gates itself, using + /// this execution's saved and one-use permissions. + #[cfg(not(target_arch = "wasm32"))] + pub async fn authorize_device_permission( + &self, + request: truapi::latest::HostDevicePermissionRequest, + ) -> Result { + use truapi::versioned::IntoLatest; + + self.product_runtime + .authorize_device_permission(&CallContext::default(), request.into()) + .await + .map(|response| response.into_latest().granted) + .map_err(|error| truapi::latest::GenericError { reason: format!("{error:?}") }) } - /// Build an admin handle for one product connection. #[instrument(skip_all, fields(runtime.method = "host_admin.new"))] - pub fn new( + fn new( services: Arc, - authority: Arc, + accounts: Arc>, host_session: Arc, product: ProductContext, adapters: ConnectionAdapters, @@ -1357,11 +1411,22 @@ impl HostAdmin { let product_runtime = Arc::new(ProductRuntimeHost::from_services( services, adapters, - authority.clone(), + accounts.clone(), host_session.clone(), product, )); - Self { authority, host_session, product_runtime } + Self { + connection: product_runtime.connection().clone(), + #[cfg(not(target_arch = "wasm32"))] + product_runtime, + host_session, + product_subtree: Arc::new(move |product_id, timeout_ms| { + let accounts = accounts.clone(); + Box::pin(async move { + product_subtree_public_key(&accounts, &product_id, timeout_ms).await + }) + }), + } } /// Core-owned logout/disconnect. @@ -1380,7 +1445,7 @@ impl HostAdmin { &self, request: PermissionAuthorizationRequest, ) -> Result { - self.product_runtime.connection().permission_authorization_status(request).await + self.connection.permission_authorization_status(request).await } /// Read stored permission authorization statuses without prompting. @@ -1393,10 +1458,7 @@ impl HostAdmin { &self, requests: Vec, ) -> Result, v01::GenericError> { - self.product_runtime - .connection() - .permission_authorization_statuses(requests) - .await + self.connection.permission_authorization_statuses(requests).await } /// Update a stored permission authorization status. @@ -1406,10 +1468,7 @@ impl HostAdmin { request: PermissionAuthorizationRequest, status: PermissionAuthorizationStatus, ) -> Result<(), v01::GenericError> { - self.product_runtime - .connection() - .set_permission_authorization_status(request, status) - .await + self.connection.set_permission_authorization_status(request, status).await } } @@ -1460,8 +1519,7 @@ impl CoreAdmin for HostAdmin { } async fn get_device_encryption_key(&self) -> Result<[u8; 32], v01::GenericError> { - self.product_runtime - .connection() + self.connection .services() .device_encryption_secret() .await @@ -1473,7 +1531,7 @@ impl CoreAdmin for HostAdmin { product_id: String, timeout_ms: Option, ) -> Result, v01::GenericError> { - product_subtree_public_key(self.authority.as_ref(), &product_id, timeout_ms).await + (self.product_subtree)(product_id, timeout_ms).await } } @@ -1489,14 +1547,14 @@ impl CoreAdmin for HostAdmin { /// cancelling. Only the SSO response wait observes the cancellation token; the /// statement-store setup before it does not, so a call parked there ignores a /// cancel and would outlive any deadline that waited for it to finish. -async fn product_subtree_public_key( - authority: &(impl ProductAuthority + ?Sized), +async fn product_subtree_public_key( + accounts: &HostAccounts, product_id: &str, timeout_ms: Option, ) -> Result, v01::GenericError> { let product_id = normalize_product_identifier(product_id) .map_err(|reason| v01::GenericError { reason: reason.to_string() })?; - let Some(session) = authority.current_session() else { + let Some(authority_session) = accounts.current_session() else { return Ok(None); }; let timeout = timeout_ms @@ -1505,7 +1563,10 @@ async fn product_subtree_public_key( let mut cx = CallContext::default(); cx.set_timeout(timeout); - let call = authority.product_subtree_public_key(&cx, &session, product_id).fuse(); + let product = product_context(&product_id)?; + let call = accounts + .product_subtree_public_key(&authority_session, &cx, &product, product_id) + .fuse(); let deadline = futures_timer::Delay::new(timeout).fuse(); pin_mut!(call, deadline); futures::select! { @@ -1531,7 +1592,8 @@ async fn product_subtree_public_key( /// in-flight dispatch cancellation on dispose. pub struct ProductRuntime { core: TrUApiCore, - admin: HostAdmin, + host_session: Arc, + connection: Arc, transport: Arc, host_subscriptions: Arc, disposed: Arc, @@ -1648,7 +1710,7 @@ impl ProductRuntime { /// what it cached before. For an embedder that holds only this runtime; /// one holding the host runtime calls it there. pub fn notify_contacts_changed(&self) { - self.admin.product_runtime.connection().services().contact_handles.clear(); + self.connection.services().contact_handles.clear(); } /// Build a product-facing host core around a platform implementation and @@ -1723,18 +1785,23 @@ impl ProductRuntime { pairing.product_runtime(product, sink) } - /// Build a product runtime with connection-scoped adapters. #[instrument(skip_all, fields(runtime.method = "product_runtime.new"))] - pub fn new( + fn new( services: Arc, - authority: Arc, + accounts: Arc>, host_session: Arc, product: ProductContext, adapters: ConnectionAdapters, sink: Arc, ) -> Self { - let session_state = host_session.session_state(); - let admin = HostAdmin::new(services.clone(), authority, host_session, product, adapters); + let runtime = Arc::new(ProductRuntimeHost::from_services( + services.clone(), + adapters, + accounts, + host_session.clone(), + product, + )); + let connection = runtime.connection().clone(); let disposed = Arc::new(AtomicBool::new(false)); let transport = Arc::new(SinkTransport { sink, @@ -1744,12 +1811,9 @@ impl ProductRuntime { }); let host_subscriptions = Arc::new(HostInitiatedSubscriptionManager::new()); Self { - core: TrUApiCore::from_product_runtime( - admin.product_runtime.clone(), - services.spawner.clone(), - session_state, - ), - admin, + core: crate::truapi_core::from_product_runtime(runtime, services.spawner.clone()), + connection, + host_session, transport, host_subscriptions, disposed, @@ -1819,7 +1883,7 @@ impl ProductRuntime { /// Return a cloneable native control handle bound to this connection. pub fn control(&self) -> ProductRuntimeControl { ProductRuntimeControl { - connection: self.admin.product_runtime.connection().clone(), + connection: self.connection.clone(), transport: self.transport.clone(), host_subscriptions: self.host_subscriptions.clone(), disposed: self.disposed.clone(), @@ -1831,7 +1895,7 @@ impl ProductRuntime { /// session state. #[instrument(skip_all, fields(runtime.method = "product_runtime.disconnect_session"))] pub async fn disconnect_session(&self) { - self.admin.disconnect_session().await; + self.host_session.disconnect().await; } /// Read a stored permission authorization status without prompting. @@ -1844,7 +1908,7 @@ impl ProductRuntime { &self, request: PermissionAuthorizationRequest, ) -> Result { - self.admin.permission_authorization_status(request).await + self.connection.permission_authorization_status(request).await } /// Read stored permission authorization statuses without prompting. @@ -1857,7 +1921,7 @@ impl ProductRuntime { &self, requests: Vec, ) -> Result, v01::GenericError> { - self.admin.permission_authorization_statuses(requests).await + self.connection.permission_authorization_statuses(requests).await } /// Update a stored permission authorization status. `NotDetermined` @@ -1868,7 +1932,7 @@ impl ProductRuntime { request: PermissionAuthorizationRequest, status: PermissionAuthorizationStatus, ) -> Result<(), v01::GenericError> { - self.admin.set_permission_authorization_status(request, status).await + self.connection.set_permission_authorization_status(request, status).await } /// Install a dev-only [`DebugSink`] that observes every product frame in @@ -1904,10 +1968,9 @@ impl ProductRuntime { handle.abort(); } } - let connection = self.admin.product_runtime.connection(); - connection.detach_chat(); - connection.detach_renderer(); - connection.release_open_operations(); + self.connection.detach_chat(); + self.connection.detach_renderer(); + self.connection.release_open_operations(); self.host_subscriptions.close(); self.core.cancel_subscriptions(); } @@ -2033,7 +2096,6 @@ mod tests { use parity_scale_codec::Encode; use std::sync::atomic::Ordering; use truapi::{ - api::Permissions, latest::{RemotePermission, RemotePermissionRequest, RemotePermissionResponse}, versioned::permissions, }; @@ -2084,7 +2146,7 @@ mod tests { || !platform.auth_states.lock().expect("auth state list mutex poisoned").is_empty(), "the boot reconcile did not report the empty session store", ); - runtime.pairing_host.session_state().set_session(session); + runtime.pairing.session().session_state().set_session(session); } fn assert_send(_: T) {} @@ -2359,7 +2421,8 @@ mod tests { let session = crate::test_support::sso_session_info(); install_session_after_boot(&runtime, &platform, session.clone()); runtime - .pairing_host + .pairing + .accounts() .cache_product_subtree_for_test(&session, "myapp.dot", [9; 32]); let key = @@ -2833,24 +2896,31 @@ mod tests { crate::platform::ProductExecutionKind::Worker, ) .expect("worker product context is valid"); + let sink = Arc::new(RecordingSink::default()); let runtime = ProductRuntime::from_platform_with_config( Arc::new(StubPlatform::default()), host_config, product, test_spawner(), - Arc::new(RecordingSink::default()), + sink.clone(), ); - let host = runtime.admin.product_runtime().clone(); assert!( - host.test_session_state().current().is_none(), + runtime.core.session_state().current().is_none(), "the fixture must be signed out for this test to mean anything" ); - let mut actions = futures::executor::block_on(truapi::api::Renderer::action_subscribe( - host.as_ref(), - &CallContext::with_request_id("renderer:1".to_string()), - truapi::versioned::renderer::HostRendererActionSubscribeRequest::V1, - )); + let ids = subscription_ids("renderer_action_subscribe").expect("known subscription"); + let mut frame = ProtocolMessage { + request_id: "renderer:1".to_string(), + payload: Payload { + trait_id: ids.trait_id, + method_id: ids.method_id, + message_type: crate::frame::MESSAGE_TYPE_START, + value: truapi::versioned::renderer::HostRendererActionSubscribeRequest::V1.encode(), + }, + }; + futures::executor::block_on(runtime.receive_frame(frame.encode())) + .expect("subscribe actions"); let _render = runtime .control() @@ -2859,6 +2929,7 @@ mod tests { payload: vec![], }) .expect("a signed-out Worker connection may render"); + sink.frames.lock().unwrap().clear(); let published = v01::HostRendererActionSubscribeItem { context: v01::RenderContext::PocketCard { card_id: "loyalty".into() }, @@ -2870,17 +2941,12 @@ mod tests { .publish_renderer_action(published.clone()) .expect("a signed-out Worker connection may receive actions"); - let mut cx = core::task::Context::from_waker(futures::task::noop_waker_ref()); - let delivered = match actions.poll_next_unpin(&mut cx) { - core::task::Poll::Ready(Some(item)) => item, - other => panic!("a published renderer action must be ready, got {other:?}"), - }; - let Ok(truapi::versioned::renderer::HostRendererActionSubscribeItem::V1(delivered)) = - delivered - else { - panic!("expected a renderer action item") - }; - assert_eq!(delivered, published); + frame.payload.message_type = crate::frame::MESSAGE_TYPE_RECEIVE; + frame.payload.value = + truapi::versioned::renderer::HostRendererActionSubscribeItem::V1(published).encode(); + wait_until(|| !sink.frames.lock().unwrap().is_empty(), "renderer action must arrive"); + let frames = sink.frames.lock().unwrap().clone(); + assert_eq!(frames, vec![frame.encode()]); } #[test] @@ -2912,7 +2978,7 @@ mod tests { test_spawner(), Arc::new(RecordingSink::default()), ); - let services = runtime.admin.product_runtime().connection().services().clone(); + let services = runtime.connection.services().clone(); let demand = Arc::new(RecordingDemand::default()); assert!( services.worker_ledger.install_demand_observer(demand.clone()), @@ -3017,7 +3083,7 @@ mod tests { #[test] fn a_render_the_product_ends_releases_its_worker_reference() { let (runtime, sink) = render_runtime(); - let services = runtime.admin.product_runtime().connection().services().clone(); + let services = runtime.connection.services().clone(); let mut render = start_render(&runtime, "loyalty"); assert_eq!(services.worker_ledger.count("worker.dot"), 1); @@ -3042,7 +3108,7 @@ mod tests { #[test] fn a_render_the_product_interrupts_releases_its_worker_reference() { let (runtime, sink) = render_runtime(); - let services = runtime.admin.product_runtime().connection().services().clone(); + let services = runtime.connection.services().clone(); let mut render = start_render(&runtime, "loyalty"); let request_id = render_request_id(&sink, 0); @@ -3074,7 +3140,7 @@ mod tests { #[test] fn a_render_refused_for_a_malformed_tree_releases_its_worker_reference() { let (runtime, sink) = render_runtime(); - let services = runtime.admin.product_runtime().connection().services().clone(); + let services = runtime.connection.services().clone(); let mut render = start_render(&runtime, "loyalty"); let request_id = render_request_id(&sink, 0); @@ -3092,7 +3158,7 @@ mod tests { #[test] fn disposing_the_core_releases_an_open_renders_worker_reference() { let (runtime, _sink) = render_runtime(); - let services = runtime.admin.product_runtime().connection().services().clone(); + let services = runtime.connection.services().clone(); let mut render = start_render(&runtime, "loyalty"); assert_eq!(services.worker_ledger.count("worker.dot"), 1); @@ -3107,7 +3173,7 @@ mod tests { #[test] fn one_render_ending_leaves_the_other_renders_worker_reference() { let (runtime, sink) = render_runtime(); - let services = runtime.admin.product_runtime().connection().services().clone(); + let services = runtime.connection.services().clone(); let mut first = start_render(&runtime, "loyalty"); let second = start_render(&runtime, "rewards"); assert_eq!(services.worker_ledger.count("worker.dot"), 2); @@ -3376,11 +3442,68 @@ mod tests { ); } + fn begin_worker_operation(runtime: &ProductRuntime) { + let ids = request_ids("worker_begin_operation").expect("known Worker request"); + let frame = ProtocolMessage { + request_id: "worker:begin".to_string(), + payload: Payload { + trait_id: ids.trait_id, + method_id: ids.method_id, + message_type: crate::frame::MESSAGE_TYPE_REQUEST, + value: truapi::versioned::worker::HostWorkerBeginOperationRequest::V1( + truapi::latest::HostWorkerBeginOperationRequest { label: None }, + ) + .encode(), + }, + }; + futures::executor::block_on(runtime.receive_frame(frame.encode())) + .expect("begin operation frame"); + } + + #[test] + fn open_operation_demand_lasts_until_dispose_or_the_last_control() { + for dispose in [false, true] { + let platform = Arc::new(StubPlatform::default()); + let sink = Arc::new(RecordingSink::default()); + let (host_config, mut product) = runtime_config("myapp.dot"); + product.execution_kind = crate::platform::ProductExecutionKind::Worker; + let runtime = ProductRuntime::from_platform_with_config( + platform.clone(), + host_config, + product, + test_spawner(), + sink, + ); + let services = runtime.connection.services().clone(); + let control = runtime.control(); + + begin_worker_operation(&runtime); + assert_eq!(services.worker_ledger.count("myapp.dot"), 1); + + if dispose { + runtime.dispose(); + wait_until( + || { + *platform.ended_operations.lock().unwrap() == + vec![("myapp.dot".to_string(), 1)] + }, + "disposing the runtime must end its host operations while controls still exist", + ); + } + drop(runtime); + assert_eq!(services.worker_ledger.count("myapp.dot"), usize::from(!dispose)); + + drop(control); + assert_eq!(services.worker_ledger.count("myapp.dot"), 0); + } + } + #[test] fn dispose_releases_the_demand_open_operations_hold() { let platform = Arc::new(StubPlatform::default()); let sink = Arc::new(RecordingSink::default()); - let (host_config, product) = runtime_config("myapp.dot"); + let (host_config, mut product) = runtime_config("myapp.dot"); + product.execution_kind = crate::platform::ProductExecutionKind::Worker; let runtime = ProductRuntime::from_platform_with_config( platform, host_config, @@ -3388,71 +3511,20 @@ mod tests { test_spawner(), sink, ); - let host = runtime.admin.product_runtime().clone(); + let services = runtime.connection.services().clone(); - futures::executor::block_on(truapi::api::Worker::begin_operation( - host.as_ref(), - &truapi::CallContext::default(), - truapi::versioned::worker::HostWorkerBeginOperationRequest::V1( - truapi::latest::HostWorkerBeginOperationRequest { label: None }, - ), - )) - .expect("begin operation"); - assert_eq!(host.connection().services().worker_ledger.count("myapp.dot"), 1); + begin_worker_operation(&runtime); + assert_eq!(services.worker_ledger.count("myapp.dot"), 1); runtime.dispose(); assert_eq!( - host.connection().services().worker_ledger.count("myapp.dot"), + services.worker_ledger.count("myapp.dot"), 0, "disposing a connection drops the demand its open operations held" ); } - #[test] - fn dispose_ends_the_operations_the_host_is_still_holding() { - let platform = Arc::new(StubPlatform::default()); - let sink = Arc::new(RecordingSink::default()); - let (host_config, product) = runtime_config("myapp.dot"); - let runtime = ProductRuntime::from_platform_with_config( - platform.clone(), - host_config, - product, - test_spawner(), - sink, - ); - let host = runtime.admin.product_runtime().clone(); - - futures::executor::block_on(truapi::api::Worker::begin_operation( - host.as_ref(), - &truapi::CallContext::default(), - truapi::versioned::worker::HostWorkerBeginOperationRequest::V1( - truapi::latest::HostWorkerBeginOperationRequest { label: None }, - ), - )) - .expect("begin operation"); - - runtime.dispose(); - - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); - loop { - let ended = platform - .ended_operations - .lock() - .expect("ended operations mutex poisoned") - .clone(); - if ended == vec![("myapp.dot".to_string(), 1)] { - break; - } - assert!( - std::time::Instant::now() < deadline, - "the host's own record of the operation is closed, not left to \ - accumulate; saw {ended:?}" - ); - std::thread::yield_now(); - } - } - #[test] fn dispose_cancels_active_subscriptions() { let theme_stream_dropped = Arc::new(AtomicBool::new(false)); @@ -3574,103 +3646,6 @@ mod tests { assert_eq!(answer, 42); } - #[test] - fn answer_sso_request_distinguishes_disconnect_from_ignorable_messages() { - use crate::{ - host_internal::sso_messages::{RemoteMessage, RemoteMessageData, Response, v1}, - platform::{HostInfo, PlatformInfo, SigningHostConfig}, - }; - - const ENTROPY: [u8; 32] = [0xab; 32]; - - let config = SigningHostConfig::new( - HostInfo { - name: "Polkadot Mobile".to_string(), - icon: None, - version: None, - platform: truapi::latest::HostPlatform::Unknown, - }, - PlatformInfo::default(), - [0; 32], - [0xbb; 32], - [0xcc; 32], - "paseo".to_string(), - ) - .expect("signing host config is valid"); - let runtime = - SigningHostRuntime::new(Arc::new(StubPlatform::default()), config, test_spawner()); - futures::executor::block_on(runtime.activate_local_session(ENTROPY.to_vec())) - .expect("activation succeeds"); - - let disconnected = RemoteMessage { - message_id: "m1".to_string(), - data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), - }; - let outcome = futures::executor::block_on(runtime.answer_sso_request(disconnected)); - assert!(matches!(outcome, SsoRequestOutcome::Disconnected)); - - let response_variant = RemoteMessage { - message_id: "m2".to_string(), - data: RemoteMessageData::V1(v1::RemoteMessage::SignRawWithLegacyAccountResponse( - Response { responding_to: "m2".to_string(), payload: Ok(vec![]) }, - )), - }; - let outcome = futures::executor::block_on(runtime.answer_sso_request(response_variant)); - assert!(matches!(outcome, SsoRequestOutcome::Ignored)); - } - - #[test] - fn answer_sso_request_returns_a_correlated_response() { - use crate::{ - host_internal::sso_messages::{ - ProductSubtreeRequest, RemoteMessage, RemoteMessageData, v1, - }, - platform::{HostInfo, PlatformInfo, SigningHostConfig}, - }; - - const ENTROPY: [u8; 32] = [0xab; 32]; - - let config = SigningHostConfig::new( - HostInfo { - name: "Polkadot Mobile".to_string(), - icon: None, - version: None, - platform: truapi::latest::HostPlatform::Unknown, - }, - PlatformInfo::default(), - [0; 32], - [0xbb; 32], - [0xcc; 32], - "paseo".to_string(), - ) - .expect("signing host config is valid"); - let runtime = - SigningHostRuntime::new(Arc::new(StubPlatform::default()), config, test_spawner()); - futures::executor::block_on(runtime.activate_local_session(ENTROPY.to_vec())) - .expect("activation succeeds"); - - let request = RemoteMessage { - message_id: "m3".to_string(), - data: RemoteMessageData::V1(v1::RemoteMessage::ProductSubtreeRequest( - ProductSubtreeRequest { product_id: "browse.dot".to_string() }, - )), - }; - let outcome = futures::executor::block_on(runtime.answer_sso_request(request)); - let SsoRequestOutcome::Response { message } = outcome else { - panic!("expected a response outcome"); - }; - let response = - RemoteMessage::decode(&mut message.as_slice()).expect("valid response encoding"); - assert_eq!(response.message_id, "m3:response"); - let RemoteMessageData::V1(v1::RemoteMessage::ProductSubtreeResponse(payload)) = - response.data - else { - panic!("expected a product subtree response payload"); - }; - assert_eq!(payload.responding_to, "m3"); - assert!(payload.payload.is_ok()); - } - #[test] fn disconnect_paired_host_submits_one_disconnected_message_to_the_selected_peer() { let platform = Arc::new(StubPlatform { @@ -3891,7 +3866,7 @@ mod tests { let host = ProductRuntimeHost::from_services( runtime.services.clone(), ConnectionAdapters::from_services(&runtime.services), - runtime.signing_host.clone(), + runtime.signing_host.accounts().clone(), runtime.signing_host.clone(), ProductContext::new("unknown.dot".to_string()).expect("valid product id"), ); diff --git a/rust/crates/truapi/src/host_internal/bulletin.rs b/rust/crates/truapi/src/host_internal/bulletin.rs index a0c3f8aa94..928f8210fe 100644 --- a/rust/crates/truapi/src/host_internal/bulletin.rs +++ b/rust/crates/truapi/src/host_internal/bulletin.rs @@ -1,15 +1,12 @@ -//! Bulletin store transaction construction and signing. +//! Validated Bulletin store payloads and allowance-key parsing. use subxt::{ - client::{ClientAtBlock, OnlineClientAtBlockT}, - config::{DefaultExtrinsicParamsBuilder, substrate::SubstrateConfig}, - error::ExtrinsicError, ext::{ codec::Encode, scale_encode::{self, EncodeAsFields, FieldIter, TypeResolver}, scale_type_resolver::{Primitive, visitor}, }, - tx::{StaticPayload, SubmittableTransaction}, + tx::StaticPayload, }; use crate::{host_internal::extrinsic::Sr25519Signer, runtime::BulletinAllowanceKey}; @@ -20,7 +17,7 @@ pub const STORE_PALLET_NAME: &str = "TransactionStorage"; pub const STORE_CALL_NAME: &str = "store"; /// Mortality window for store transactions. -const MORTAL_PERIOD_BLOCKS: u64 = 64; +pub const MORTAL_PERIOD_BLOCKS: u64 = 64; /// Preimage key: blake2b-256 of the raw preimage bytes. pub fn preimage_key(value: &[u8]) -> [u8; 32] { @@ -52,23 +49,9 @@ pub fn preimage_cid(key: &[u8; 32]) -> String { cid } -/// Sign with a finalized mortality anchor and the client's current account nonce. -pub async fn build_signed_store_transaction>( - client: &ClientAtBlock, - finalized: &ClientAtBlock, - signer: &Sr25519Signer, - data: &[u8], -) -> Result, ExtrinsicError> { - let payload = StaticPayload::new(STORE_PALLET_NAME, STORE_CALL_NAME, StoreCallData(data)); - let params = DefaultExtrinsicParamsBuilder::::new() - .mortal_from_unchecked( - MORTAL_PERIOD_BLOCKS, - finalized.block_number(), - finalized.block_hash(), - ) - .build(); - let mut tx = client.tx(); - tx.create_signed(&payload, signer, params).await +/// Store payload whose call data is checked against the chain metadata. +pub fn store_transaction_payload(data: &[u8]) -> impl subxt::tx::Payload + '_ { + StaticPayload::new(STORE_PALLET_NAME, STORE_CALL_NAME, StoreCallData(data)) } /// The only [`BulletinAllowanceKey`] -> signer conversion in the crate. The @@ -128,6 +111,11 @@ fn require_u8_sequence( #[cfg(test)] mod tests { + use subxt::{ + config::{DefaultExtrinsicParamsBuilder, substrate::SubstrateConfig}, + tx::SubmittableTransaction, + }; + use super::*; use crate::{ host_internal::extrinsic::tests::{OfflineChainState, bulletin_chain_state, split_v4}, @@ -521,9 +509,7 @@ mod tests { #[test] fn rejects_secret_of_wrong_shape() { - let error = - allowance_signer(&BulletinAllowanceKey::from_secret_bytes(vec![0xff; 64]).unwrap()) - .unwrap_err(); - assert!(error.contains("invalid bulletin allowance key"), "{error}"); + let error = BulletinAllowanceKey::from_secret_bytes(vec![0xff; 64]).unwrap_err(); + assert!(error.to_string().contains("invalid bulletin allowance key"), "{error}"); } } diff --git a/rust/crates/truapi/src/host_internal/extrinsic.rs b/rust/crates/truapi/src/host_internal/extrinsic.rs index fb772037f2..c6d8b4704f 100644 --- a/rust/crates/truapi/src/host_internal/extrinsic.rs +++ b/rust/crates/truapi/src/host_internal/extrinsic.rs @@ -30,7 +30,7 @@ use subxt::{ tx::Signer, utils::{AccountId32, H256, MultiAddress, MultiSignature}, }; -use truapi::latest::{HostCreateTransactionResponse, TxPayloadExtension}; +use truapi::latest::TxPayloadExtension; use crate::{chain_runtime::ChainRuntime, host_logic::product_account::SR25519_SIGNING_CONTEXT}; @@ -308,30 +308,18 @@ pub enum LocalTransactionError { Other(String), } -/// Assemble a local transaction using the client's runtime metadata. -pub async fn build_local_transaction( +/// Runtime metadata for locally constructing a transaction. +pub async fn local_transaction_metadata( chain: &ChainRuntime, - keypair: &schnorrkel::Keypair, genesis_hash: [u8; 32], - call_data: &[u8], - extensions: &[TxPayloadExtension], - tx_ext_version: u8, -) -> Result { +) -> Result { let client = chain.online_client(&genesis_hash).await.map_err(|error| { LocalTransactionError::ChainUnavailable(format!("cannot load chain metadata: {error}")) })?; let at_block = client.at_current_block().await.map_err(|error| { LocalTransactionError::ChainUnavailable(format!("cannot select a metadata block: {error}")) })?; - let transaction = build_signed_transaction( - &Sr25519Signer::from_keypair(keypair), - genesis_hash, - call_data, - extensions, - tx_ext_version, - at_block.metadata(), - )?; - Ok(HostCreateTransactionResponse { transaction }) + Ok(at_block.metadata()) } /// Choose the extrinsic format for `tx_ext_version`, then assemble it. diff --git a/rust/crates/truapi/src/host_internal/sso_messages.rs b/rust/crates/truapi/src/host_internal/sso_messages.rs index 808aa55c7a..128fd777d4 100644 --- a/rust/crates/truapi/src/host_internal/sso_messages.rs +++ b/rust/crates/truapi/src/host_internal/sso_messages.rs @@ -106,21 +106,6 @@ pub struct Response

{ pub payload: P, } -/// Result of answering an SSO message for a caller-owned session. -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Enum))] -pub enum SsoRequestOutcome { - /// Encoded response to post on the session. - Response { - /// SCALE-encoded `RemoteMessage` response. - message: Vec, - }, - /// The peer ended the session. - Disconnected, - /// The message did not require a response. - Ignored, -} - /// A product's canonical request payload and the identity of its caller. /// /// SCALE encodes the caller followed directly by the payload fields. diff --git a/rust/crates/truapi/src/host_internal/sso_wire.rs b/rust/crates/truapi/src/host_internal/sso_wire.rs index 5bd3ab0233..20338cb152 100644 --- a/rust/crates/truapi/src/host_internal/sso_wire.rs +++ b/rust/crates/truapi/src/host_internal/sso_wire.rs @@ -68,6 +68,16 @@ impl ResponseOutcome { } impl RemoteMessage { + /// Request withdrawn by this control message. + pub fn withdrawn_request_id(&self) -> Option<&str> { + match &self.data { + RemoteMessageData::V1(v1::RemoteMessage::Cancel(withdrawal)) => { + Some(&withdrawal.message_id) + }, + _ => None, + } + } + /// Service method name for requests; variant name for other messages. pub fn name(&self) -> &'static str { let RemoteMessageData::V1(message) = &self.data; diff --git a/rust/crates/truapi/src/native.rs b/rust/crates/truapi/src/native.rs index f91b5f16b6..bf012e6b5b 100644 --- a/rust/crates/truapi/src/native.rs +++ b/rust/crates/truapi/src/native.rs @@ -24,16 +24,14 @@ mod executor; mod platform; mod renderer; mod runtime; +mod sso; mod subscribers; mod ws_bridge; -pub use crate::{ - host_internal::sso_messages::SsoRequestOutcome, - host_logic::{ - dev_server::{DevServerError, DevServerProduct}, - dotns::{NavigateDecision, PocketDeeplinkAction}, - scanner::{ScanFilter, ScanVerdict}, - }, +pub use crate::host_logic::{ + dev_server::{DevServerError, DevServerProduct}, + dotns::{NavigateDecision, PocketDeeplinkAction}, + scanner::{ScanFilter, ScanVerdict}, }; pub use callbacks::{ HostCallbacks, NativeBalanceCallbacks, NativeChatCallbacks, NativeContactsCallbacks, @@ -48,6 +46,7 @@ pub use renderer::{NativeRendererObserver, NativeRendererSubscription}; pub use runtime::{ NativeAnnouncedPairing, NativePairingError, NativeProductExecution, NativeTrUApiHostRuntime, }; +pub use sso::SsoRequestOutcome; pub use ws_bridge::{WsBridgeEndpoint, WsBridgeStartError}; use parity_scale_codec::{DecodeLimit, Encode}; @@ -58,6 +57,15 @@ use truapi::latest; use crate::SigningHostRuntime; use crate::{PairingProposal, host_logic::dotns}; +/// Classify a core slot so native hosts can choose its storage protection. +#[uniffi::export] +pub fn core_storage_key_description( + encoded: Vec, +) -> Result { + crate::platform::describe_core_storage_key(&encoded) + .map_err(|error| HostRejection::Rejected { reason: error.to_string() }) +} + /// Classify a navigation input exactly like the core's internal navigate host /// call: dotNS first, then `localhost`, then normalized external, with /// everything else rejected. Pure and stateless; hosts call it on every diff --git a/rust/crates/truapi/src/native/callbacks.rs b/rust/crates/truapi/src/native/callbacks.rs index c937a1197e..0954d53e54 100644 --- a/rust/crates/truapi/src/native/callbacks.rs +++ b/rust/crates/truapi/src/native/callbacks.rs @@ -100,6 +100,9 @@ pub trait HostCallbacks: Send + Sync { /// only when the state changes. fn auth_state_changed(&self, state: AuthState); + /// List encoded core keys without loading or decrypting their values. + async fn core_storage_keys(&self) -> Result>, HostRejection>; + /// Read a core-owned host-private storage slot. `key` is a SCALE-encoded /// [`CoreStorageKey`]. async fn core_storage_read(&self, key: Vec) -> Result>, HostRejection>; diff --git a/rust/crates/truapi/src/native/platform.rs b/rust/crates/truapi/src/native/platform.rs index 28821b5363..63a7b6dbf0 100644 --- a/rust/crates/truapi/src/native/platform.rs +++ b/rust/crates/truapi/src/native/platform.rs @@ -313,6 +313,16 @@ impl ProductOperations for CallbackPlatform { #[async_trait] impl CoreStorage for CallbackPlatform { + async fn core_storage_keys( + &self, + ) -> Result { + self.callbacks + .core_storage_keys() + .await + .map(|encoded_keys| crate::platform::CoreStorageKeys { encoded_keys }) + .map_err(v01::GenericError::from) + } + async fn read_core_storage( &self, key: CoreStorageKey, diff --git a/rust/crates/truapi/src/native/runtime.rs b/rust/crates/truapi/src/native/runtime.rs index a225a6a277..51bd6ebe8f 100644 --- a/rust/crates/truapi/src/native/runtime.rs +++ b/rust/crates/truapi/src/native/runtime.rs @@ -11,7 +11,7 @@ use crate::platform::{ ProductExecutionKind, }; use parity_scale_codec::Encode; -use truapi::{Bytes32, api::Permissions, v01, versioned::IntoLatest}; +use truapi::{Bytes32, v01}; use super::{ reject_undecodable_deeplink, @@ -23,11 +23,9 @@ use crate::{ durable::RecoveryError, host_internal::{ permissions::TemporaryPermissions, - sso_messages::{ - RemoteMessage, RemoteMessageData, SsoRequestOutcome, decode_remote_message, v1, - }, + sso_messages::{RemoteMessage, RemoteMessageData, v1}, }, - runtime::{AnnouncedPairing, sso_remote::sso_message_id}, + runtime::{AnnouncedPairing, SsoAccountHolderService, sso_remote::sso_message_id}, store::{Db, core_db_config}, subscription::Spawner, }; @@ -606,6 +604,16 @@ impl NativeTrUApiHostRuntime { self.runtime.last_statement_renewal_report() } + /// Remove retained grants when a product is uninstalled. + pub async fn clear_product_state(&self, product_id: String) -> Result<(), HostRejection> { + self.runtime.clear_product_state(&product_id).await.map_err(Into::into) + } + + /// Remove a deleted wallet's retained grants, including while locked. + pub async fn clear_wallet_state(&self, owner: Bytes32) -> Result<(), HostRejection> { + self.runtime.clear_wallet_state(owner).await.map_err(Into::into) + } + /// Activate or replace the process-wide local signing session. pub fn activate_local_session( &self, @@ -635,27 +643,16 @@ impl NativeTrUApiHostRuntime { self.runtime.run_durable_recovery().await } - /// Answer one decrypted SSO remote message from a wallet-managed - /// statement-store session. - /// - /// `message` is one SCALE-encoded `RemoteMessage` exactly as decrypted from - /// the session statement. The bytes are deliberately opaque at this - /// boundary: the wallet forwards wire encodings verbatim and never - /// constructs them. Session control and transport stay with the wallet — - /// `Disconnected` is reported, never handled here. Confirmation-gated - /// requests await `confirm_user_action`, so this can take arbitrarily long. - /// - /// A `Cancel` withdraws the request it names and returns at once. It - /// reaches a running request only if the wallet passes it on as it - /// arrives; queued behind that request it arrives too late to stop it. - /// The withdrawn request, and the `Cancel` itself, answer `Ignored`. - pub async fn handle_sso_request( + /// Bind externally owned SSO transport to its current wallet activation. + pub fn open_sso_service( &self, - message: Vec, - ) -> Result { - let message = - decode_remote_message(&message).map_err(|reason| HostRejection::Rejected { reason })?; - Ok(self.runtime.answer_sso_request(message).await) + own_statement_account_id: Bytes32, + own_encryption_public_key: Bytes32, + ) -> Result, HostRejection> { + self.runtime + .open_sso_service(own_statement_account_id, own_encryption_public_key) + .map(Arc::new) + .map_err(|error| HostRejection::Rejected { reason: error.to_string() }) } /// Build the SCALE-encoded `Disconnected` message a wallet posts over a @@ -736,7 +733,11 @@ impl NativeProductExecution { } fn admin(&self) -> crate::HostAdmin { - self.runtime.product_admin_with(self.product.clone(), self.adapters()) + crate::host_core::product_admin_with_adapters( + &self.runtime, + self.product.clone(), + self.adapters(), + ) } fn require_chat(&self) -> Result<(), crate::ProductRuntimeError> { @@ -780,18 +781,17 @@ impl NativeProductExecution { reason: "product execution is closed".to_string(), }); } - let response = self + let granted = self .admin() - .product_runtime() - .authorize_remote_permission(&truapi::CallContext::default(), request.into()) + .authorize_remote_permission(request) .await - .map_err(|error| HostRejection::Rejected { reason: format!("{error:?}") })?; + .map_err(|error| HostRejection::Rejected { reason: error.reason })?; if self.closed.load(Ordering::Acquire) { return Err(HostRejection::Rejected { reason: "product execution is closed".to_string(), }); } - Ok(response.into_latest().granted) + Ok(granted) } /// Authorize one device capability that the host's web view gates itself, @@ -806,18 +806,17 @@ impl NativeProductExecution { reason: "product execution is closed".to_string(), }); } - let response = self + let granted = self .admin() - .product_runtime() - .authorize_device_permission(&truapi::CallContext::default(), request.into()) + .authorize_device_permission(request) .await - .map_err(|error| HostRejection::Rejected { reason: format!("{error:?}") })?; + .map_err(|error| HostRejection::Rejected { reason: error.reason })?; if self.closed.load(Ordering::Acquire) { return Err(HostRejection::Rejected { reason: "product execution is closed".to_string(), }); } - Ok(response.into_latest().granted) + Ok(granted) } /// Read a product-scoped permission authorization without prompting. @@ -1003,8 +1002,12 @@ impl NativeProductExecution { let adapters = self.adapters(); let product_control = self.product_control.clone(); let runtime_factory = Arc::new(move |sink| { - let product_runtime = - runtime.product_runtime_with(product.clone(), adapters.clone(), sink); + let product_runtime = crate::host_core::product_runtime_with_adapters( + &runtime, + product.clone(), + adapters.clone(), + sink, + ); *product_control.lock().expect("native product control mutex poisoned") = Some(product_runtime.control()); product_runtime @@ -1041,6 +1044,7 @@ mod tests { use super::*; use crate::{ PairedSsoPeer, + frame::{Payload, ProtocolMessage, request_ids, subscription_ids}, native::tests::*, platform::{ PermissionAuthorizationRequest, PermissionAuthorizationStatus, PermissionDecision, @@ -1048,8 +1052,48 @@ mod tests { }, }; use futures::stream::StreamExt; - use std::sync::{Arc, atomic::Ordering}; - use truapi::v01; + use parity_scale_codec::{Decode, Encode}; + use std::sync::{ + Arc, + atomic::Ordering, + mpsc::{Receiver, Sender}, + }; + use truapi::{v01, versioned::IntoLatest}; + + struct ProductFrames(Sender); + + impl crate::FrameSink for ProductFrames { + fn emit_frame(&self, frame: Vec) { + let _ = self.0.send(ProtocolMessage::decode(&mut frame.as_slice()).unwrap()); + } + } + + fn product_connection( + execution: &NativeProductExecution, + ) -> (crate::ProductRuntime, Receiver) { + let (sender, receiver) = std::sync::mpsc::channel(); + let runtime = crate::host_core::product_runtime_with_adapters( + &execution.runtime, + execution.product.clone(), + execution.adapters(), + Arc::new(ProductFrames(sender)), + ); + (runtime, receiver) + } + + fn product_request(method: &str, value: Vec) -> Vec { + let ids = request_ids(method).or_else(|| subscription_ids(method)).unwrap(); + ProtocolMessage { + request_id: method.to_string(), + payload: Payload { + trait_id: ids.trait_id, + method_id: ids.method_id, + message_type: crate::frame::MESSAGE_TYPE_REQUEST, + value, + }, + } + .encode() + } #[test] fn a_worker_write_reaches_a_storage_subscription_in_the_products_other_execution() { @@ -1220,12 +1264,12 @@ mod tests { ) .expect("Worker execution should open"); - let admin = execution.admin(); - let mut actions = futures::executor::block_on(truapi::api::Renderer::action_subscribe( - admin.product_runtime().as_ref(), - &truapi::CallContext::with_request_id("renderer-1".to_string()), - truapi::versioned::renderer::HostRendererActionSubscribeRequest::V1, - )); + let (connection, frames) = product_connection(&execution); + futures::executor::block_on(connection.receive_frame(product_request( + "renderer_action_subscribe", + truapi::versioned::renderer::HostRendererActionSubscribeRequest::V1.encode(), + ))) + .unwrap(); let published = v01::HostRendererActionSubscribeItem { context: v01::RenderContext::ChatMessage { @@ -1240,15 +1284,19 @@ mod tests { .publish_renderer_action(published.clone()) .expect("a Worker execution may publish renderer actions"); - let mut cx = core::task::Context::from_waker(futures::task::noop_waker_ref()); - let delivered = match actions.poll_next_unpin(&mut cx) { - core::task::Poll::Ready(Some(item)) => item, - other => panic!("a published renderer action must be ready, got {other:?}"), - }; - let delivered = delivered.expect("expected a renderer action item").into_latest(); - assert_eq!(delivered, published); + let delivered = frames.recv_timeout(std::time::Duration::from_secs(2)).unwrap(); + connection.dispose(); + assert_eq!( + delivered.payload.value, + truapi::versioned::renderer::HostRendererActionSubscribeItem::V1(published).encode(), + ); // The scanner checks taps on a connection, while hosts publish them here. - assert!(admin.product_runtime().connection().recently_tapped()); + assert!( + execution + .adapters() + .renderer + .published_within(crate::runtime::USER_TAP_WINDOW_SECS) + ); } #[test] @@ -1274,12 +1322,25 @@ mod tests { let (card, card_callbacks) = open_widget("card.dot"); let (_other, other_callbacks) = open_widget("other.dot"); - let admin = card.admin(); - let reply = futures::executor::block_on(truapi::api::ExpandedCard::set_face_shown( - admin.product_runtime().as_ref(), - &truapi::CallContext::default(), - v01::HostExpandedCardSetFaceShownRequest { shown: false }.into(), - )); + let (connection, frames) = product_connection(&card); + futures::executor::block_on( + connection.receive_frame(product_request( + "expanded_card_set_face_shown", + truapi::versioned::expanded_card::HostExpandedCardSetFaceShownRequest::V1( + v01::HostExpandedCardSetFaceShownRequest { shown: false }, + ) + .encode(), + )), + ) + .unwrap(); + let reply = frames.recv_timeout(std::time::Duration::from_secs(2)).unwrap(); + connection.dispose(); + + let reply = Result::< + truapi::versioned::expanded_card::HostExpandedCardSetFaceShownResponse, + truapi::CallError, + >::decode(&mut reply.payload.value.as_slice()) + .unwrap(); assert!(reply.is_ok(), "the card's own host applied the request: {reply:?}"); assert_eq!(*card_callbacks.face_requests.lock().expect("face requests"), [false]); @@ -1447,6 +1508,7 @@ mod tests { }; let execution = open(); let other = open(); + let (connection, frames) = product_connection(&execution); futures::executor::block_on(async { let admin = execution.admin(); let request = truapi::latest::RemotePermissionRequest { @@ -1454,13 +1516,27 @@ mod tests { domains: vec!["api.example.com".to_string()], }, }; - let context = truapi::CallContext::default(); - let sdk_request = || { - admin - .product_runtime() - .request_remote_permission(&context, request.clone().into()) + let sdk_request = || async { + connection + .receive_frame(product_request( + "permissions_request_remote_permission", + truapi::versioned::permissions::RemotePermissionRequest::V1( + request.clone(), + ) + .encode(), + )) + .await + .unwrap(); + let response = frames.recv_timeout(std::time::Duration::from_secs(2)).unwrap(); + Result::< + truapi::versioned::permissions::RemotePermissionResponse, + truapi::CallError, + >::decode(&mut response.payload.value.as_slice()) + .unwrap() + .unwrap() + .into_latest() }; - let granted = sdk_request().await.unwrap().into_latest(); + let granted = sdk_request().await; let permission = PermissionAuthorizationRequest::Remote(request.clone()); let other_status = other.permission_authorization_status(permission.clone()).await.unwrap(); @@ -1472,7 +1548,7 @@ mod tests { execution.authorize_remote_permission(request.clone()).await.unwrap(); let other_operation = other.authorize_remote_permission(request.clone()).await.unwrap(); let prompts_after_operations = callbacks.remote_permission_calls.load(Ordering::SeqCst); - sdk_request().await.unwrap(); + sdk_request().await; execution.shutdown(); let after_shutdown = admin.permission_authorization_status(permission).await.unwrap(); assert_eq!( diff --git a/rust/crates/truapi/src/native/sso.rs b/rust/crates/truapi/src/native/sso.rs new file mode 100644 index 0000000000..4f77d86265 --- /dev/null +++ b/rust/crates/truapi/src/native/sso.rs @@ -0,0 +1,60 @@ +//! Wallet and peer lifetimes for externally owned SSO transports. + +use parity_scale_codec::Encode; + +use super::HostRejection; +use crate::{ + host_internal::sso_messages::decode_remote_message, + runtime::{SsoAccountHolderService, sso_service::Dispatch}, +}; + +/// Result for a caller that owns the authenticated SSO transport. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] +pub enum SsoRequestOutcome { + /// Response to post through the same peer's transport. + Response { + /// SCALE-encoded `RemoteMessage`, forwarded without interpretation. + message: Vec, + }, + /// The peer ended the session; the caller removes its transport and records. + Disconnected, + /// No response to post. + Ignored, +} + +#[uniffi::export] +impl SsoAccountHolderService { + /// Apply Cancel immediately; other messages remain in the transport's request queue. + pub fn handle_sso_control( + &self, + message: Vec, + ) -> Result, HostRejection> { + let message = decode_remote_message(&message).map_err(reject)?; + Ok(self.handle_control(&message).map(outcome)) + } + + /// Answer one decrypted SCALE message using this peer's original wallet activation. + pub async fn handle_sso_request( + &self, + message: Vec, + ) -> Result { + let message = decode_remote_message(&message).map_err(reject)?; + Ok(outcome(self.answer(message).await.map_err(reject)?)) + } +} + +fn reject(error: impl core::fmt::Display) -> HostRejection { + HostRejection::Rejected { reason: error.to_string() } +} + +fn outcome(dispatch: Dispatch) -> SsoRequestOutcome { + match dispatch { + Dispatch::Response(answer) => { + SsoRequestOutcome::Response { message: answer.message.encode() } + }, + Dispatch::Disconnected => SsoRequestOutcome::Disconnected, + Dispatch::NotARequest(_) | Dispatch::Withdraw(_) | Dispatch::Withdrawn => { + SsoRequestOutcome::Ignored + }, + } +} diff --git a/rust/crates/truapi/src/native/tests.rs b/rust/crates/truapi/src/native/tests.rs index 40a41af5ad..2ed121663f 100644 --- a/rust/crates/truapi/src/native/tests.rs +++ b/rust/crates/truapi/src/native/tests.rs @@ -317,6 +317,9 @@ impl HostCallbacks for EventCallbacks { fn auth_state_changed(&self, state: AuthState) { self.auth_states.lock().expect("auth state mutex poisoned").push(state); } + async fn core_storage_keys(&self) -> Result>, HostRejection> { + Ok(self.core_storage.lock().unwrap().keys().cloned().collect()) + } async fn core_storage_read(&self, key: Vec) -> Result>, HostRejection> { Ok(self.core_storage.lock().unwrap().get(&key).cloned()) } @@ -1838,6 +1841,9 @@ fn start_ws_bridge_twice_returns_already_running() { Ok(PermissionDecision::Deny) } fn auth_state_changed(&self, _state: AuthState) {} + async fn core_storage_keys(&self) -> Result>, HostRejection> { + Ok(Vec::new()) + } async fn core_storage_read(&self, _key: Vec) -> Result>, HostRejection> { Ok(None) } @@ -1999,6 +2005,9 @@ fn pending_permission_decision_does_not_stall_bridge() { Ok(PermissionDecision::Deny) } fn auth_state_changed(&self, _state: AuthState) {} + async fn core_storage_keys(&self) -> Result>, HostRejection> { + Ok(Vec::new()) + } async fn core_storage_read(&self, _key: Vec) -> Result>, HostRejection> { Ok(None) } @@ -2429,10 +2438,116 @@ pub fn native_host_runtime_no_session() -> Arc { } #[test] -fn handle_sso_request_rejects_undecodable_bytes() { - let runtime = native_host_runtime_no_session(); - let result = futures::executor::block_on(runtime.handle_sso_request(vec![0xFF, 0xFF, 0xFF])); - assert!(result.is_err(), "garbage bytes must be a decode error"); +fn native_sso_binding_verifies_transport_and_retains_its_activation() { + use crate::{ + host_internal::sso_messages::{ + ProductSubtreeRequest, RemoteMessage, RemoteMessageData, Response, v1, + }, + host_logic::{ + product_account::{derive_identity_keypair, derive_product_subtree_keypair}, + sso::pairing::derive_x25519_keypair_from_entropy, + }, + }; + use parity_scale_codec::Encode; + + let entropy = [7; 32]; + let statement = derive_identity_keypair(&entropy, "paseo").unwrap().public.to_bytes(); + let (_, encryption) = derive_x25519_keypair_from_entropy(&entropy, b"sso"); + assert!( + native_host_runtime_no_session() + .open_sso_service(statement, encryption) + .is_err() + ); + for replacement in [vec![8; 32], entropy.to_vec()] { + let runtime = NativeTrUApiHostRuntime::with_runtime_config( + Arc::new(EventCallbacks::new()), + native_host_runtime_config(), + ) + .unwrap(); + assert_eq!( + ( + runtime.open_sso_service([0; 32], encryption).is_err(), + runtime.open_sso_service(statement, [0; 32]).is_err(), + ), + (true, true), + ); + let service = runtime.open_sso_service(statement, encryption).unwrap(); + let request = RemoteMessage::request( + "subtree".to_string(), + ProductSubtreeRequest { product_id: "browse.dot".to_string() }, + ) + .encode(); + let response = |payload| SsoRequestOutcome::Response { + message: RemoteMessage { + message_id: "subtree:response".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::ProductSubtreeResponse(Response { + responding_to: "subtree".to_string(), + payload, + })), + } + .encode(), + }; + let public_key = derive_product_subtree_keypair( + &crate::host_logic::product_account::derive_root_keypair_from_entropy(&entropy) + .unwrap(), + "browse.dot", + ) + .unwrap() + .public + .to_bytes(); + assert_eq!( + futures::executor::block_on(service.handle_sso_request(request.clone())).unwrap(), + response(Ok(public_key)) + ); + let other_peer = runtime.open_sso_service(statement, encryption).unwrap(); + let cancel = RemoteMessage { + message_id: "cancel".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::Cancel( + crate::host_internal::sso_messages::Withdrawal { + message_id: "subtree".to_string(), + }, + )), + } + .encode(); + assert_eq!(service.handle_sso_control(cancel).unwrap(), Some(SsoRequestOutcome::Ignored)); + assert_eq!( + futures::executor::block_on(other_peer.handle_sso_request(request.clone())).unwrap(), + response(Ok(public_key)) + ); + assert_eq!( + futures::executor::block_on(service.handle_sso_request(request.clone())).unwrap(), + SsoRequestOutcome::Ignored + ); + assert_eq!(service.handle_sso_control(request.clone()).unwrap(), None); + assert!(futures::executor::block_on(service.handle_sso_request(vec![0xff; 3])).is_err()); + assert!(service.handle_sso_control(vec![0xff; 3]).is_err()); + let response_variant = RemoteMessage { + message_id: "response".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::ProductSubtreeResponse(Response { + responding_to: "subtree".to_string(), + payload: Ok(public_key), + })), + } + .encode(); + assert_eq!( + futures::executor::block_on(service.handle_sso_request(response_variant)).unwrap(), + SsoRequestOutcome::Ignored + ); + let disconnected = RemoteMessage { + message_id: "disconnect".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), + } + .encode(); + assert_eq!( + futures::executor::block_on(service.handle_sso_request(disconnected)).unwrap(), + SsoRequestOutcome::Disconnected + ); + runtime.activate_local_session(replacement, None).unwrap(); + assert_eq!( + futures::executor::block_on(service.handle_sso_request(request)).unwrap(), + response(Err("signing host session is not active".to_string())) + ); + } } #[test] diff --git a/rust/crates/truapi/src/platform.rs b/rust/crates/truapi/src/platform.rs index 6656f8f4be..e6179ac9dc 100644 --- a/rust/crates/truapi/src/platform.rs +++ b/rust/crates/truapi/src/platform.rs @@ -1362,6 +1362,25 @@ pub trait Features: Send + Sync { async fn supported_chains(&self) -> Result; } +/// Chain resource an allowance key grants access to. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Encode, Decode)] +pub enum AllowanceResource { + /// Bulletin-chain transaction storage. + Bulletin, + /// People-chain statement store. + StatementStore, +} + +/// Required protection for a core-owned storage value. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Enum))] +pub enum CoreStorageProtection { + /// Value contains signing or encryption authority. + Secret, + /// Value contains no private signing or encryption material. + Public, +} + /// Core-owned host-private storage slots. Products never address these slots; /// the host chooses the backing store for each slot. /// @@ -1452,6 +1471,16 @@ pub enum CoreStorageKey { /// Product whose manifest was cached, normalized. product_id: String, }, + /// One wallet's retained allowance for a product and chain resource. + #[codec(index = 13)] + NativeAllowanceKey { + /// Wallet that received the grant. + root_public_key: [u8; 32], + /// Product authorized to use the grant. + product_id: String, + /// Chain resource sponsored by the key. + resource: AllowanceResource, + }, } /// Stable metadata describing one strictly decoded [`CoreStorageKey`]. @@ -1460,11 +1489,21 @@ pub enum CoreStorageKey { /// `product_id` is present only for keys whose storage slot is directly /// product-indexed. #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Record))] pub struct CoreStorageKeyDescription { /// Stable storage-key variant name. - pub kind: &'static str, + pub kind: String, /// Product that owns this exact slot, when the key is product-indexed. pub product_id: Option, + /// Minimum protection required by the value. + pub protection: CoreStorageProtection, +} + +/// Stored key inventory, independent of value decoding or decryption. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct CoreStorageKeys { + /// Opaque keys, including keys unknown to this core version. + pub encoded_keys: Vec>, } /// Failure to decode exactly one [`CoreStorageKey`]. @@ -1489,7 +1528,26 @@ pub fn describe_core_storage_key( if !input.is_empty() { return Err(CoreStorageKeyDescriptionError::TrailingBytes); } + let protection = match &key { + CoreStorageKey::AuthSession | + CoreStorageKey::PairingDeviceIdentity | + CoreStorageKey::AllowanceKeys { .. } | + CoreStorageKey::AutoSigningKey { .. } | + CoreStorageKey::AutoSigningKeys | + CoreStorageKey::DeviceEncryptionKey | + CoreStorageKey::NativeAllowanceKey { .. } => CoreStorageProtection::Secret, + CoreStorageKey::PermissionAuthorization { .. } | + CoreStorageKey::LastProcessedPairingStatement | + CoreStorageKey::RingVrfRegistry { .. } | + CoreStorageKey::StatementRenewalTargets | + CoreStorageKey::ProductSubtree { .. } | + CoreStorageKey::SsoResponderRequestLedger { .. } | + CoreStorageKey::ProductManifest { .. } => CoreStorageProtection::Public, + }; let (kind, product_id) = match key { + CoreStorageKey::NativeAllowanceKey { product_id, .. } => { + ("NativeAllowanceKey", Some(product_id)) + }, CoreStorageKey::AuthSession => ("AuthSession", None), CoreStorageKey::PairingDeviceIdentity => ("PairingDeviceIdentity", None), CoreStorageKey::PermissionAuthorization { product_id, .. } => { @@ -1506,7 +1564,7 @@ pub fn describe_core_storage_key( CoreStorageKey::SsoResponderRequestLedger { .. } => ("SsoResponderRequestLedger", None), CoreStorageKey::ProductManifest { product_id } => ("ProductManifest", Some(product_id)), }; - Ok(CoreStorageKeyDescription { kind, product_id }) + Ok(CoreStorageKeyDescription { kind: kind.to_string(), product_id, protection }) } impl CoreStorageKey { @@ -2430,11 +2488,21 @@ mod tests { assert_eq!( describe_core_storage_key(&permission), Ok(CoreStorageKeyDescription { - kind: "PermissionAuthorization", + kind: "PermissionAuthorization".to_string(), + protection: CoreStorageProtection::Public, product_id: Some("product.dot".to_string()), }) ); for (key, kind, product_id) in [ + ( + CoreStorageKey::NativeAllowanceKey { + root_public_key: [1; 32], + product_id: "product.dot".to_string(), + resource: AllowanceResource::Bulletin, + }, + "NativeAllowanceKey", + Some("product.dot"), + ), (CoreStorageKey::AuthSession, "AuthSession", None), (CoreStorageKey::PairingDeviceIdentity, "PairingDeviceIdentity", None), ( @@ -2713,6 +2781,10 @@ mod tests { /// Host-private persistence for core-owned state. /// +/// Secret slots require protected storage. Only absence returns `None`; failures +/// must propagate. Successful secret writes and clears are durable and ordered +/// after earlier writes, including writes whose awaiting task was cancelled. +/// /// Clearing product-indexed slots is the host's job. The core drops the ones /// it is holding when a session ends, but a product it never opened this run /// has no entry to drop, so those slots outlive the disconnect. A host that @@ -2727,6 +2799,11 @@ mod tests { // TODO: introduce SecretStorage to make protection a separate contract. #[async_trait] pub trait CoreStorage: Send + Sync { + /// Enumerate encoded keys without reading values, including undecodable records. + async fn core_storage_keys(&self) -> Result { + Err(GenericError { reason: "core storage enumeration is unavailable".to_string() }) + } + /// Read a core-owned value by typed slot. async fn read_core_storage(&self, key: CoreStorageKey) -> Result>, GenericError>; diff --git a/rust/crates/truapi/src/platform/mock.rs b/rust/crates/truapi/src/platform/mock.rs index c5969aa1bd..96ab063de7 100644 --- a/rust/crates/truapi/src/platform/mock.rs +++ b/rust/crates/truapi/src/platform/mock.rs @@ -696,53 +696,10 @@ fn product_key(key: &str) -> String { format!("product:{key}") } -/// Lowercase hex for a 32-byte key used in a storage slot name. -fn hex_key(bytes: &[u8; 32]) -> String { - bytes.iter().map(|b| format!("{b:02x}")).collect() -} - /// Stable string key for a typed core-storage slot. fn core_key(key: &CoreStorageKey) -> String { - match key { - CoreStorageKey::AuthSession => "core:auth-session".to_string(), - CoreStorageKey::PairingDeviceIdentity => "core:pairing-device-identity".to_string(), - CoreStorageKey::PermissionAuthorization { product_id, request } => { - format!("core:permission:{product_id}:{request:?}") - }, - CoreStorageKey::AutoSigningKey { product_id } => { - format!("core:auto-signing-key:{product_id}") - }, - CoreStorageKey::AutoSigningKeys => "core:auto-signing-keys".to_string(), - CoreStorageKey::RingVrfRegistry { root_public_key } => { - format!("core:ring-vrf-registry:{}", hex_key(root_public_key)) - }, - CoreStorageKey::StatementRenewalTargets => "core:statement-renewal-targets".to_string(), - CoreStorageKey::DeviceEncryptionKey => "core:device-encryption-key".to_string(), - CoreStorageKey::ProductSubtree { session_id, product_id } => { - format!("core:product-subtree:{session_id}:{product_id}") - }, - CoreStorageKey::SsoResponderRequestLedger { - root_public_key, - peer_statement_account_id, - peer_encryption_public_key, - } => { - format!( - "core:sso-responder-ledger:{}:{}:{}", - hex_key(root_public_key), - hex_key(peer_statement_account_id), - hex_key(peer_encryption_public_key) - ) - }, - CoreStorageKey::ProductManifest { product_id } => { - format!("core:product-manifest:{product_id}") - }, - CoreStorageKey::AllowanceKeys { session_id } => { - format!("core:allowance-keys:{session_id}") - }, - CoreStorageKey::LastProcessedPairingStatement => { - "core:last-processed-pairing-statement".to_string() - }, - } + use parity_scale_codec::Encode; + format!("core:{}", hex::encode(key.encode())) } /// Content address of a preimage value: blake2b-256 of the raw bytes. @@ -821,6 +778,25 @@ impl ProductStorage for MockPlatform { #[async_trait] impl CoreStorage for MockPlatform { + async fn core_storage_keys( + &self, + ) -> Result { + if let Some(reason) = &self.config.faults.storage_error { + return Err(latest::GenericError { reason: reason.clone() }); + } + Ok(crate::platform::CoreStorageKeys { + encoded_keys: self + .storage + .lock() + .expect("storage poisoned") + .keys() + .filter_map(|key| { + key.strip_prefix("core:").and_then(|encoded| hex::decode(encoded).ok()) + }) + .collect(), + }) + } + async fn read_core_storage( &self, key: CoreStorageKey, diff --git a/rust/crates/truapi/src/runtime.rs b/rust/crates/truapi/src/runtime.rs index 09bbad2b39..066ca70aad 100644 --- a/rust/crates/truapi/src/runtime.rs +++ b/rust/crates/truapi/src/runtime.rs @@ -19,31 +19,34 @@ mod capabilities; mod chat; pub mod contacts; mod dotns_lookup; +mod host_accounts; +mod host_grants; mod host_session; mod identity; pub mod login_failure; mod pairing_host; mod payment_id; mod product_accounts; +mod product_consent; pub mod product_manifest; mod product_subtree; +mod remote_account_holder; mod renderer; mod ring_vrf_registry; /// Role-neutral runtime services shared by product-facing runtimes. pub mod services; mod signing_host; -/// Pairing flow for wallet login. -pub mod sso_pairing; +mod sso_account_holder_service; /// SSO remote request/response messaging over the statement store. pub mod sso_remote; +mod sso_request_service; +mod sso_responder_service; pub mod sso_service; /// Statement Store and Bulletin allowance allocation. pub mod statement_allowance; /// `StatementStore` surface: proofs plus submit and subscribe flows. pub mod statement_store; mod statement_store_rpc; -#[cfg(feature = "test-host")] -mod test_resource_controls; mod vrf; use core::{future::Future, time::Duration}; @@ -55,40 +58,40 @@ use std::{ }; pub use actions::ActionChannel; -use authority::{AuthorityCancelError, AuthoritySession}; -pub use authority::{AuthorityError, BulletinAllowanceKey, ProductAuthority}; +use authority::AuthorityCancelError; +pub use authority::{AccountHolder, AuthorityError, AuthoritySession, BulletinAllowanceKey}; pub use chat::chat_platform_for; pub use contacts::ContactResolutionError; -pub use host_session::HostSession; /// The host's contact picker plus the key its handles are minted under: /// everything one `contacts.pick` call needs from the connection. type ContactsPicker = (Arc, crate::runtime::contacts::ContactHandles); use futures::{FutureExt, StreamExt, pin_mut}; -#[cfg(test)] -use pairing_host::PairingHost; -pub use pairing_host::PairingHost as PairingHostRole; +pub use host_accounts::HostAccounts; +pub use pairing_host::PairingHost; use product_accounts::ProductAccounts; +#[cfg(feature = "test-host")] +mod test_resource_controls; +pub use host_session::HostSession; +pub use remote_account_holder::RemoteAccountHolder; pub use renderer::renderer_access_for; pub use services::RuntimeServices; -pub use signing_host::{ - AnnouncedPairing, DevicePairingObserver, LocalActivation, MAX_PAIRING_METADATA_CHARS, - PairedSsoPeer, PairingProposal, PairingProposalMetadata, ResponderExit, - SigningHost as SigningHostRole, SigningHostSsoService, disconnect_paired_host, - establish_pairing, notify_pairing_allowance_allocation, notify_pairing_failed, - respond_to_pairing, resume_pairing, +pub use signing_host::{LocalActivation, SigningHost as SigningHostRole}; +pub use sso_account_holder_service::SsoAccountHolderService; +pub use sso_responder_service::{ + AnnouncedPairing, DevicePairingObserver, MAX_PAIRING_METADATA_CHARS, PairedSsoPeer, + PairingProposal, PairingProposalMetadata, ResponderExit, SsoResponderService, }; #[cfg(all(target_arch = "wasm32", feature = "test-host"))] pub use vrf::ring_vrf_member; // `TrackedStatementRenewalTarget` is only read back by the native renewal // reporting, so re-exporting it on wasm leaves an unused import. use crate::platform::{ - AccountAccessReview, ChatFieldError, CrossProductSignatureKind, IdentityDisclosureReview, - PermissionAuthorizationRequest, PermissionAuthorizationStatus, PermissionDecision, Platform, - ProductContext, ProductStorageKey, SessionUiInfo, UserConfirmationReview, - normalize_chat_identifier, normalize_product_identifier, validate_chat_icon, - validate_chat_message_content, validate_chat_name, + ChatFieldError, IdentityDisclosureReview, PermissionAuthorizationRequest, + PermissionAuthorizationStatus, PermissionDecision, Platform, ProductContext, ProductStorageKey, + SessionUiInfo, UserConfirmationReview, normalize_chat_identifier, normalize_product_identifier, + validate_chat_icon, validate_chat_message_content, validate_chat_name, }; pub use signing_host::StatementRenewalTarget; #[cfg(not(target_arch = "wasm32"))] @@ -130,14 +133,12 @@ use crate::{ chain_runtime::RuntimeFailure, host_internal::{ bulletin::preimage_key, - permissions::{PermissionsService, TemporaryPermissions, cross_product_signature_key}, + permissions::{PermissionsService, TemporaryPermissions}, product_manifest::Granted, sso_messages::RingVrfError, }, host_logic::{ - product_account::{ - derivation_index_bytes, derive_product_public_key, public_key_from_address, - }, + product_account::public_key_from_address, session::{SessionInfo, SessionState}, sso::pairing::x25519_public_key, }, @@ -303,9 +304,9 @@ fn authority_cancellation_error(cx: &CallContext, reason: CancellationReason) -> } /// Product adapter for the generated TrUAPI dispatcher. -pub struct ProductRuntimeHost { +pub struct ProductRuntimeHost { connection: Arc, - accounts: ProductAccounts, + accounts: ProductAccounts, host_session: Arc, } @@ -461,46 +462,6 @@ impl ProductConnection { } } - async fn confirm_product_action( - &self, - review: UserConfirmationReview, - ) -> Result { - if crate::platform::has_trusted_remote_permissions(&self.product_id()) { - return Ok(true); - } - self.platform.confirm_user_action(review).await - } - - /// Confirm a signature with `account`. When the account belongs to another - /// product, the user is asked once per kind and owning product for the rest - /// of this execution. Prompts open one at a time, and a signature the user - /// already approved never waits for one. - async fn confirm_signature( - &self, - kind: CrossProductSignatureKind, - account: &v01::ProductAccountId, - review: impl FnOnce() -> UserConfirmationReview, - ) -> Result { - if account.dot_ns_identifier == self.product_id() { - return self.confirm_product_action(review()).await; - } - let approval = - cross_product_signature_key(&self.product_id(), &account.dot_ns_identifier, kind); - let permissions = &self.temporary_permissions; - if permissions.authorize(&approval, false) { - return Ok(true); - } - let _prompt = permissions.signature_prompt().await; - if permissions.authorize(&approval, false) { - return Ok(true); - } - let approved = self.confirm_product_action(review()).await?; - if approved { - permissions.grant(approval); - } - Ok(approved) - } - async fn require_chain_submit(&self, denied_error: E) -> Result<(), CallError> { self.require_remote_permission(latest::RemotePermission::ChainSubmit, denied_error) .await @@ -672,19 +633,19 @@ impl ProductConnection { } } -impl ProductRuntimeHost { +impl ProductRuntimeHost { /// Build a product-scoped dispatcher target from a long-lived host runtime /// and the adapters scoped to this product connection. pub fn from_services( services: Arc, adapters: crate::host_core::ConnectionAdapters, - authority: Arc, + accounts: Arc>, host_session: Arc, product: ProductContext, ) -> Self { let core_instance = services.next_core_instance(); let chain = services.chain.for_product(core_instance); - let accounts = ProductAccounts::new(authority, product.clone()); + let accounts = ProductAccounts::new(accounts, product.clone()); let connection = Arc::new(ProductConnection { services, platform: adapters.platform, @@ -710,128 +671,20 @@ impl ProductRuntimeHost { &self.connection } - /// Test constructor building a standalone pairing-host runtime. - #[cfg(test)] - pub fn new

( - platform: Arc

, - config: (crate::platform::PairingHostConfig, ProductContext), - spawner: Spawner, - ) -> Self - where - P: Platform + 'static, - { - let (host_config, product) = config; - let platform: Arc = platform; - Self::new_pairing_for_tests(platform, host_config, product, spawner).0 - } - - /// Compatibility constructor used only by tests that do not exercise - /// product-scoped behavior. - #[cfg(test)] - fn new_compat(platform: Arc, spawner: Spawner) -> Self { - Self::new_compat_with_pairing(platform, spawner).0 - } - - #[cfg(test)] - fn compat_host_config() -> crate::platform::PairingHostConfig { - crate::platform::PairingHostConfig::new( - crate::platform::HostInfo { - name: "Polkadot Web".to_string(), - icon: Some("https://example.invalid/dotli.png".to_string()), - version: None, - platform: truapi::latest::HostPlatform::Web, - }, - crate::platform::PlatformInfo::default(), - [0; 32], - [0xbb; 32], - [0xcc; 32], - "polkadotapp".to_string(), - ) - .expect("compat runtime config is valid") - } - - /// Compat host used by preimage tests. - #[cfg(test)] - fn new_compat_with_bulletin(platform: Arc, spawner: Spawner) -> Self { - Self::new_pairing_for_tests( - platform, - Self::compat_host_config(), - ProductContext::new("unknown.dot".to_string()) - .expect("compat product context is valid"), - spawner, - ) - .0 - } - - #[cfg(test)] - fn new_compat_with_pairing( - platform: Arc, - spawner: Spawner, - ) -> (Self, Arc) { - let host_config = Self::compat_host_config(); - Self::new_pairing_for_tests( - platform, - host_config, - ProductContext::new("unknown.dot".to_string()) - .expect("compat product context is valid"), - spawner, - ) - } - - #[cfg(test)] - fn new_pairing_for_tests( - platform: Arc, - host_config: crate::platform::PairingHostConfig, - product: ProductContext, - spawner: Spawner, - ) -> (Self, Arc) { - let services = RuntimeServices::new( - platform.clone(), - host_config.host.host_info.clone(), - host_config.people_chain_genesis_hash, - host_config.bulletin_chain_genesis_hash, - host_config.asset_hub_chain_genesis_hash, - spawner.clone(), - ); - let pairing_host = PairingHost::new(services.clone(), host_config); - let core_instance = services.next_core_instance(); - let chain = services.chain.for_product(core_instance); - let chat = Arc::new(ActionChannel::chat()); - let renderer = Arc::new(ActionChannel::renderer()); - let connection = Arc::new(ProductConnection { - services, - platform, - chat_platform: None, - permission_status: None, - temporary_permissions: Arc::default(), - session_state: pairing_host.session_state(), - product, - core_instance, - chain, - chat, - renderer, - pocket_platform: None, - game_platform: None, - expanded_card: None, - open_operations: Mutex::new(HashSet::new()), - }); - let host = Self { - accounts: ProductAccounts::new(pairing_host.clone(), connection.product.clone()), - connection, - host_session: pairing_host.clone(), - }; - (host, pairing_host) + /// Lifecycle owner shared by this connection and its host administration. + pub fn host_session(&self) -> &Arc { + &self.host_session } /// Test-only access to the shared session-state holder. #[cfg(test)] - pub fn test_session_state(&self) -> Arc { + fn test_session_state(&self) -> Arc { self.host_session.session_state() } /// Seed the paired Account Holder's hard product subtree for unit tests. #[cfg(test)] - pub fn test_cache_product_subtree( + fn test_cache_product_subtree( &self, session: &SessionInfo, product_id: &str, @@ -843,50 +696,10 @@ impl ProductRuntimeHost { /// Disconnect this runtime from its paired signing host. #[cfg(test)] #[instrument(skip_all, fields(runtime.method = "account.disconnect"))] - pub async fn disconnect(&self) { + async fn disconnect(&self) { self.host_session.disconnect().await; } - /// Resolve the canonical account owner this product may access. - pub async fn authorized_product_account( - &self, - dot_ns_identifier: &str, - cx: &CallContext, - ) -> Option { - let product_id = self.connection.product_id(); - if crate::platform::is_localhost_product_identifier(&product_id) { - return normalize_product_identifier(dot_ns_identifier).ok(); - } - let cx = remote_authority_context(cx); - self.bounded_cross_product_scope_target(dot_ns_identifier, Granted::Context, &cx) - .await - } - - /// Resolve the grant within the caller's deadline and cancellation. - pub async fn bounded_cross_product_scope_target( - &self, - target: &str, - scope: Granted, - cx: &CallContext, - ) -> Option { - let lookup = self.connection.cross_product_scope_target(target, scope).fuse(); - let cancelled = cx.cancel().cancelled().fuse(); - pin_mut!(lookup, cancelled); - let Some(budget) = cx.timeout() else { - return futures::select! { - resolved = lookup => resolved, - _ = cancelled => None, - }; - }; - let deadline = futures_timer::Delay::new(budget).fuse(); - pin_mut!(deadline); - futures::select! { - resolved = lookup => resolved, - _ = cancelled => None, - () = deadline => None, - } - } - fn normalize_product_account_id( product_account_id: latest::ProductAccountId, ) -> Result { @@ -897,69 +710,47 @@ impl ProductRuntimeHost { }) } - async fn product_account_public_key( - &self, - cx: &CallContext, - session: &AuthoritySession, - product_account_id: &latest::ProductAccountId, - ) -> Result<[u8; 32], AuthorityError> { - let cx = remote_authority_context(cx); - let subtree = remote_authority_call( - &cx, - self.accounts.product_subtree_public_key( - session, - &cx, - product_account_id.dot_ns_identifier.clone(), - ), - ) - .await?; - derive_product_public_key( - subtree, - derivation_index_bytes(&product_account_id.derivation_index), - ) - .map_err(|err| AuthorityError::Unknown { reason: err.to_string() }) - } - async fn legacy_slot_zero_public_key( &self, cx: &CallContext, - session: &AuthoritySession, + authority_session: &AuthoritySession, ) -> Result<[u8; 32], String> { - self.product_account_public_key( - cx, - session, - &v01::ProductAccountId { - dot_ns_identifier: self.connection.product_id(), - derivation_index: v01::DerivationIndex::Index(0), - }, - ) - .await - .map_err(|err| err.to_string()) + self.accounts + .product_account_public_key( + cx, + authority_session, + &latest::ProductAccountId { + dot_ns_identifier: self.connection.product_id(), + derivation_index: latest::DerivationIndex::Index(0), + }, + ) + .await + .map_err(|err| err.to_string()) } async fn classify_legacy_address_signer( &self, cx: &CallContext, - session: &AuthoritySession, + authority_session: &AuthoritySession, signer: &str, ) -> Result { let requested_key = parse_legacy_signer_hex(signer) .or_else(|| public_key_from_address(signer)) .ok_or(LegacySignerError::Unavailable)?; - self.classify_legacy_signer(cx, session, requested_key).await + self.classify_legacy_signer(cx, authority_session, requested_key).await } async fn classify_legacy_signer( &self, cx: &CallContext, - session: &AuthoritySession, + authority_session: &AuthoritySession, requested_key: [u8; 32], ) -> Result { - if session.identity_account_id == Some(requested_key) { + if authority_session.identity_account_id == Some(requested_key) { return Ok(LegacySigner::Identity(requested_key)); } let product_public_key = self - .legacy_slot_zero_public_key(cx, session) + .legacy_slot_zero_public_key(cx, authority_session) .await .map_err(LegacySignerError::ProductDerivation)?; if requested_key == product_public_key { @@ -967,147 +758,6 @@ impl ProductRuntimeHost { } Err(LegacySignerError::Unavailable) } - - /// Replace declared contact handles with their accounts before review or signing. - pub async fn substitute_declared_contacts( - &self, - call_data: Vec, - declared: &[v01::ContactHandle], - ) -> Result, ContactResolutionError> { - let cache = &self.connection.services.contact_handles; - let declared_bytes: Vec<[u8; 32]> = declared.iter().map(|handle| handle.bytes).collect(); - if cache.has_undeclared_handle(&call_data, &declared_bytes) { - return Err(ContactResolutionError::UnknownContact); - } - if declared.is_empty() { - return Ok(call_data); - } - let (platform, handles) = self.contacts_picker().map_err(|error| match error { - CallError::Unsupported => ContactResolutionError::Unsupported, - CallError::Domain(v01::HostContactsPickError::NotConnected) => { - ContactResolutionError::NotConnected - }, - CallError::Domain(v01::HostContactsPickError::Unknown { reason }) => { - ContactResolutionError::Host(reason) - }, - other => ContactResolutionError::Host(format!("{other:?}")), - })?; - let mut resolved: Vec<([u8; 32], Option<[u8; 32]>)> = declared_bytes - .iter() - .map(|handle| (*handle, cache.get(handle, &handles))) - .collect(); - // The host is asked only about handles the cache cannot answer, all - // of them in one lookup. - let misses: Vec<[u8; 32]> = resolved - .iter() - .filter(|(_, account)| account.is_none()) - .map(|(handle, _)| *handle) - .collect(); - if !misses.is_empty() { - let generation = cache.generation(); - let lookup = crate::platform::HostContactLookup { - handle_key: handles.handle_key(), - handles: misses, - }; - let matches = platform - .contacts(&lookup) - .await - .map_err(|error| ContactResolutionError::Host(error.reason))?; - // One answer per handle, or the answers cannot be paired up. - if matches.accounts.len() != lookup.handles.len() { - return Err(ContactResolutionError::Host(format!( - "contacts lookup answered {} of {} handles", - matches.accounts.len(), - lookup.handles.len() - ))); - } - let mut answers = matches.accounts.into_iter(); - for (handle, account) in resolved.iter_mut().filter(|(_, account)| account.is_none()) { - let answer = answers.next().expect("one answer per miss; qed"); - // A host answer is checked, not trusted: an account that does - // not hash to its handle is treated as no contact at all. - *account = answer.filter(|account| handles.names(handle, account)); - if let Some(account) = account { - cache.insert(*handle, *account, generation); - } - } - } - crate::host_logic::contact_substitution::substitute(&call_data, &resolved) - .map_err(|_| ContactResolutionError::UnknownContact) - } - - /// The contact picker for this connection, plus the key its handles are - /// minted under. - /// - /// Ordered so a host that serves no picker answers `Unsupported` without an - /// overlay ever being raised. There is no permission step: the user - /// selecting a contact is the consent. - fn contacts_picker(&self) -> Result> { - // A capability the host does not serve is a framework answer; a - // missing session is one the product handles. - let platform = - self.connection.services.contacts_platform().ok_or(CallError::Unsupported)?; - let session = self - .accounts - .current_session() - .ok_or(CallError::Domain(v01::HostContactsPickError::NotConnected))?; - let handle_key = - self.accounts.contacts_handle_key(&session).map_err(|error| match error { - AuthorityError::Disconnected => { - CallError::Domain(v01::HostContactsPickError::NotConnected) - }, - other => CallError::Domain(v01::HostContactsPickError::Unknown { - reason: other.to_string(), - }), - })?; - Ok((platform, crate::runtime::contacts::ContactHandles::from_handle_key(handle_key))) - } -} - -async fn account_access_authorization( - platform: &dyn Platform, - requesting_product_id: &str, - target_product_id: &str, -) -> Result { - if requesting_product_id == target_product_id || - crate::platform::normalizes_to_trusted_remote_permissions(requesting_product_id) - { - return Ok(PermissionAuthorizationStatus::Authorized); - } - - let target = crate::host_internal::product_manifest::bare_product_label(target_product_id); - let caller = crate::host_internal::product_manifest::bare_product_label(requesting_product_id); - let cached = crate::host_internal::permissions::account_access_status(platform, caller, target) - .await - .map_err(AccountAccessAuthorizationError::PermissionStorage)?; - if cached != PermissionAuthorizationStatus::NotDetermined { - return Ok(cached); - } - - let decision = platform - .confirm_permission(UserConfirmationReview::AccountAccess(AccountAccessReview { - requesting_product_id: requesting_product_id.to_string(), - target_product_id: target_product_id.to_string(), - })) - .await - .map_err(AccountAccessAuthorizationError::Confirmation)?; - let status = match decision { - PermissionDecision::AllowOnce => return Ok(PermissionAuthorizationStatus::Authorized), - PermissionDecision::AllowAlways => PermissionAuthorizationStatus::Authorized, - PermissionDecision::Deny => PermissionAuthorizationStatus::Denied, - }; - crate::host_internal::permissions::set_account_access_status(platform, caller, target, status) - .await - .map_err(AccountAccessAuthorizationError::PermissionStorage)?; - Ok(status) -} - -#[derive(Debug, thiserror::Error)] -enum AccountAccessAuthorizationError { - #[error("permission storage failed: {0:?}")] - PermissionStorage(v01::GenericError), - #[error("account access confirmation failed: {0:?}")] - Confirmation(v01::GenericError), } fn parse_legacy_signer_hex(signer: &str) -> Option<[u8; 32]> { @@ -1164,10 +814,14 @@ fn validate_vrf_transcript(request: &latest::HostAccountSignVrfRequest) -> Resul fn vrf_call_error(err: AuthorityError) -> CallError { CallError::Domain(err.into()) } + fn account_get_authority_error(err: AuthorityError) -> CallError { let error = match err { AuthorityError::Disconnected => latest::HostAccountGetError::NotConnected, AuthorityError::Rejected => latest::HostAccountGetError::Rejected, + AuthorityError::ConfirmationFailed(error) => { + return CallError::HostFailure { reason: error.reason }; + }, AuthorityError::Cancelled(err) => { latest::HostAccountGetError::Unknown { reason: err.to_string() } }, @@ -1251,6 +905,11 @@ fn signing_call_error(err: AuthorityError) -> CallError { latest::HostSignPayloadError::Rejected }, + AuthorityError::ConfirmationFailed(error) => { + return CallError::HostFailure { + reason: format!("sign payload confirmation failed: {error:?}"), + }; + }, AuthorityError::Cancelled(err) => { latest::HostSignPayloadError::Unknown { reason: err.to_string() } }, @@ -1265,6 +924,11 @@ fn transaction_call_error(err: AuthorityError) -> CallError { latest::HostCreateTransactionError::Rejected }, + AuthorityError::ConfirmationFailed(error) => { + return CallError::HostFailure { + reason: format!("create transaction confirmation failed: {error:?}"), + }; + }, AuthorityError::Cancelled(err) => { latest::HostCreateTransactionError::Unknown { reason: err.to_string() } }, @@ -1277,8 +941,102 @@ fn transaction_call_error(err: AuthorityError) -> CallError ProductRuntimeHost { + /// Replace declared contact handles with their accounts before review or signing. + async fn substitute_declared_contacts( + &self, + call_data: Vec, + declared: &[v01::ContactHandle], + ) -> Result, ContactResolutionError> { + let cache = &self.connection.services.contact_handles; + let declared_bytes: Vec<[u8; 32]> = declared.iter().map(|handle| handle.bytes).collect(); + if cache.has_undeclared_handle(&call_data, &declared_bytes) { + return Err(ContactResolutionError::UnknownContact); + } + if declared.is_empty() { + return Ok(call_data); + } + let (platform, handles) = self.contacts_picker().map_err(|error| match error { + CallError::Unsupported => ContactResolutionError::Unsupported, + CallError::Domain(v01::HostContactsPickError::NotConnected) => { + ContactResolutionError::NotConnected + }, + CallError::Domain(v01::HostContactsPickError::Unknown { reason }) => { + ContactResolutionError::Host(reason) + }, + other => ContactResolutionError::Host(format!("{other:?}")), + })?; + let mut resolved: Vec<([u8; 32], Option<[u8; 32]>)> = declared_bytes + .iter() + .map(|handle| (*handle, cache.get(handle, &handles))) + .collect(); + // The host is asked only about handles the cache cannot answer, all + // of them in one lookup. + let misses: Vec<[u8; 32]> = resolved + .iter() + .filter(|(_, account)| account.is_none()) + .map(|(handle, _)| *handle) + .collect(); + if !misses.is_empty() { + let generation = cache.generation(); + let lookup = handles.lookup(misses); + let matches = platform + .contacts(&lookup) + .await + .map_err(|error| ContactResolutionError::Host(error.reason))?; + // One answer per handle, or the answers cannot be paired up. + if matches.accounts.len() != lookup.handles.len() { + return Err(ContactResolutionError::Host(format!( + "contacts lookup answered {} of {} handles", + matches.accounts.len(), + lookup.handles.len() + ))); + } + let mut answers = matches.accounts.into_iter(); + for (handle, account) in resolved.iter_mut().filter(|(_, account)| account.is_none()) { + let answer = answers.next().expect("one answer per miss; qed"); + // A host answer is checked, not trusted: an account that does + // not hash to its handle is treated as no contact at all. + *account = answer.filter(|account| handles.names(handle, account)); + if let Some(account) = account { + cache.insert(*handle, *account, generation); + } + } + } + crate::host_logic::contact_substitution::substitute(&call_data, &resolved) + .map_err(|_| ContactResolutionError::UnknownContact) + } + + /// The contact picker for this connection, plus the key its handles are + /// minted under. + /// + /// Ordered so a host that serves no picker answers `Unsupported` without an + /// overlay ever being raised. There is no permission step: the user + /// selecting a contact is the consent. + fn contacts_picker(&self) -> Result> { + // A capability the host does not serve is a framework answer; a + // missing session is one the product handles. + let platform = + self.connection.services.contacts_platform().ok_or(CallError::Unsupported)?; + let authority_session = self + .accounts + .current_session() + .ok_or(CallError::Domain(v01::HostContactsPickError::NotConnected))?; + let handles = + self.accounts.contact_handles(&authority_session).map_err(|error| match error { + AuthorityError::Disconnected => { + CallError::Domain(v01::HostContactsPickError::NotConnected) + }, + other => CallError::Domain(v01::HostContactsPickError::Unknown { + reason: other.to_string(), + }), + })?; + Ok((platform, handles)) + } +} + #[crate::platform::async_trait] -impl Contacts for ProductRuntimeHost { +impl Contacts for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "contacts.pick"))] async fn pick( &self, @@ -1336,7 +1094,7 @@ fn contacts_error( } #[crate::platform::async_trait] -impl Chat for ProductRuntimeHost { +impl Chat for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "chat.create_room"))] async fn create_room( &self, @@ -1487,7 +1245,7 @@ impl Chat for ProductRuntimeHost { } #[crate::platform::async_trait] -impl Renderer for ProductRuntimeHost { +impl Renderer for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "renderer.action_subscribe"))] async fn action_subscribe( &self, @@ -1503,7 +1261,7 @@ impl Renderer for ProductRuntimeHost { } #[truapi::async_trait] -impl Pocket for ProductRuntimeHost { +impl Pocket for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "pocket.list_subscribe"))] async fn list_subscribe( &self, @@ -1594,7 +1352,7 @@ fn chat_create_room_field_error( })) } -impl ProductRuntimeHost { +impl ProductRuntimeHost { /// Cache a just-submitted preimage under its key for immediate lookups. fn prime_preimage_cache(&self, key: &[u8], value: Vec) { if let Ok(key_bytes) = <[u8; 32]>::try_from(key) { @@ -1609,15 +1367,95 @@ fn preimage_submit_error(reason: String) -> CallError String { - match err { - AuthorityError::Rejected => { - "Bulletin allowance allocation was rejected by the signing host".to_string() - }, - AuthorityError::Disconnected => { - "Signing host disconnected while allocating Bulletin allowance".to_string() - }, - other => other.to_string(), +#[cfg(test)] +impl ProductRuntimeHost { + /// Test constructor building a standalone pairing-host runtime. + fn new

( + platform: Arc

, + config: (crate::platform::PairingHostConfig, ProductContext), + spawner: Spawner, + ) -> Self + where + P: Platform + 'static, + { + let (host_config, product) = config; + let platform: Arc = platform; + Self::new_pairing_for_tests(platform, host_config, product, spawner).0 + } + + /// Compatibility constructor used only by tests that do not exercise + /// product-scoped behavior. + fn new_compat(platform: Arc, spawner: Spawner) -> Self { + Self::new_compat_with_pairing(platform, spawner).0 + } + + fn compat_host_config() -> crate::platform::PairingHostConfig { + crate::platform::PairingHostConfig::new( + crate::platform::HostInfo { + name: "Polkadot Web".to_string(), + icon: Some("https://example.invalid/dotli.png".to_string()), + version: None, + platform: truapi::latest::HostPlatform::Web, + }, + crate::platform::PlatformInfo::default(), + [0; 32], + [0xbb; 32], + [0xcc; 32], + "polkadotapp".to_string(), + ) + .expect("compat runtime config is valid") + } + + /// Compat host used by preimage tests. + fn new_compat_with_bulletin(platform: Arc, spawner: Spawner) -> Self { + Self::new_pairing_for_tests( + platform, + Self::compat_host_config(), + ProductContext::new("unknown.dot".to_string()) + .expect("compat product context is valid"), + spawner, + ) + .0 + } + + fn new_compat_with_pairing( + platform: Arc, + spawner: Spawner, + ) -> (Self, PairingHost) { + let host_config = Self::compat_host_config(); + Self::new_pairing_for_tests( + platform, + host_config, + ProductContext::new("unknown.dot".to_string()) + .expect("compat product context is valid"), + spawner, + ) + } + + fn new_pairing_for_tests( + platform: Arc, + host_config: crate::platform::PairingHostConfig, + product: ProductContext, + spawner: Spawner, + ) -> (Self, PairingHost) { + let services = RuntimeServices::new( + platform.clone(), + host_config.host.host_info.clone(), + host_config.people_chain_genesis_hash, + host_config.bulletin_chain_genesis_hash, + host_config.asset_hub_chain_genesis_hash, + spawner.clone(), + ); + let pairing = PairingHost::new(services.clone(), host_config); + let adapters = crate::host_core::ConnectionAdapters::from_services(&services); + let host = Self::from_services( + services, + adapters, + pairing.accounts().clone(), + pairing.session().clone(), + product, + ); + (host, pairing) } } diff --git a/rust/crates/truapi/src/runtime/allowances.rs b/rust/crates/truapi/src/runtime/allowances.rs index 64021ece32..c821ea0807 100644 --- a/rust/crates/truapi/src/runtime/allowances.rs +++ b/rust/crates/truapi/src/runtime/allowances.rs @@ -7,38 +7,61 @@ use truapi::latest::GenericError; use super::{authority::AuthorityError, sso_remote::SsoSessionKey}; use crate::host_logic::session::{SessionInfo, SsoSessionInfo}; -/// Chain resource an allowance key grants access to. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Encode, Decode)] -pub enum AllowanceResource { - /// Bulletin-chain transaction storage. - Bulletin, - /// People-chain statement store. - StatementStore, +pub use crate::platform::AllowanceResource; + +/// Canonical wallet and optional paired channel owning in-memory grants. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct GrantScope { + root_public_key: [u8; 32], + channel: Option, +} + +impl GrantScope { + /// Use session identity directly, without interpreting an authority token. + pub fn from_session(session: &SessionInfo) -> Self { + Self { + root_public_key: session.public_key, + channel: session.sso.as_ref().map(SsoSessionKey::from_session), + } + } + + /// Whether this owner remains selected by the canonical session. + pub fn matches(self, state: &crate::host_logic::session::SessionState) -> bool { + state + .current() + .as_ref() + .is_some_and(|session| Self::from_session(session) == self) + } } /// Memory-cache key: `(session, product_id, resource)`. #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct AllowanceCacheKey { - session: SsoSessionKey, + session: GrantScope, product_id: String, resource: AllowanceResource, } impl AllowanceCacheKey { - /// Cache key for the session's SSO pair; fails when the session has none. - pub fn new( - session: &SessionInfo, - product_id: &str, - resource: AllowanceResource, - ) -> Result { - Ok(Self { session: sso_cache_key(session)?, product_id: product_id.to_string(), resource }) + /// Key for the wallet and authenticated channel that received this grant. + pub fn new(session: &SessionInfo, product_id: &str, resource: AllowanceResource) -> Self { + Self { + session: GrantScope::from_session(session), + product_id: product_id.to_string(), + resource, + } } /// Whether this key belongs to the given SSO session. - pub fn is_for_session(&self, session: SsoSessionKey) -> bool { + pub fn is_for_session(&self, session: GrantScope) -> bool { self.session == session } + /// Whether this grant belongs to a wallet being removed. + pub fn is_for_owner(&self, owner: [u8; 32]) -> bool { + self.session.root_public_key == owner + } + /// Whether this key belongs to the given product. pub fn is_for_product(&self, product_id: &str) -> bool { self.product_id == product_id @@ -183,11 +206,6 @@ fn storage_key(session: &SessionInfo) -> Result }) } -fn sso_cache_key(session: &SessionInfo) -> Result { - let sso = session.sso.as_ref().ok_or(AuthorityError::Disconnected)?; - Ok(SsoSessionKey::from_session(sso)) -} - pub fn session_storage_id(session: &SsoSessionInfo) -> String { let mut bytes = Vec::with_capacity(64); bytes.extend_from_slice(&session.session_id_own); @@ -199,6 +217,21 @@ fn storage_error(err: GenericError) -> AuthorityError { AuthorityError::Unknown { reason: format!("allowance storage failed: {}", err.reason) } } +/// Allowance period clock, using web-time on browsers to avoid SystemTime panics. +pub fn current_unix_secs() -> Result { + #[cfg(not(target_arch = "wasm32"))] + use std::time::{SystemTime, UNIX_EPOCH}; + #[cfg(target_arch = "wasm32")] + use web_time::{SystemTime, UNIX_EPOCH}; + + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .map_err(|_| AuthorityError::Unavailable { + reason: "system clock before UNIX epoch".to_string(), + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/rust/crates/truapi/src/runtime/authority.rs b/rust/crates/truapi/src/runtime/authority.rs index 6f9e081994..27fa8ca8cd 100644 --- a/rust/crates/truapi/src/runtime/authority.rs +++ b/rust/crates/truapi/src/runtime/authority.rs @@ -1,7 +1,8 @@ -//! Account authority contracts shared by product runtimes. +//! Account calls and host grant contracts. //! -//! Local and paired hosts use the same request and session types. +//! Caller origin separates local host permissions from remote wallet consent. +use crate::platform::ProductContext; use async_trait::async_trait; use truapi::{ CallContext, CancellationReason, @@ -12,17 +13,15 @@ use truapi::{ HostAccountRegisterRingVrfKeyResponse, HostAccountRingVrfSignRequest, HostAccountRingVrfSignResponse, HostAccountSignVrfError, HostAccountSignVrfRequest, HostCreateTransactionResponse, HostRequestResourceAllocationRequest, - HostRequestResourceAllocationResponse, HostSignPayloadRequest, HostSignPayloadResponse, - HostSignPayloadWithLegacyAccountRequest, HostSignRawRequest, - HostSignRawWithLegacyAccountRequest, LegacyAccountTxPayload, ProductAccountId, - ProductAccountTxPayload, VrfSignature, + HostSignPayloadRequest, HostSignPayloadResponse, HostSignPayloadWithLegacyAccountRequest, + HostSignRawRequest, HostSignRawWithLegacyAccountRequest, LegacyAccountTxPayload, + ProductAccountId, ProductAccountTxPayload, VrfSignature, }, }; use crate::{ host_internal::{ - extrinsic::LocalTransactionError, - sso_messages::{ProductRequest, RingVrfError}, + extrinsic::LocalTransactionError, sso_messages::RingVrfError, transaction::ExtrinsicPayloadError, }, host_logic::{ @@ -32,7 +31,43 @@ use crate::{ }, }; -/// Core-only Bulletin key, zeroized on drop and redacted in diagnostics. +/// Wallet call bound to a session and the origin that supplied its caller. +#[derive(Clone, Copy)] +pub struct AccountInvocation<'a> { + /// Cancellation and deadline for this operation. + pub call: &'a CallContext, + /// Session selected before the operation began. + pub session: &'a AuthoritySession, + /// This host's product binding or a paired host's reported identity. + pub caller: AccountCaller<'a>, +} + +/// Trust boundary for product identity and host permissions. +#[derive(Clone, Copy)] +pub enum AccountCaller<'a> { + /// Product identity bound by this host's runtime. + Local { + /// Product bound by the host runtime. + product: &'a ProductContext, + }, + /// Product identity reported by an authenticated paired host. + Remote { + /// Some SSO operations carry no product identity. + product_id: Option<&'a str>, + }, +} + +impl AccountCaller<'_> { + /// Product named by this invocation, when the transport supplied one. + pub fn product_id(&self) -> Option<&str> { + match self { + Self::Local { product } => Some(&product.product_id), + Self::Remote { product_id } => *product_id, + } + } +} + +/// Validated Bulletin signing material, redacted in diagnostics and zeroized on drop. #[derive(Clone, zeroize::Zeroize, zeroize::ZeroizeOnDrop, derive_more::Debug)] pub struct BulletinAllowanceKey { #[debug("\"\"")] @@ -42,11 +77,16 @@ pub struct BulletinAllowanceKey { impl BulletinAllowanceKey { /// Reject invalid sr25519 material before it can be retained. pub fn from_secret_bytes(secret: Vec) -> Result { - let secret: [u8; 64] = - secret.try_into().map_err(|secret: Vec| AuthorityError::Unavailable { + let secret = zeroize::Zeroizing::new(secret); + let bytes: [u8; 64] = + secret.as_slice().try_into().map_err(|_| AuthorityError::Unavailable { reason: format!("bulletin allowance key must be 64 bytes, got {}", secret.len()), })?; - Ok(Self { secret }) + let secret = zeroize::Zeroizing::new(bytes); + schnorrkel::SecretKey::from_bytes(&secret[..]).map_err(|_| { + AuthorityError::Unavailable { reason: "invalid bulletin allowance key".to_string() } + })?; + Ok(Self { secret: *secret }) } /// Raw secret for the in-core Bulletin signer. @@ -65,14 +105,17 @@ pub struct AutoSigningKey { } impl AutoSigningKey { + /// Keep both delegated secrets together for the same product subtree. pub fn from_parts(secret: [u8; 64], ring_vrf_domain_entropy: [u8; 32]) -> Self { Self { secret, ring_vrf_domain_entropy } } + /// Retained subtree secret for delegated account signing. pub fn as_secret_bytes(&self) -> &[u8; 64] { &self.secret } + /// Product-scoped entropy for delegated ring signing. pub fn ring_vrf_domain_entropy(&self) -> &[u8; 32] { &self.ring_vrf_domain_entropy } @@ -123,6 +166,9 @@ pub enum AuthorityError { /// User or authority rejected the request. #[display("Rejected")] Rejected, + /// The platform could not present or complete a wallet review. + #[display("confirmation failed: {}", _0.reason)] + ConfirmationFailed(#[error(not(source))] crate::latest::GenericError), /// The selected authority session is no longer active. #[display("Disconnected")] Disconnected, @@ -191,6 +237,9 @@ impl From for HostAccountSignVrfError { match err { AuthorityError::Disconnected => Self::NotConnected, AuthorityError::Rejected => Self::Rejected, + AuthorityError::ConfirmationFailed(error) => { + Self::Unknown { reason: format!("VRF signing confirmation failed: {error:?}") } + }, AuthorityError::Cancelled(err) => Self::Unknown { reason: err.to_string() }, AuthorityError::Unavailable { reason } | AuthorityError::NotSupported { reason } | @@ -238,6 +287,13 @@ pub enum SignRawAuthorityRequest { Product(HostSignRawRequest), /// Sign raw data through the legacy-account API. LegacyAccount { + /// Product slot-zero account backing the validated legacy signer. + product_account: ProductAccountId, + /// Original legacy-account request. + request: HostSignRawWithLegacyAccountRequest, + }, + /// Sign with the active identity through the legacy-account API. + IdentityAccount { /// Account selected by the product and validated against the session. account: [u8; 32], /// Original legacy-account request. @@ -261,29 +317,63 @@ pub enum CreateTransactionAuthorityRequest { IdentityAccount(LegacyAccountTxPayload), } -/// Whether blessed `calling_product_id` is using its own account, `owner`. -pub(crate) fn is_blessed_owner(calling_product_id: &str, owner: &str) -> bool { - use crate::platform::{has_trusted_remote_permissions, normalize_product_identifier}; - normalize_product_identifier(calling_product_id).is_ok_and(|caller| { - has_trusted_remote_permissions(&caller) && - normalize_product_identifier(owner).is_ok_and(|owner| owner == caller) - }) +impl SignPayloadAuthorityRequest { + /// Canonical review for this selected signing request. + pub fn review(&self, caller: AccountCaller<'_>) -> crate::platform::UserConfirmationReview { + use crate::platform::{SignPayloadReview, UserConfirmationReview}; + UserConfirmationReview::SignPayload(match self { + Self::Product(request) => SignPayloadReview::Product { + calling_product_id: caller.product_id().map(str::to_string), + request: request.clone(), + }, + Self::LegacyAccount { request, .. } => { + SignPayloadReview::LegacyAccount(request.clone()) + }, + }) + } } -/// Whether this product account can sign without confirmation. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum AutoSigningGrant { - /// The authority holds the signing keys and needs no prompt. - Active, - /// User consent is required. - Absent, +impl SignRawAuthorityRequest { + /// Canonical review, including the original legacy request and watermark. + pub fn review( + &self, + caller: AccountCaller<'_>, + watermarked: bool, + ) -> crate::platform::UserConfirmationReview { + use crate::platform::{SignRawReview, UserConfirmationReview}; + UserConfirmationReview::SignRaw(match self { + Self::Product(request) => SignRawReview::Product { + calling_product_id: caller.product_id().map(str::to_string), + request: request.clone(), + watermarked, + }, + Self::LegacyAccount { request, .. } | Self::IdentityAccount { request, .. } => { + SignRawReview::LegacyAccount { request: request.clone(), watermarked } + }, + }) + } +} + +impl CreateTransactionAuthorityRequest { + /// Canonical review for a transaction, including resolved contact data. + pub fn review(&self, caller: AccountCaller<'_>) -> crate::platform::UserConfirmationReview { + use crate::platform::{CreateTransactionReview, UserConfirmationReview}; + UserConfirmationReview::CreateTransaction(match self { + Self::Product(payload) => CreateTransactionReview::Product { + calling_product_id: caller.product_id().map(str::to_string), + payload: payload.clone(), + }, + Self::LegacyAccount { request, .. } | Self::IdentityAccount(request) => { + CreateTransactionReview::LegacyAccount(request.clone()) + }, + }) + } } /// Statement-store allowance signing material held by the authority layer. #[derive(Clone, PartialEq, Eq, zeroize::Zeroize, zeroize::ZeroizeOnDrop)] pub struct StatementStoreAllowanceKey { - /// Secret used to sign allowance statements. - pub secret: [u8; 64], + secret: [u8; 64], /// Public key derived from the secret. pub public_key: [u8; 32], } @@ -292,98 +382,161 @@ impl StatementStoreAllowanceKey { /// Wrap a 64-byte sr25519 secret and derive its public key; other lengths /// are `Unavailable`. pub fn from_secret_bytes(secret: Vec) -> Result { - let secret: [u8; 64] = - secret.try_into().map_err(|secret: Vec| AuthorityError::Unavailable { + let secret = zeroize::Zeroizing::new(secret); + let bytes: [u8; 64] = + secret.as_slice().try_into().map_err(|_| AuthorityError::Unavailable { reason: format!( "statement-store allowance key must be 64 bytes, got {}", secret.len() ), })?; - let public_key = statement_public_key_from_secret(secret) + let secret = zeroize::Zeroizing::new(bytes); + let public_key = statement_public_key_from_secret(*secret) .map_err(|reason| AuthorityError::Unavailable { reason })?; - Ok(Self { secret, public_key }) + Ok(Self { secret: *secret, public_key }) } + + /// sr25519 secret used to sign allowance statements. + pub fn as_secret_bytes(&self) -> &[u8; 64] { + &self.secret + } +} + +/// Issued capability material, ready for the host to validate and retain. +pub enum AccountGrant { + /// Statement signing material and a known on-chain allocation period, if supplied. + StatementStore { + /// Validated statement signing key. + key: StatementStoreAllowanceKey, + /// Actual period returned by wallet issuance; SSO does not provide one. + period: Option, + }, + /// Dedicated Bulletin allowance key. + Bulletin(BulletinAllowanceKey), + /// Funded product account. + SmartContract, + /// Exported product signing material. + AutoSigning(AutoSigningKey), +} + +/// One resource result in an otherwise valid allocation batch. +pub enum AccountGrantOutcome { + /// Issued capability material. + Allocated(AccountGrant), + /// The holder declined this resource. + Rejected, + /// No grant is available; local issuers may supply diagnostic context. + NotAvailable { + /// Issuer detail absent from the existing SSO response. + reason: Option, + }, +} + +/// Where a product's public subtree comes from. +pub enum SubtreeResolution<'a> { + /// The holder derived it without asking anyone. + Known([u8; 32]), + /// A request to the account holder, sent only when awaited. + Ask(futures::future::BoxFuture<'a, Result<[u8; 32], AuthorityError>>), +} + +impl SubtreeResolution<'_> { + /// The subtree, asking the account holder when it is not known. + pub async fn resolve(self) -> Result<[u8; 32], AuthorityError> { + match self { + Self::Known(public_key) => Ok(public_key), + Self::Ask(request) => request.await, + } + } +} + +/// How a host keeps the grants its account holder issues. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GrantKeeping { + /// The wallet's own host: allowances persist per wallet and product, and + /// AutoSigning keys last one wallet activation. + Wallet, + /// A host paired with a wallet on another device: grants persist under the pairing. + Paired, } -/// Host account authority used by product runtimes. +/// Wallet account operations bound to a selected session. #[async_trait] -pub trait ProductAuthority: Send + Sync { +pub trait AccountHolder: Send + Sync + 'static { + /// How the host keeps the grants this holder issues. + const GRANT_KEEPING: GrantKeeping; + /// Current account-authority session, if connected. fn current_session(&self) -> Option; - /// Seed a product subtree for authority tests. - #[cfg(test)] - fn cache_product_subtree_for_test( - &self, - _session: &SessionInfo, - _product_id: &str, - _public_key: [u8; 32], - ) { - } + /// Reject replacement of the originally selected wallet or paired channel. + fn require_current_session(&self, session: &AuthoritySession) -> Result<(), AuthorityError>; - /// Resolve the public key for a product's hard subtree. - async fn product_subtree_public_key( - &self, - cx: &CallContext, - session: &AuthoritySession, - product_id: String, - ) -> Result<[u8; 32], AuthorityError>; - - /// Whether subtree resolution needs SSO and host consent. - async fn subtree_resolution_reaches_account_holder( + /// Lazily issue the ordered grants of an explicit request the caller confirmed. + async fn allocate_grants<'a>( + &'a self, + invocation: AccountInvocation<'a>, + request: HostRequestResourceAllocationRequest, + policy: crate::host_internal::sso_messages::OnExistingAllowancePolicy, + ) -> Result< + futures::stream::BoxStream<'a, Result>, + AuthorityError, + >; + + /// Obtain one allowance when the host has no usable grant. + async fn ensure_allowance( &self, - session: &AuthoritySession, - product_id: &str, - ) -> bool; + invocation: AccountInvocation<'_>, + resource: super::allowances::AllowanceResource, + policy: crate::host_internal::sso_messages::OnExistingAllowancePolicy, + ) -> Result; - /// Whether this product account can sign without confirmation. - async fn auto_signing_status( + /// Renew a sponsorship already retained by this host without issuing a new signing key. + async fn renew_statement_sponsorship( &self, - session: &AuthoritySession, - calling_product_id: &str, - account: &ProductAccountId, - ) -> Result; + invocation: AccountInvocation<'_>, + account_id: [u8; 32], + ) -> Result<(), AuthorityError>; + + /// Resolve the public hard subtree under the selected account holder. + async fn product_subtree_public_key<'a>( + &'a self, + invocation: AccountInvocation<'a>, + product_id: String, + ) -> Result, AuthorityError>; /// Sign an RFC-0023 Merlin transcript with a product account. async fn sign_vrf( &self, - cx: &CallContext, - session: &AuthoritySession, - calling_product_id: String, + invocation: AccountInvocation<'_>, request: HostAccountSignVrfRequest, ) -> Result; /// Sign a SCALE transaction payload for a product account. /// - /// Bind the caller to the account before local signing; `None` denotes an SSO relay. + /// The caller confirms the request with the user first. async fn sign_payload( &self, - cx: &CallContext, - session: &AuthoritySession, - calling_product_id: Option<&str>, + invocation: AccountInvocation<'_>, request: SignPayloadAuthorityRequest, ) -> Result; /// Sign arbitrary bytes for a product account. /// - /// Uses the same caller binding as [`ProductAuthority::sign_payload`]. + /// Confirmed by the caller like [`AccountHolder::sign_payload`]. async fn sign_raw( &self, - cx: &CallContext, - session: &AuthoritySession, - calling_product_id: Option<&str>, + invocation: AccountInvocation<'_>, request: SignRawAuthorityRequest, watermarked: bool, ) -> Result; /// Build a transaction for a product account, signed unless the request /// supplies its own V5 `VerifyMultiSignature` extension. - /// Uses the same caller binding as [`ProductAuthority::sign_payload`]. + /// Confirmed by the caller like [`AccountHolder::sign_payload`]. async fn create_transaction( &self, - cx: &CallContext, - session: &AuthoritySession, - calling_product_id: Option<&str>, + invocation: AccountInvocation<'_>, request: CreateTransactionAuthorityRequest, ) -> Result; @@ -393,9 +546,8 @@ pub trait ProductAuthority: Send + Sync { /// the alias bound to `context`; `create_proof` derives the same alias. async fn account_alias( &self, - cx: &CallContext, - session: &AuthoritySession, - request: ProductRequest, + invocation: AccountInvocation<'_>, + request: HostAccountGetAliasRequest, ) -> Result; /// Create a ring-VRF proof bound to a context and message. @@ -404,90 +556,45 @@ pub trait ProductAuthority: Send + Sync { /// `contextual_alias` matches `account_alias` for the same inputs. async fn create_proof( &self, - cx: &CallContext, - session: &AuthoritySession, - request: ProductRequest, + invocation: AccountInvocation<'_>, + request: HostAccountCreateProofRequest, ) -> Result; - /// Register a ring-VRF key owned by the calling product. + /// Register a ring-VRF key owned by the calling product and record it in + /// the registry this host reads. async fn register_ring_vrf_key( &self, - cx: &CallContext, - session: &AuthoritySession, - request: ProductRequest, + invocation: AccountInvocation<'_>, + request: HostAccountRegisterRingVrfKeyRequest, ) -> Result; - /// List registered ring-VRF keys. + /// Record a key the host derived from the calling product's kept + /// AutoSigning key, so the holder stays the registry's only writer. + async fn record_ring_vrf_key( + &self, + invocation: AccountInvocation<'_>, + request: HostAccountRegisterRingVrfKeyRequest, + public_key: [u8; 32], + ) -> Result<(), RingVrfError>; + + /// List an owner's registered ring-VRF keys, keeping the registry this host reads current. async fn list_ring_vrf_keys( &self, - cx: &CallContext, - session: &AuthoritySession, - request: ProductRequest, + invocation: AccountInvocation<'_>, + request: HostAccountListRingVrfKeysRequest, ) -> Result; /// Sign bytes directly with a registered ring-VRF key. async fn ring_vrf_sign( &self, - cx: &CallContext, - session: &AuthoritySession, - request: ProductRequest, + invocation: AccountInvocation<'_>, + request: HostAccountRingVrfSignRequest, ) -> Result; - /// Ask the account authority to allocate product-scoped resources. - async fn allocate_resources( - &self, - cx: &CallContext, - session: &AuthoritySession, - product_id: String, - request: HostRequestResourceAllocationRequest, - ) -> Result; - - /// Return statement-store allowance key material for the calling product. - async fn statement_store_allowance_key( - &self, - cx: &CallContext, - session: &AuthoritySession, - product_id: String, - ) -> Result; - - /// Forget the cached key only if it matches `public_key`, preserving any - /// replacement. Hosts without a local cache use the no-op default. - fn forget_statement_store_allowance_key(&self, _product_id: &str, _public_key: [u8; 32]) {} - - /// Whether a preimage submission is kept in the core instead of being sent - /// to the Bulletin chain. - /// - /// True only for a test host that answers allowances as granted without - /// performing them: the Bulletin allowance it hands out was never - /// authorized on chain, so a real `store` would always be refused. - #[cfg(feature = "test-host")] - fn submits_preimages_locally(&self) -> bool { - false - } - - /// Return Bulletin allowance key material for the calling product. - async fn bulletin_allowance_key( - &self, - cx: &CallContext, - session: &AuthoritySession, - product_id: String, - ) -> Result; - - /// Invalidate the cached Bulletin key and increase or recreate its allowance - /// after a submission is rejected for an exhausted or missing allowance. - async fn refresh_bulletin_allowance_key( - &self, - cx: &CallContext, - session: &AuthoritySession, - product_id: String, - ) -> Result; - /// Sign exact statement-store proof bytes with a product-derived account. async fn sign_statement_store_product_payload( &self, - cx: &CallContext, - session: &AuthoritySession, - calling_product_id: Option<&str>, + invocation: AccountInvocation<'_>, account: ProductAccountId, payload: Vec, ) -> Result<[u8; 64], AuthorityError>; @@ -500,8 +607,15 @@ pub trait ProductAuthority: Send + Sync { context: &[u8], ) -> Result<[u8; 32], AuthorityError>; - /// Core-only root-derived key for stable contact handles across products. - fn contacts_handle_key(&self, session: &AuthoritySession) -> Result<[u8; 32], AuthorityError>; + /// Mint and resolve contact handles under the session's root entropy source. + /// + /// Handles match across products and host roles. The key stays inside + /// `ContactHandles`, so no product can recover contacts by hashing + /// candidate accounts. + fn contact_handles( + &self, + session: &AuthoritySession, + ) -> Result; } /// Build the neutral authority-session snapshot for `session`. diff --git a/rust/crates/truapi/src/runtime/bulletin_rpc.rs b/rust/crates/truapi/src/runtime/bulletin_rpc.rs index 3a2c2f650f..9538bbf6fc 100644 --- a/rust/crates/truapi/src/runtime/bulletin_rpc.rs +++ b/rust/crates/truapi/src/runtime/bulletin_rpc.rs @@ -14,13 +14,10 @@ use web_time::{Duration, Instant}; use crate::{ chain_runtime::{ChainRuntime, RuntimeFailure}, - host_internal::{ - bulletin::{ - STORE_PALLET_NAME, allowance_signer, build_signed_store_transaction, preimage_key, - }, - extrinsic::Sr25519Signer, + host_internal::bulletin::{ + MORTAL_PERIOD_BLOCKS, STORE_PALLET_NAME, preimage_key, store_transaction_payload, }, - runtime::BulletinAllowanceKey, + runtime::AuthorityError, }; use futures::{FutureExt, pin_mut}; use subxt::{ @@ -49,15 +46,6 @@ const SUBMIT_ATTEMPTS: usize = 2; /// identical transaction can be included at most once, so re-broadcasting it /// can never double-store the preimage. const MAX_IDENTICAL_BROADCASTS: usize = 2; -/// Wall-clock window to keep re-checking whether a freshly-claimed allowance -/// has propagated to the dry-run view before treating the rejection as real. -/// Bounding by elapsed time rather than a best-block count keeps the intended -/// ~18s budget stable across changes in Bulletin's block cadence (a fixed -/// count silently shrank the budget when block time dropped), and leaves room -/// in the end-to-end submit timeout for one refresh and retry. -const ALLOWANCE_DRY_RUN_PROPAGATION_WINDOW: Duration = Duration::from_secs(18); -/// Bound each wait for a newer best block while allowance state propagates. -const ALLOWANCE_DRY_RUN_BLOCK_WAIT_TIMEOUT: Duration = Duration::from_secs(20); /// Finalized blocks to inspect for the already-broadcast transaction after /// the watch is interrupted (for example when `chainHead_follow` emits a /// `stop` event mid-submission). @@ -124,6 +112,9 @@ pub enum BulletinSubmitError { /// Subxt failed while building, validating, submitting, or reading events. #[display("subxt: {_0}")] Subxt(#[error(source)] Box), + /// The original host operation or account holder is no longer available. + #[display("{_0}")] + Authority(#[error(not(source))] AuthorityError), /// The allowance key could not be converted to its purpose-scoped signer. #[display("invalid allowance key: {_0}")] InvalidAllowanceKey(#[error(not(source))] String), @@ -175,6 +166,12 @@ pub enum AllowanceRejectionPhase { Dispatch, } +impl From for BulletinSubmitError { + fn from(error: AuthorityError) -> Self { + Self::Authority(error) + } +} + impl BulletinSubmitError { /// Post-broadcast failure that leaves the transaction's fate unknown: /// the watch died without a verdict (for example `chainHead_follow` @@ -233,11 +230,6 @@ pub struct BulletinRpc { submit_lock: futures::lock::Mutex<()>, /// Last phase entered, for timeout reasons. phase: StdMutex, - /// Wall-clock budget for allowance propagation to reach the dry-run view. - allowance_propagation_window: Duration, - /// Bound on each wait for a newer best block while allowance state - /// propagates. - allowance_block_wait_timeout: Duration, } impl BulletinRpc { @@ -248,8 +240,6 @@ impl BulletinRpc { genesis_hash, submit_lock: futures::lock::Mutex::new(()), phase: StdMutex::new(SubmissionPhase::Connect), - allowance_propagation_window: ALLOWANCE_DRY_RUN_PROPAGATION_WINDOW, - allowance_block_wait_timeout: ALLOWANCE_DRY_RUN_BLOCK_WAIT_TIMEOUT, } } @@ -264,24 +254,7 @@ impl BulletinRpc { .map(subxt_rpcs::RpcClient::new) } - /// Override the allowance-propagation window. Test-only: lets a scripted - /// run exercise both the "keep polling" and "window elapsed" paths without - /// depending on real block cadence. - #[cfg(test)] - fn with_allowance_propagation_window(mut self, window: Duration) -> Self { - self.allowance_propagation_window = window; - self - } - - /// Override the per-block allowance wait. Test-only: keeps scripted runs - /// that never produce a newer block from waiting out the real timeout. - #[cfg(test)] - fn with_allowance_block_wait_timeout(mut self, timeout: Duration) -> Self { - self.allowance_block_wait_timeout = timeout; - self - } - - /// Submit `value` as a Bulletin preimage signed by `allowance`, returning + /// Submit `value` using the selected account's signing operation, returning /// the preimage key once the transaction is included and its dispatch /// succeeded. #[instrument(skip_all, fields(runtime.method = "bulletin_rpc.submit_preimage"))] @@ -289,7 +262,12 @@ impl BulletinRpc { &self, cx: &CallContext, deadline: Instant, - allowance: &BulletinAllowanceKey, + account_id: &subxt::utils::AccountId32, + sign: &( + dyn Fn(&[u8]) -> Result + + Send + + Sync + ), value: &[u8], ) -> Result, BulletinSubmitError> { // Serialize submissions, keeping the lock wait cancellable. @@ -307,7 +285,7 @@ impl BulletinRpc { let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { return Err(BulletinSubmitError::Timeout { phase: self.current_phase() }); }; - let flow = self.submit_flow(allowance, value).fuse(); + let flow = self.submit_flow(account_id, sign, value).fuse(); let timeout = futures_timer::Delay::new(remaining).fuse(); let cancelled = cx.cancel().cancelled().fuse(); pin_mut!(flow, timeout, cancelled); @@ -331,7 +309,12 @@ impl BulletinRpc { async fn submit_flow( &self, - allowance: &BulletinAllowanceKey, + account_id: &subxt::utils::AccountId32, + sign: &( + dyn Fn(&[u8]) -> Result + + Send + + Sync + ), value: &[u8], ) -> Result, BulletinSubmitError> { let key = preimage_key(value); @@ -353,11 +336,8 @@ impl BulletinRpc { let head = initial_best_block(&mut best_blocks, finalized.block_number()).await?; self.enter_phase(SubmissionPhase::Build); - let signer = - allowance_signer(allowance).map_err(BulletinSubmitError::InvalidAllowanceKey)?; - let signed = self - .build_signed_and_dry_run(&mut best_blocks, head, &finalized, &signer, value) - .await?; + let signed = + self.build_signed_and_dry_run(head, &finalized, account_id, sign, value).await?; drop(best_blocks); self.enter_phase(SubmissionPhase::Watch); @@ -409,84 +389,52 @@ impl BulletinRpc { Ok(key.to_vec()) } - /// Build, sign, and dry-run the extrinsic against the chosen best block. - /// Dry-run provides the typed signal used to distinguish stale allowances, - /// nonce races, and other runtime validity failures. async fn build_signed_and_dry_run( &self, - best_blocks: &mut Blocks, head: Block, finalized: &OnlineClientAtBlock, - signer: &Sr25519Signer, + account_id: &subxt::utils::AccountId32, + sign: &( + dyn Fn(&[u8]) -> Result + + Send + + Sync + ), value: &[u8], ) -> Result { - let mut block = head; - let mut allowance_rejections = 0; - let mut allowance_rejection_started = None; - loop { - self.enter_phase(SubmissionPhase::Build); - let at_block = block - .at() - .await - .map_err(|error| BulletinSubmitError::Subxt(Box::new(error.into())))?; - let signed = build_signed_store_transaction(&at_block, finalized, signer, value) - .await - .map_err(|error| BulletinSubmitError::Subxt(Box::new(error.into())))?; - - self.enter_phase(SubmissionPhase::DryRun); - let validity = signed - .validate() - .await - .map_err(|error| BulletinSubmitError::Subxt(Box::new(error.into())))?; - match Self::classify_dry_run_validity(validity)? { - DryRunStatus::Valid => return Ok(signed), - DryRunStatus::AllowanceRejected => { - let started = allowance_rejection_started.get_or_insert_with(Instant::now); - let elapsed = started.elapsed(); - if elapsed >= self.allowance_propagation_window { - warn!( - rejections = allowance_rejections + 1, - elapsed_ms = elapsed.as_millis(), - allowance_propagation_window_ms = - self.allowance_propagation_window.as_millis(), - stop = "propagation-window", - "Bulletin allowance remained unavailable to dry-run" - ); - return Err(BulletinSubmitError::AllowanceRejected { - phase: AllowanceRejectionPhase::DryRun, - }); - } - allowance_rejections += 1; - warn!( - attempt = allowance_rejections, - elapsed_ms = elapsed.as_millis(), - allowance_propagation_window_ms = - self.allowance_propagation_window.as_millis(), - block_wait_timeout_ms = self.allowance_block_wait_timeout.as_millis(), - "Bulletin allowance not visible to dry-run yet; rebuilding at next block" - ); - block = match next_best_block( - best_blocks, - self.allowance_block_wait_timeout, - SubmissionPhase::DryRun, - ) - .await - { - Err(BulletinSubmitError::Timeout { .. }) => { - warn!( - rejections = allowance_rejections, - elapsed_ms = started.elapsed().as_millis(), - stop = "block-wait-timeout", - "Bulletin allowance remained unavailable to dry-run" - ); - return Err(BulletinSubmitError::AllowanceRejected { - phase: AllowanceRejectionPhase::DryRun, - }); - }, - result => result?, - }; - }, - } + self.enter_phase(SubmissionPhase::Build); + let at_block = head + .at() + .await + .map_err(|error| BulletinSubmitError::Subxt(Box::new(error.into())))?; + let payload = store_transaction_payload(value); + let params = subxt::config::DefaultExtrinsicParamsBuilder::::new() + .mortal_from_unchecked( + MORTAL_PERIOD_BLOCKS, + finalized.block_number(), + finalized.block_hash(), + ) + .build(); + let mut prepared = at_block + .tx() + .create_signable(&payload, account_id, params) + .await + .map_err(|error| BulletinSubmitError::Subxt(Box::new(error.into())))?; + let bytes = prepared + .signer_payload() + .map_err(|error| BulletinSubmitError::Subxt(Box::new(error.into())))?; + let signed = prepared + .sign_with_account_and_signature(account_id, &sign(&bytes)?) + .map_err(|error| BulletinSubmitError::Subxt(Box::new(error.into())))?; + self.enter_phase(SubmissionPhase::DryRun); + let validity = signed + .validate() + .await + .map_err(|error| BulletinSubmitError::Subxt(Box::new(error.into())))?; + match Self::classify_dry_run_validity(validity)? { + DryRunStatus::Valid => Ok(signed), + DryRunStatus::AllowanceRejected => Err(BulletinSubmitError::AllowanceRejected { + phase: AllowanceRejectionPhase::DryRun, + }), } } @@ -911,18 +859,26 @@ mod tests { use super::*; use crate::{ chain_runtime::{RuntimeChainProvider, RuntimeFailure}, - host_internal::extrinsic::tests::{ - OfflineChainState, bulletin_chain_state, bulletin_runtime_call, split_v4, - system_events, + host_internal::{ + bulletin::{MORTAL_PERIOD_BLOCKS, store_transaction_payload}, + extrinsic::tests::{ + OfflineChainState, bulletin_chain_state, bulletin_runtime_call, split_v4, + system_events, + }, }, host_logic::product_account::SR25519_SIGNING_CONTEXT, platform::JsonRpcConnection, + runtime::BulletinAllowanceKey, subscription::thread_per_subscription_spawner, }; use async_trait::async_trait; - use futures::{StreamExt, channel::mpsc, stream::BoxStream}; + use futures::{ + StreamExt, + channel::{mpsc, oneshot}, + stream::BoxStream, + }; use parity_scale_codec::{Compact, Decode, Encode}; - use schnorrkel::{PublicKey, Signature}; + use schnorrkel::{PublicKey, SecretKey, Signature}; use serde_json::{Value as JsonValue, json}; use std::{ collections::VecDeque, @@ -967,6 +923,7 @@ mod tests { stall_headers: bool, replayed_finalized_blocks: usize, replay_header_delay: Option, + nonce_reply: Option>>, last_transaction: Option, omit_transaction_from_next_body: bool, } @@ -1001,6 +958,7 @@ mod tests { stall_headers, replayed_finalized_blocks: 0, replay_header_delay: None, + nonce_reply: None, last_transaction: None, omit_transaction_from_next_body: false, })), @@ -1051,6 +1009,12 @@ mod tests { self } + fn hold_nonce_reply(&self) -> oneshot::Receiver> { + let (sender, receiver) = oneshot::channel(); + self.state.lock().unwrap().nonce_reply = Some(sender); + receiver + } + fn method_count(&self, method: &str) -> usize { self.sent .lock() @@ -1264,6 +1228,12 @@ mod tests { "output": format!("0x{}", hex::encode(output)) })), ]; + if runtime_method == "AccountNonceApi_account_nonce" && + let Some(reply) = state.nonce_reply.take() + { + reply.send(frames).unwrap(); + return Vec::new(); + } if validation_outcome == ValidationOutcome::AllowanceRejected && state.advance_best_block_after_rejection { @@ -1456,12 +1426,45 @@ mod tests { .unwrap() } - fn rpc(provider: Arc) -> BulletinRpc { - BulletinRpc::new( - ChainRuntime::new(provider, thread_per_subscription_spawner()), - [0x42; 32], - ) - .with_allowance_block_wait_timeout(Duration::from_millis(25)) + struct SubmissionFixture { + rpc: BulletinRpc, + signing_available: core::sync::atomic::AtomicBool, + } + + impl SubmissionFixture { + async fn submit( + &self, + value: &[u8], + budget: Duration, + ) -> Result, BulletinSubmitError> { + use subxt::tx::Signer; + let signer = + crate::host_internal::bulletin::allowance_signer(&allowance_fixture()).unwrap(); + self.rpc + .submit_preimage( + &CallContext::default(), + Instant::now() + budget, + &signer.account_id(), + &|bytes| { + if !self.signing_available.load(core::sync::atomic::Ordering::Relaxed) { + return Err(AuthorityError::Disconnected.into()); + } + Ok(signer.sign(bytes)) + }, + value, + ) + .await + } + } + + fn rpc(provider: Arc) -> SubmissionFixture { + SubmissionFixture { + signing_available: core::sync::atomic::AtomicBool::new(true), + rpc: BulletinRpc::new( + ChainRuntime::new(provider, thread_per_subscription_spawner()), + [0x42; 32], + ), + } } fn store_signature_verifies_at(transaction: &[u8], value: &[u8], block_hash: H256) -> bool { @@ -1511,12 +1514,9 @@ mod tests { let best_hash = scripted_block_hash(5).parse().unwrap(); let value = b"fork mortality reproduction"; - futures::executor::block_on(rpc(provider.clone()).submit_preimage( - &CallContext::default(), - Instant::now() + Duration::from_secs(2), - &allowance_fixture(), - value, - )) + futures::executor::block_on( + rpc(provider.clone()).submit(value, Duration::from_secs(2)), + ) .unwrap(); let transactions = provider.submitted_transactions(); @@ -1549,12 +1549,9 @@ mod tests { let finalized_hash: H256 = scripted_block_hash(3).parse().unwrap(); let value = b"lagging replay"; - futures::executor::block_on(rpc(provider.clone()).submit_preimage( - &CallContext::default(), - Instant::now() + Duration::from_secs(5), - &allowance_fixture(), - value, - )) + futures::executor::block_on( + rpc(provider.clone()).submit(value, Duration::from_secs(5)), + ) .unwrap(); let transactions = provider.submitted_transactions(); @@ -1574,13 +1571,9 @@ mod tests { fn submit_preimage_drives_subxt_happy_path() { let provider = Arc::new(BulletinScriptedProvider::new([TransactionOutcome::Included])); let value = b"scripted bulletin happy path"; - let result = futures::executor::block_on(rpc(provider.clone()).submit_preimage( - &CallContext::default(), - Instant::now() + Duration::from_secs(2), - &allowance_fixture(), - value, - )) - .unwrap(); + let fixture = rpc(provider.clone()); + let result = + futures::executor::block_on(fixture.submit(value, Duration::from_secs(2))).unwrap(); assert_eq!(result, preimage_key(value)); assert_eq!( @@ -1589,6 +1582,78 @@ mod tests { ); assert_eq!(provider.method_count("transactionWatch_v1_submitAndWatch"), 1); assert_eq!(provider.method_count("chainHead_v1_storage"), 1); + + let submitted = provider.submitted_transactions(); + let encoded = hex::decode(submitted[0].trim_start_matches("0x")).unwrap(); + let (account, signature, _) = split_v4(&encoded); + let client = OfflineChainState { + genesis_hash: [0x42; 32], + spec_version: 1, + transaction_version: 1, + metadata: ArcMetadata::from(bulletin_metadata()), + } + .client_at(1) + .unwrap(); + let params = subxt::config::DefaultExtrinsicParamsBuilder::::new() + .nonce(0) + .mortal_from_unchecked(MORTAL_PERIOD_BLOCKS, 1, subxt::utils::H256([0x11; 32])) + .build(); + let payload = client + .tx() + .create_v4_signable_offline(&store_transaction_payload(value), params) + .unwrap() + .signer_payload() + .unwrap(); + let public = SecretKey::from_bytes(allowance_fixture().as_secret_bytes()) + .unwrap() + .to_public(); + assert_eq!(account, public.to_bytes()); + public + .verify_simple( + SR25519_SIGNING_CONTEXT, + &payload, + &Signature::from_bytes(&signature).unwrap(), + ) + .unwrap(); + } + + #[test] + fn a_signer_refusal_after_preparation_prevents_broadcast() { + let provider = Arc::new(BulletinScriptedProvider::new([TransactionOutcome::Included])); + let nonce_reply = provider.hold_nonce_reply(); + let fixture = rpc(provider.clone()); + let error = futures::executor::block_on(async { + let submission = + fixture.submit(b"reactivated wallet", Duration::from_secs(2)).fuse(); + let nonce_reply = nonce_reply.fuse(); + futures::pin_mut!(submission, nonce_reply); + let frames = futures::select! { + result = submission => panic!("submission ended before nonce preparation: {result:?}"), + frames = nonce_reply => frames.unwrap(), + }; + fixture.signing_available.store(false, core::sync::atomic::Ordering::Relaxed); + for frame in frames { + provider + .sender + .lock() + .unwrap() + .as_ref() + .unwrap() + .unbounded_send(frame) + .unwrap(); + } + submission.await.unwrap_err() + }); + + assert!(matches!(error, BulletinSubmitError::Authority(AuthorityError::Disconnected))); + assert_eq!( + ( + provider.runtime_call_count("AccountNonceApi_account_nonce"), + provider.runtime_call_count("TaggedTransactionQueue_validate_transaction"), + provider.method_count("transactionWatch_v1_submitAndWatch"), + ), + (1, 0, 0) + ); } #[test] @@ -1598,13 +1663,9 @@ mod tests { TransactionOutcome::Included, ])); let value = b"scripted bulletin retry"; - let result = futures::executor::block_on(rpc(provider.clone()).submit_preimage( - &CallContext::default(), - Instant::now() + Duration::from_secs(2), - &allowance_fixture(), - value, - )) - .unwrap(); + let fixture = rpc(provider.clone()); + let result = + futures::executor::block_on(fixture.submit(value, Duration::from_secs(2))).unwrap(); assert_eq!(result, preimage_key(value)); assert_eq!(provider.method_count("transactionWatch_v1_submitAndWatch"), 2); @@ -1616,13 +1677,9 @@ mod tests { TransactionOutcome::IncludedWithMissingBody, ])); let value = b"scripted bulletin inconsistent inclusion"; - let result = futures::executor::block_on(rpc(provider.clone()).submit_preimage( - &CallContext::default(), - Instant::now() + Duration::from_secs(2), - &allowance_fixture(), - value, - )) - .unwrap(); + let fixture = rpc(provider.clone()); + let result = + futures::executor::block_on(fixture.submit(value, Duration::from_secs(2))).unwrap(); assert_eq!(result, preimage_key(value)); // The reported inclusion block was inconsistent, but the re-check @@ -1638,13 +1695,9 @@ mod tests { TransactionOutcome::FollowStop, ])); let value = b"scripted watch stop recovery"; - let result = futures::executor::block_on(rpc(provider.clone()).submit_preimage( - &CallContext::default(), - Instant::now() + Duration::from_secs(2), - &allowance_fixture(), - value, - )) - .unwrap(); + let fixture = rpc(provider.clone()); + let result = + futures::executor::block_on(fixture.submit(value, Duration::from_secs(2))).unwrap(); assert_eq!(result, preimage_key(value)); assert_eq!(provider.method_count("transactionWatch_v1_submitAndWatch"), 1); @@ -1657,12 +1710,10 @@ mod tests { BulletinScriptedProvider::new([TransactionOutcome::FollowStop]) .with_failed_events(), ); - let error = futures::executor::block_on(rpc(provider.clone()).submit_preimage( - &CallContext::default(), - Instant::now() + Duration::from_secs(2), - &allowance_fixture(), - b"scripted watch stop failed dispatch", - )) + let fixture = rpc(provider.clone()); + let error = futures::executor::block_on( + fixture.submit(b"scripted watch stop failed dispatch", Duration::from_secs(2)), + ) .unwrap_err(); let BulletinSubmitError::Subxt(error) = error else { @@ -1684,13 +1735,9 @@ mod tests { TransactionOutcome::Included, ])); let value = b"scripted watch stop rebroadcast"; - let result = futures::executor::block_on(rpc(provider.clone()).submit_preimage( - &CallContext::default(), - Instant::now() + Duration::from_secs(2), - &allowance_fixture(), - value, - )) - .unwrap(); + let fixture = rpc(provider.clone()); + let result = + futures::executor::block_on(fixture.submit(value, Duration::from_secs(2))).unwrap(); assert_eq!(result, preimage_key(value)); let submitted = provider.submitted_transactions(); @@ -1707,13 +1754,9 @@ mod tests { TransactionOutcome::Invalid, ])); let value = b"scripted bounced rebroadcast recovery"; - let result = futures::executor::block_on(rpc(provider.clone()).submit_preimage( - &CallContext::default(), - Instant::now() + Duration::from_secs(2), - &allowance_fixture(), - value, - )) - .unwrap(); + let fixture = rpc(provider.clone()); + let result = + futures::executor::block_on(fixture.submit(value, Duration::from_secs(2))).unwrap(); assert_eq!(result, preimage_key(value)); // The bounce triggers one more re-check, which finds the first @@ -1727,12 +1770,10 @@ mod tests { TransactionOutcome::FollowStopWithMissingBody, TransactionOutcome::InvalidWithMissingBody, ])); - let error = futures::executor::block_on(rpc(provider.clone()).submit_preimage( - &CallContext::default(), - Instant::now() + Duration::from_secs(2), - &allowance_fixture(), - b"scripted bounced rebroadcast unseen", - )) + let fixture = rpc(provider.clone()); + let error = futures::executor::block_on( + fixture.submit(b"scripted bounced rebroadcast unseen", Duration::from_secs(2)), + ) .unwrap_err(); assert!(matches!(error, BulletinSubmitError::RebroadcastRejected { .. })); @@ -1749,12 +1790,10 @@ mod tests { TransactionOutcome::Invalid, TransactionOutcome::Invalid, ])); - let error = futures::executor::block_on(rpc(provider.clone()).submit_preimage( - &CallContext::default(), - Instant::now() + Duration::from_secs(2), - &allowance_fixture(), - b"scripted stale allowance", - )) + let fixture = rpc(provider.clone()); + let error = futures::executor::block_on( + fixture.submit(b"scripted stale allowance", Duration::from_secs(2)), + ) .unwrap_err(); let BulletinSubmitError::Subxt(error) = error else { @@ -1781,12 +1820,10 @@ mod tests { false, ), ); - let error = futures::executor::block_on(rpc(provider.clone()).submit_preimage( - &CallContext::default(), - Instant::now() + Duration::from_secs(2), - &allowance_fixture(), - b"scripted stale allowance", - )) + let fixture = rpc(provider.clone()); + let error = futures::executor::block_on( + fixture.submit(b"scripted stale allowance", Duration::from_secs(2)), + ) .unwrap_err(); assert!(matches!( @@ -1803,13 +1840,10 @@ mod tests { #[test] fn submit_preimage_budget_reports_pre_broadcast_phase() { let provider = Arc::new(BulletinScriptedProvider::with_options([], true)); - let error = futures::executor::block_on(rpc(provider).submit_preimage( - &CallContext::default(), - Instant::now() + Duration::from_millis(25), - &allowance_fixture(), - b"timeout", - )) - .unwrap_err(); + let fixture = rpc(provider); + let error = + futures::executor::block_on(fixture.submit(b"timeout", Duration::from_millis(25))) + .unwrap_err(); assert!(matches!( &error, @@ -1819,104 +1853,14 @@ mod tests { } #[test] - fn dry_run_rebuilds_at_a_new_best_block_then_submits() { - let provider = Arc::new( - BulletinScriptedProvider::new([TransactionOutcome::Included]) - .with_validation_outcomes( - [ValidationOutcome::AllowanceRejected, ValidationOutcome::Valid], - true, - ), - ); - let value = b"scripted allowance propagation"; - let result = futures::executor::block_on(rpc(provider.clone()).submit_preimage( - &CallContext::default(), - Instant::now() + Duration::from_secs(2), - &allowance_fixture(), - value, - )) - .unwrap(); - - assert_eq!(result, preimage_key(value)); - assert_eq!( - provider.runtime_call_count("TaggedTransactionQueue_validate_transaction"), - 2 - ); - assert_eq!(provider.method_count("transactionWatch_v1_submitAndWatch"), 1); - } - - #[test] - fn dry_run_propagation_block_wait_timeout_remains_an_allowance_rejection() { + fn a_dry_run_allowance_rejection_is_returned_without_broadcast_or_retry() { let provider = Arc::new( BulletinScriptedProvider::new([]) .with_validation_outcomes([ValidationOutcome::AllowanceRejected], false), ); - let error = futures::executor::block_on(rpc(provider.clone()).submit_preimage( - &CallContext::default(), - Instant::now() + Duration::from_secs(2), - &allowance_fixture(), - b"scripted propagation block wait timeout", - )) - .unwrap_err(); - - assert!(matches!( - error, - BulletinSubmitError::AllowanceRejected { phase: AllowanceRejectionPhase::DryRun } - )); - assert_eq!(provider.method_count("transactionWatch_v1_submitAndWatch"), 0); - } - - #[test] - fn dry_run_keeps_polling_past_the_old_block_count_while_blocks_are_fast() { - // Regression: Bulletin's best-block cadence dropped, so a - // fixed-count propagation budget (3 blocks) collapsed from the - // intended ~18s to ~3s and gave up before a freshly-claimed - // allowance became visible to the dry-run. The wait is now bound - // by wall-clock, so a rapid stream of rejections keeps polling - // until the allowance propagates. - // Six is well past the old three-block give-up cap. - let rejections_past_old_limit = 6; - let mut validation_outcomes = - vec![ValidationOutcome::AllowanceRejected; rejections_past_old_limit]; - validation_outcomes.push(ValidationOutcome::Valid); - let provider = Arc::new( - BulletinScriptedProvider::new([TransactionOutcome::Included]) - .with_validation_outcomes(validation_outcomes, true), - ); - let value = b"scripted fast-block allowance propagation"; - let result = futures::executor::block_on(rpc(provider.clone()).submit_preimage( - &CallContext::default(), - Instant::now() + Duration::from_secs(30), - &allowance_fixture(), - value, - )) - .unwrap(); - - assert_eq!(result, preimage_key(value)); - assert_eq!( - provider.runtime_call_count("TaggedTransactionQueue_validate_transaction"), - rejections_past_old_limit + 1 - ); - assert_eq!(provider.method_count("transactionWatch_v1_submitAndWatch"), 1); - } - - #[test] - fn dry_run_propagation_stops_once_the_wall_clock_window_elapses() { - // A zero-length window makes the first rejection exceed the budget - // immediately, so a never-propagating allowance is treated as real - // after one dry-run rather than looping until the outer deadline. - let provider = Arc::new( - BulletinScriptedProvider::new([]) - .with_validation_outcomes([ValidationOutcome::AllowanceRejected; 4], true), - ); + let fixture = rpc(provider.clone()); let error = futures::executor::block_on( - rpc(provider.clone()) - .with_allowance_propagation_window(Duration::ZERO) - .submit_preimage( - &CallContext::default(), - Instant::now() + Duration::from_secs(2), - &allowance_fixture(), - b"scripted propagation window elapsed", - ), + fixture.submit(b"rejected allowance", Duration::from_secs(2)), ) .unwrap_err(); @@ -1924,10 +1868,6 @@ mod tests { error, BulletinSubmitError::AllowanceRejected { phase: AllowanceRejectionPhase::DryRun } )); - assert_eq!( - provider.runtime_call_count("TaggedTransactionQueue_validate_transaction"), - 1 - ); assert_eq!(provider.method_count("transactionWatch_v1_submitAndWatch"), 0); } } diff --git a/rust/crates/truapi/src/runtime/capabilities/account.rs b/rust/crates/truapi/src/runtime/capabilities/account.rs index f15b8dc3c1..97bb1e66ce 100644 --- a/rust/crates/truapi/src/runtime/capabilities/account.rs +++ b/rust/crates/truapi/src/runtime/capabilities/account.rs @@ -1,9 +1,8 @@ -//! Product-facing account operations and login lifecycle. +//! Product-facing account capability adapters. +//! +//! Account operations use shared host accounts; login uses the session lifecycle. -use crate::platform::{ - PermissionAuthorizationStatus, ProductSubtreeReview, UserConfirmationReview, - normalize_product_identifier, -}; +use crate::platform::{PermissionAuthorizationStatus, normalize_product_identifier}; use futures::StreamExt; use tracing::instrument; use truapi::{ @@ -30,18 +29,14 @@ use truapi::{ use super::from_latest_error; use truapi::versioned::{FromLatest, IntoLatest, Versioned}; -use crate::{ - host_internal::product_manifest::Granted, - runtime::{ - ProductRuntimeHost, account_access_authorization, account_get_authority_error, - remote_authority_call, remote_authority_context, ring_vrf_alias_error, ring_vrf_list_error, - ring_vrf_proof_error, ring_vrf_register_error, ring_vrf_sign_error, until_cancelled, - validate_vrf_transcript, vrf_call_error, - }, +use crate::runtime::{ + AccountHolder, ProductRuntimeHost, remote_authority_call, remote_authority_context, + ring_vrf_alias_error, ring_vrf_list_error, ring_vrf_proof_error, ring_vrf_register_error, + ring_vrf_sign_error, validate_vrf_transcript, vrf_call_error, }; #[truapi::async_trait] -impl Account for ProductRuntimeHost { +impl Account for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "account.get_account"))] async fn get_account( &self, @@ -51,60 +46,7 @@ impl Account for ProductRuntimeHost { let version = request.version(); let latest::HostAccountGetRequest { product_account_id } = request.into_latest(); let result = async { - let product_account_id = Self::normalize_product_account_id(product_account_id) - .map_err(|()| CallError::Domain(latest::HostAccountGetError::DomainNotValid))?; - let Some(session) = self.accounts.current_session() else { - return Err(CallError::Domain(latest::HostAccountGetError::NotConnected)); - }; - - let product_id = self.connection.product_id(); - if product_account_id.dot_ns_identifier != product_id { - match account_access_authorization( - self.connection.platform.as_ref(), - &product_id, - &product_account_id.dot_ns_identifier, - ) - .await - { - Ok(PermissionAuthorizationStatus::Authorized) => {}, - Ok( - PermissionAuthorizationStatus::Denied | - PermissionAuthorizationStatus::NotDetermined, - ) => { - return Err(CallError::Domain(latest::HostAccountGetError::Rejected)); - }, - Err(err) => { - return Err(CallError::HostFailure { reason: err.to_string() }); - }, - } - } else if self - .accounts - .subtree_resolution_reaches_account_holder( - &session, - &product_account_id.dot_ns_identifier, - ) - .await - { - let approved = until_cancelled( - cx, - self.connection.confirm_product_action(UserConfirmationReview::ProductSubtree( - ProductSubtreeReview { - product_id: product_account_id.dot_ns_identifier.clone(), - }, - )), - ) - .await - .map_err(account_get_authority_error)? - .map_err(|err| CallError::HostFailure { reason: err.reason })?; - if !approved { - return Err(CallError::Domain(latest::HostAccountGetError::Rejected)); - } - } - - let public_key = self - .product_account_public_key(cx, &session, &product_account_id) - .await - .map_err(account_get_authority_error)?; + let public_key = self.accounts.get_account(cx, product_account_id).await?; Ok(latest::HostAccountGetResponse { account: latest::ProductAccount { public_key: public_key.to_vec() }, @@ -131,14 +73,17 @@ impl Account for ProductRuntimeHost { reason: "Invalid key handle".to_string(), }) })?; - let Some(session) = self.accounts.current_session() else { + let Some(authority_session) = self.accounts.current_session() else { return Err(CallError::Domain(latest::HostAccountGetAliasError::Rejected)); }; let cx = remote_authority_context(cx); - remote_authority_call(&cx, self.accounts.account_alias(&session, &cx, request)) - .await - .map_err(|err| CallError::Domain(ring_vrf_alias_error(err))) + remote_authority_call( + &cx, + self.accounts.account_alias(&authority_session, &cx, request), + ) + .await + .map_err(|err| CallError::Domain(ring_vrf_alias_error(err))) } .await; result @@ -161,29 +106,13 @@ impl Account for ProductRuntimeHost { reason: "Invalid key handle".to_string(), }) })?; - let Some(session) = self.accounts.current_session() else { + let Some(authority_session) = self.accounts.current_session() else { return Err(CallError::Domain(latest::HostAccountCreateProofError::Rejected)); }; - let calling_product_id = self.connection.product_id(); let cx = remote_authority_context(cx); - let Some(owner) = self - .bounded_cross_product_scope_target( - &request.key_handle.dot_ns_identifier, - Granted::Context, - &cx, - ) - .await - else { - tracing::info!( - caller = %calling_product_id, - owner = %request.key_handle.dot_ns_identifier, - "cross-product ring-VRF access refused at the runtime frontend" - ); - return Err(CallError::Domain(latest::HostAccountCreateProofError::NotAllowlisted)); - }; - request.key_handle.dot_ns_identifier = owner; - remote_authority_call(&cx, self.accounts.create_proof(&session, &cx, request)) + self.accounts + .create_proof(&authority_session, &cx, request) .await .map_err(|err| CallError::Domain(ring_vrf_proof_error(err))) } @@ -203,15 +132,18 @@ impl Account for ProductRuntimeHost { let version = request.version(); let request = request.into_latest(); let result = async { - let Some(session) = self.accounts.current_session() else { + let Some(authority_session) = self.accounts.current_session() else { return Err(CallError::Domain( latest::HostAccountRegisterRingVrfKeyError::NotConnected, )); }; let cx = remote_authority_context(cx); - remote_authority_call(&cx, self.accounts.register_ring_vrf_key(&session, &cx, request)) - .await - .map_err(|err| CallError::Domain(ring_vrf_register_error(err))) + remote_authority_call( + &cx, + self.accounts.register_ring_vrf_key(&authority_session, &cx, request), + ) + .await + .map_err(|err| CallError::Domain(ring_vrf_register_error(err))) } .await; result @@ -230,7 +162,7 @@ impl Account for ProductRuntimeHost { let version = request.version(); let mut request = request.into_latest(); let result = async { - let Some(session) = self.accounts.current_session() else { + let Some(authority_session) = self.accounts.current_session() else { return Err(CallError::Domain( latest::HostAccountListRingVrfKeysError::NotConnected, )); @@ -241,9 +173,12 @@ impl Account for ProductRuntimeHost { }) })?; let cx = remote_authority_context(cx); - remote_authority_call(&cx, self.accounts.list_ring_vrf_keys(&session, &cx, request)) - .await - .map_err(|err| CallError::Domain(ring_vrf_list_error(err))) + remote_authority_call( + &cx, + self.accounts.list_ring_vrf_keys(&authority_session, &cx, request), + ) + .await + .map_err(|err| CallError::Domain(ring_vrf_list_error(err))) } .await; result @@ -266,28 +201,12 @@ impl Account for ProductRuntimeHost { reason: "Invalid key handle".to_string(), }) })?; - let Some(session) = self.accounts.current_session() else { + let Some(authority_session) = self.accounts.current_session() else { return Err(CallError::Domain(latest::HostAccountRingVrfSignError::NotConnected)); }; - let calling_product_id = self.connection.product_id(); let cx = remote_authority_context(cx); - let Some(owner) = self - .bounded_cross_product_scope_target( - &request.key_handle.dot_ns_identifier, - Granted::Context, - &cx, - ) - .await - else { - tracing::info!( - caller = %calling_product_id, - owner = %request.key_handle.dot_ns_identifier, - "cross-product ring-VRF access refused at the runtime frontend" - ); - return Err(CallError::Domain(latest::HostAccountRingVrfSignError::NotAllowlisted)); - }; - request.key_handle.dot_ns_identifier = owner; - remote_authority_call(&cx, self.accounts.ring_vrf_sign(&session, &cx, request)) + self.accounts + .ring_vrf_sign(&authority_session, &cx, request) .await .map_err(|err| CallError::Domain(ring_vrf_sign_error(err))) } @@ -315,11 +234,11 @@ impl Account for ProductRuntimeHost { validate_vrf_transcript(&request).map_err(|reason| { CallError::Domain(latest::HostAccountSignVrfError::Unknown { reason }) })?; - let Some(session) = self.accounts.current_session() else { + let Some(authority_session) = self.accounts.current_session() else { return Err(CallError::Domain(latest::HostAccountSignVrfError::NotConnected)); }; let cx = remote_authority_context(cx); - remote_authority_call(&cx, self.accounts.sign_vrf(&session, &cx, request)) + remote_authority_call(&cx, self.accounts.sign_vrf(&authority_session, &cx, request)) .await .map_err(vrf_call_error) } diff --git a/rust/crates/truapi/src/runtime/capabilities/chain.rs b/rust/crates/truapi/src/runtime/capabilities/chain.rs index b748cb8285..8ad3c9236c 100644 --- a/rust/crates/truapi/src/runtime/capabilities/chain.rs +++ b/rust/crates/truapi/src/runtime/capabilities/chain.rs @@ -40,7 +40,7 @@ use super::from_latest_error; use crate::{ host_logic::features::{chain_info, supported_chains}, runtime::{ - AUTHORITY_CANCEL_UNWIND_GRACE, PERMISSION_DENIED_REASON, ProductConnection, + AUTHORITY_CANCEL_UNWIND_GRACE, AccountHolder, PERMISSION_DENIED_REASON, ProductConnection, ProductRuntimeHost, runtime_failure_to_call_error, }, }; @@ -70,7 +70,7 @@ impl ProductConnection { } #[truapi::async_trait] -impl Chain for ProductRuntimeHost { +impl Chain for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "chain.follow_head_subscribe"))] async fn follow_head_subscribe( &self, diff --git a/rust/crates/truapi/src/runtime/capabilities/expanded_card.rs b/rust/crates/truapi/src/runtime/capabilities/expanded_card.rs index 642bd18107..b037d42db4 100644 --- a/rust/crates/truapi/src/runtime/capabilities/expanded_card.rs +++ b/rust/crates/truapi/src/runtime/capabilities/expanded_card.rs @@ -17,11 +17,11 @@ use truapi::{ use super::from_latest_error; use crate::{ platform::{ExpandedCardFaceOutcome, ProductExecutionKind}, - runtime::ProductRuntimeHost, + runtime::{AccountHolder, ProductRuntimeHost}, }; #[truapi::async_trait] -impl ExpandedCard for ProductRuntimeHost { +impl ExpandedCard for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "expanded_card.set_face_shown"))] async fn set_face_shown( &self, diff --git a/rust/crates/truapi/src/runtime/capabilities/game.rs b/rust/crates/truapi/src/runtime/capabilities/game.rs index 890d4bf406..93ecebd88a 100644 --- a/rust/crates/truapi/src/runtime/capabilities/game.rs +++ b/rust/crates/truapi/src/runtime/capabilities/game.rs @@ -19,7 +19,7 @@ use truapi::{ use super::from_latest_error; use crate::{ platform::GamePlatform, - runtime::{ProductConnection, ProductRuntimeHost}, + runtime::{AccountHolder, ProductConnection, ProductRuntimeHost}, unix_time::current_unix_secs, }; @@ -47,7 +47,7 @@ impl ProductConnection { } #[truapi::async_trait] -impl Game for ProductRuntimeHost { +impl Game for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "game.remind_next_game"))] async fn remind_next_game( &self, diff --git a/rust/crates/truapi/src/runtime/capabilities/payment.rs b/rust/crates/truapi/src/runtime/capabilities/payment.rs index 95ac37a71d..d789ab3e98 100644 --- a/rust/crates/truapi/src/runtime/capabilities/payment.rs +++ b/rust/crates/truapi/src/runtime/capabilities/payment.rs @@ -39,11 +39,11 @@ use truapi::{ use super::from_latest_error; use crate::{ host_internal::extrinsic::sr25519_secret_from_bytes, - runtime::{ProductRuntimeHost, payment_id::host_payment_id}, + runtime::{AccountHolder, ProductRuntimeHost, payment_id::host_payment_id}, }; #[truapi::async_trait] -impl CoinPayment for ProductRuntimeHost { +impl CoinPayment for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "coin_payment.create_purse"))] async fn create_purse( &self, @@ -151,7 +151,7 @@ impl CoinPayment for ProductRuntimeHost { } #[truapi::async_trait] -impl Payment for ProductRuntimeHost { +impl Payment for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "payment.balance_subscribe"))] async fn balance_subscribe( &self, @@ -256,7 +256,7 @@ impl Payment for ProductRuntimeHost { } } -impl ProductRuntimeHost { +impl ProductRuntimeHost { /// `Denied` unless a pairing session is connected. fn require_session(&self) -> Result<(), CallError> { match self.accounts.current_session() { diff --git a/rust/crates/truapi/src/runtime/capabilities/platform.rs b/rust/crates/truapi/src/runtime/capabilities/platform.rs index b45c3a768f..2724807df5 100644 --- a/rust/crates/truapi/src/runtime/capabilities/platform.rs +++ b/rust/crates/truapi/src/runtime/capabilities/platform.rs @@ -47,11 +47,11 @@ use crate::{ dotns::{NavigateDecision, parse_navigate}, features::feature_supported, }, - runtime::{PERMISSION_DENIED_REASON, ProductRuntimeHost}, + runtime::{AccountHolder, PERMISSION_DENIED_REASON, ProductRuntimeHost}, }; #[truapi::async_trait] -impl System for ProductRuntimeHost { +impl System for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "system.feature_supported"))] async fn feature_supported( &self, @@ -158,7 +158,7 @@ impl System for ProductRuntimeHost { } #[truapi::async_trait] -impl Permissions for ProductRuntimeHost { +impl Permissions for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "permissions.authorize_device_permission"))] async fn authorize_device_permission( &self, @@ -249,7 +249,7 @@ impl Permissions for ProductRuntimeHost { } #[truapi::async_trait] -impl LocalStorage for ProductRuntimeHost { +impl LocalStorage for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "local_storage.read"))] async fn read( &self, @@ -373,7 +373,7 @@ impl LocalStorage for ProductRuntimeHost { } #[truapi::async_trait] -impl Worker for ProductRuntimeHost { +impl Worker for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "worker.begin_operation"))] async fn begin_operation( &self, @@ -415,7 +415,7 @@ impl Worker for ProductRuntimeHost { } #[truapi::async_trait] -impl Theme for ProductRuntimeHost { +impl Theme for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "theme.subscribe"))] async fn subscribe( &self, @@ -436,7 +436,7 @@ impl Theme for ProductRuntimeHost { } #[truapi::async_trait] -impl Locale for ProductRuntimeHost { +impl Locale for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "locale.subscribe"))] async fn subscribe( &self, @@ -460,7 +460,7 @@ impl Locale for ProductRuntimeHost { // cancellation while the core preserves the typed TrUAPI wire shape. #[truapi::async_trait] -impl Notifications for ProductRuntimeHost { +impl Notifications for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "notifications.send_push_notification"))] async fn send_push_notification( &self, diff --git a/rust/crates/truapi/src/runtime/capabilities/preimage.rs b/rust/crates/truapi/src/runtime/capabilities/preimage.rs index 79be891356..1d3ecd365e 100644 --- a/rust/crates/truapi/src/runtime/capabilities/preimage.rs +++ b/rust/crates/truapi/src/runtime/capabilities/preimage.rs @@ -26,14 +26,13 @@ use crate::{ host_internal::bulletin::preimage_key, runtime::{ PERMISSION_DENIED_REASON, PREIMAGE_REMOTE_AUTHORITY_RESPONSE_TIMEOUT, - PREIMAGE_SUBMIT_TIMEOUT, ProductRuntimeHost, bulletin_allowance_error_reason, - bulletin_rpc::BulletinSubmitError, preimage_submit_error, remote_authority_call, + PREIMAGE_SUBMIT_TIMEOUT, ProductRuntimeHost, preimage_submit_error, remote_authority_call, remote_authority_context_until, }, }; #[truapi::async_trait] -impl Preimage for ProductRuntimeHost { +impl Preimage for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "preimage.lookup_subscribe"))] async fn lookup_subscribe( &self, @@ -110,10 +109,9 @@ impl Preimage for ProductRuntimeHost { let version = request.version(); let value = request.into_latest(); let result = async { - let Some(session) = self.accounts.current_session() else { + let Some(authority_session) = self.accounts.current_session() else { return Err(preimage_submit_error("No active session".to_string())); }; - let bulletin = &self.connection.services.bulletin; self.connection .require_remote_permission( latest::RemotePermission::PreimageSubmit, @@ -128,44 +126,23 @@ impl Preimage for ProductRuntimeHost { PREIMAGE_REMOTE_AUTHORITY_RESPONSE_TIMEOUT, submission_deadline, ); - let allowance = remote_authority_call( + let key = remote_authority_call( &authority_cx, - self.accounts.bulletin_allowance_key(&authority_cx, &session), + self.accounts.submit_preimage( + &authority_cx, + submission_deadline, + &authority_session, + &value, + ), ) .await - .map_err(|err| preimage_submit_error(bulletin_allowance_error_reason(err)))?; - + .map_err(|err| preimage_submit_error(err.to_string()))?; #[cfg(feature = "test-host")] if self.accounts.submits_preimages_locally() { - let key = preimage_key(&value); - self.connection.services.keep_local_preimage(key, value); - return Ok(key.to_vec()); + self.connection.services.keep_local_preimage(preimage_key(&value), value); + return Ok(key); } - let key = match bulletin - .submit_preimage(cx, submission_deadline, &allowance, &value) - .await - { - Ok(key) => key, - Err(BulletinSubmitError::AllowanceRejected { .. }) => { - let authority_cx = remote_authority_context_until( - cx, - PREIMAGE_REMOTE_AUTHORITY_RESPONSE_TIMEOUT, - submission_deadline, - ); - let allowance = remote_authority_call( - &authority_cx, - self.accounts.refresh_bulletin_allowance_key(&authority_cx, &session), - ) - .await - .map_err(|err| preimage_submit_error(bulletin_allowance_error_reason(err)))?; - bulletin - .submit_preimage(cx, submission_deadline, &allowance, &value) - .await - .map_err(|err| preimage_submit_error(err.to_string()))? - }, - Err(err) => return Err(preimage_submit_error(err.to_string())), - }; // Move the owned body into the lookup cache (no extra copy) so an // immediate product lookup hits before the content backend has it. self.prime_preimage_cache(&key, value); diff --git a/rust/crates/truapi/src/runtime/capabilities/resources.rs b/rust/crates/truapi/src/runtime/capabilities/resources.rs index b4e5155305..7bc89fe436 100644 --- a/rust/crates/truapi/src/runtime/capabilities/resources.rs +++ b/rust/crates/truapi/src/runtime/capabilities/resources.rs @@ -1,6 +1,5 @@ //! Product-facing resources capability adapters. -use crate::platform::{ResourceAllocationReview, UserConfirmationReview}; use tracing::instrument; use truapi::{ CallContext, CallError, @@ -18,13 +17,10 @@ use truapi::{ use super::from_latest_error; use truapi::versioned::{FromLatest, IntoLatest, Versioned}; -use crate::runtime::{ - ProductRuntimeHost, RESOURCE_ALLOCATION_REMOTE_AUTHORITY_RESPONSE_TIMEOUT, - remote_authority_call, remote_authority_context_with_default, until_cancelled, -}; +use crate::runtime::{AccountHolder, ProductRuntimeHost, authority::AuthorityError}; #[truapi::async_trait] -impl ResourceAllocation for ProductRuntimeHost { +impl ResourceAllocation for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "resource_allocation.request"))] async fn request( &self, @@ -35,46 +31,22 @@ impl ResourceAllocation for ProductRuntimeHost { let version = request.version(); let inner = request.into_latest(); let result = async { - let Some(session) = self.accounts.current_session() else { + let Some(authority_session) = self.accounts.current_session() else { return Err(CallError::Domain(latest::ResourceAllocationError::Unknown { reason: "No active session".to_string(), })); }; - let confirmed = until_cancelled( - cx, - self.connection - .confirm_product_action(UserConfirmationReview::ResourceAllocation( - ResourceAllocationReview { - calling_product_id: self.connection.product_id(), - resources: inner.resources.clone(), - }, - )), - ) - .await - .map_err(|err| { - CallError::Domain(latest::ResourceAllocationError::Unknown { - reason: err.to_string(), - }) - })? - .map_err(|err| CallError::HostFailure { - reason: format!("resource allocation confirmation failed: {err:?}"), - })?; - if !confirmed { - return Err(CallError::Domain(latest::ResourceAllocationError::Unknown { - reason: "User rejected resource allocation".to_string(), - })); - } - let cx = remote_authority_context_with_default( - cx, - RESOURCE_ALLOCATION_REMOTE_AUTHORITY_RESPONSE_TIMEOUT, - ); - remote_authority_call(&cx, self.accounts.allocate_resources(&cx, &session, inner)) + self.accounts + .allocate_resources(cx, &authority_session, inner) .await - .map_err(|err| { - CallError::Domain(latest::ResourceAllocationError::Unknown { - reason: err.to_string(), - }) + .map_err(|error| match error { + AuthorityError::ConfirmationFailed(error) => CallError::HostFailure { + reason: format!("resource allocation confirmation failed: {error:?}"), + }, + error => CallError::Domain(latest::ResourceAllocationError::Unknown { + reason: error.to_string(), + }), }) } .await; @@ -87,7 +59,7 @@ impl ResourceAllocation for ProductRuntimeHost { } #[truapi::async_trait] -impl Entropy for ProductRuntimeHost { +impl Entropy for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "entropy.derive"))] async fn derive( &self, @@ -97,16 +69,17 @@ impl Entropy for ProductRuntimeHost { let version = request.version(); let latest::HostDeriveEntropyRequest { context } = request.into_latest(); let result = async { - let Some(session) = self.accounts.current_session() else { + let Some(authority_session) = self.accounts.current_session() else { return Err(CallError::Domain(latest::HostDeriveEntropyError::Unknown { reason: "Not connected".to_string(), })); }; - let entropy = self.accounts.derive_entropy(&session, &context).map_err(|err| { - CallError::Domain(latest::HostDeriveEntropyError::Unknown { - reason: err.to_string(), - }) - })?; + let entropy = + self.accounts.derive_entropy(&authority_session, &context).map_err(|err| { + CallError::Domain(latest::HostDeriveEntropyError::Unknown { + reason: err.to_string(), + }) + })?; Ok(latest::HostDeriveEntropyResponse { entropy }) } diff --git a/rust/crates/truapi/src/runtime/capabilities/scanner.rs b/rust/crates/truapi/src/runtime/capabilities/scanner.rs index 85f3a966dc..14a421deb7 100644 --- a/rust/crates/truapi/src/runtime/capabilities/scanner.rs +++ b/rust/crates/truapi/src/runtime/capabilities/scanner.rs @@ -15,7 +15,7 @@ use super::from_latest_error; use crate::{ host_logic::scanner::{accepts, validate_request}, platform::{HostScan, ProductExecutionKind}, - runtime::{ProductRuntimeHost, until_cancelled}, + runtime::{AccountHolder, ProductRuntimeHost, until_cancelled}, }; fn unknown(reason: impl ToString) -> CallError { @@ -23,7 +23,7 @@ fn unknown(reason: impl ToString) -> CallError { } #[truapi::async_trait] -impl Scanner for ProductRuntimeHost { +impl Scanner for ProductRuntimeHost { /// Checked in this order so a host without a scanner, or a request the /// product could not make, never opens a viewfinder. #[instrument(skip_all, fields(runtime.method = "scanner.scan"))] diff --git a/rust/crates/truapi/src/runtime/capabilities/signing.rs b/rust/crates/truapi/src/runtime/capabilities/signing.rs index cf137a06ad..b4474efe0b 100644 --- a/rust/crates/truapi/src/runtime/capabilities/signing.rs +++ b/rust/crates/truapi/src/runtime/capabilities/signing.rs @@ -1,9 +1,5 @@ //! Product-facing signing capability adapters. -use crate::platform::{ - CreateTransactionReview, CrossProductSignatureKind, SignPayloadReview, SignRawReview, - UserConfirmationReview, -}; use tracing::{debug, instrument}; use truapi::{ CallContext, CallError, @@ -25,18 +21,17 @@ use super::from_latest_error; use truapi::versioned::{FromLatest, IntoLatest, Versioned}; use crate::runtime::{ - ContactResolutionError, LEGACY_ACCOUNT_UNAVAILABLE_REASON, + AccountHolder, ContactResolutionError, LEGACY_ACCOUNT_UNAVAILABLE_REASON, LEGACY_PRODUCT_ACCOUNT_MISMATCH_REASON, LegacySigner, ProductRuntimeHost, authority::{ - AuthorityError, AuthoritySession, AutoSigningGrant, CreateTransactionAuthorityRequest, + AuthorityError, AuthoritySession, CreateTransactionAuthorityRequest, SignPayloadAuthorityRequest, SignRawAuthorityRequest, }, - remote_authority_call, remote_authority_context, signing_call_error, transaction_call_error, - until_cancelled, + signing_call_error, transaction_call_error, }; #[truapi::async_trait] -impl Signing for ProductRuntimeHost { +impl Signing for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "signing.sign_payload"))] async fn sign_payload( &self, @@ -47,58 +42,24 @@ impl Signing for ProductRuntimeHost { let mut inner = request.into_latest(); let result = async { debug!("sign_payload: requesting signing-host signature"); - inner.account = Self::normalize_product_account_id(inner.account) - .map_err(|()| CallError::Domain(latest::HostSignPayloadError::PermissionDenied))?; - self.connection - .require_chain_submit(latest::HostSignPayloadError::PermissionDenied) + let (authority_session, account) = self + .product_signer( + cx, + inner.account, + || latest::HostSignPayloadError::PermissionDenied, + latest::HostSignPayloadError::Rejected, + ) .await?; - let Some(session) = self.accounts.current_session() else { - return Err(CallError::Domain(latest::HostSignPayloadError::Rejected)); - }; - let Some(owner) = - self.authorized_product_account(&inner.account.dot_ns_identifier, cx).await - else { - return Err(CallError::Domain(latest::HostSignPayloadError::PermissionDenied)); - }; - inner.account.dot_ns_identifier = owner; - let grant = self - .auto_signing_status(&session, &inner.account) - .await - .map_err(signing_call_error)?; - if grant == AutoSigningGrant::Absent { - let confirmed = until_cancelled( + inner.account = account; + self.accounts + .sign_payload( + &authority_session, cx, - self.connection.confirm_signature( - CrossProductSignatureKind::Payload, - &inner.account, - || { - UserConfirmationReview::SignPayload(SignPayloadReview::Product { - calling_product_id: Some(self.connection.product_id()), - request: inner.clone(), - }) - }, - ), + &self.connection.temporary_permissions, + SignPayloadAuthorityRequest::Product(inner), ) .await - .map_err(signing_call_error)? - .map_err(|err| CallError::HostFailure { - reason: format!("sign payload confirmation failed: {err:?}"), - })?; - if !confirmed { - return Err(CallError::Domain(latest::HostSignPayloadError::Rejected)); - } - } - let cx = remote_authority_context(cx); - remote_authority_call( - &cx, - self.accounts.sign_payload( - &session, - &cx, - SignPayloadAuthorityRequest::Product(inner), - ), - ) - .await - .map_err(signing_call_error) + .map_err(signing_call_error) } .await; result @@ -147,24 +108,15 @@ impl Signing for ProductRuntimeHost { let mut inner = request.into_latest(); let result = async { debug!("create_transaction: requesting signing-host signature"); - inner.signer = Self::normalize_product_account_id(inner.signer).map_err(|()| { - CallError::Domain(latest::HostCreateTransactionError::PermissionDenied) - })?; - self.connection - .require_chain_submit(latest::HostCreateTransactionError::PermissionDenied) + let (authority_session, signer) = self + .product_signer( + cx, + inner.signer, + || latest::HostCreateTransactionError::PermissionDenied, + latest::HostCreateTransactionError::Rejected, + ) .await?; - let Some(session) = self.accounts.current_session() else { - return Err(CallError::Domain(latest::HostCreateTransactionError::Rejected)); - }; - let Some(owner) = - self.authorized_product_account(&inner.signer.dot_ns_identifier, cx).await - else { - return Err(CallError::Domain( - latest::HostCreateTransactionError::PermissionDenied, - )); - }; - inner.signer.dot_ns_identifier = owner; - let names_contacts = !inner.contacts.is_empty(); + inner.signer = signer; inner.call_data = self .substitute_declared_contacts(inner.call_data, &inner.contacts) .await @@ -187,50 +139,15 @@ impl Signing for ProductRuntimeHost { }; CallError::Domain(error) })?; - let grant = self - .auto_signing_status(&session, &inner.signer) - .await - .map_err(transaction_call_error)?; - if grant == AutoSigningGrant::Absent || names_contacts { - let review = || { - UserConfirmationReview::CreateTransaction(CreateTransactionReview::Product { - calling_product_id: Some(self.connection.product_id()), - payload: inner.clone(), - }) - }; - let confirmed = until_cancelled(cx, async { - if names_contacts { - self.connection.confirm_product_action(review()).await - } else { - self.connection - .confirm_signature( - CrossProductSignatureKind::Transaction, - &inner.signer, - review, - ) - .await - } - }) - .await - .map_err(transaction_call_error)? - .map_err(|err| CallError::HostFailure { - reason: format!("create transaction confirmation failed: {err:?}"), - })?; - if !confirmed { - return Err(CallError::Domain(latest::HostCreateTransactionError::Rejected)); - } - } - let cx = remote_authority_context(cx); - remote_authority_call( - &cx, - self.accounts.create_transaction( - &session, - &cx, + self.accounts + .create_transaction( + &authority_session, + cx, + &self.connection.temporary_permissions, CreateTransactionAuthorityRequest::Product(inner), - ), - ) - .await - .map_err(transaction_call_error) + ) + .await + .map_err(transaction_call_error) } .await; result @@ -250,11 +167,11 @@ impl Signing for ProductRuntimeHost { let version = request.version(); let inner = request.into_latest(); let result = async { - let Some(session) = self.accounts.current_session() else { + let Some(authority_session) = self.accounts.current_session() else { return Err(CallError::Domain(latest::HostSignPayloadError::Rejected)); }; let signer = self - .classify_legacy_address_signer(cx, &session, &inner.signer) + .classify_legacy_address_signer(cx, &authority_session, &inner.signer) .await .map_err(|err| { CallError::Domain(err.into_host_error(LEGACY_PRODUCT_ACCOUNT_MISMATCH_REASON)) @@ -267,28 +184,11 @@ impl Signing for ProductRuntimeHost { self.connection .require_chain_submit(latest::HostSignPayloadError::PermissionDenied) .await?; - let confirmed = until_cancelled( - cx, - self.connection - .platform - .confirm_user_action(UserConfirmationReview::SignPayload( - SignPayloadReview::LegacyAccount(inner.clone()), - )), - ) - .await - .map_err(signing_call_error)? - .map_err(|err| CallError::HostFailure { - reason: format!("sign payload confirmation failed: {err:?}"), - })?; - if !confirmed { - return Err(CallError::Domain(latest::HostSignPayloadError::Rejected)); - } - let cx = remote_authority_context(cx); - remote_authority_call( - &cx, - self.accounts.sign_payload( - &session, - &cx, + self.accounts + .sign_payload( + &authority_session, + cx, + &self.connection.temporary_permissions, SignPayloadAuthorityRequest::LegacyAccount { product_account: latest::ProductAccountId { dot_ns_identifier: self.connection.product_id(), @@ -296,10 +196,9 @@ impl Signing for ProductRuntimeHost { }, request: inner, }, - ), - ) - .await - .map_err(signing_call_error) + ) + .await + .map_err(signing_call_error) } .await; result @@ -356,11 +255,13 @@ impl Signing for ProductRuntimeHost { let version = request.version(); let inner = request.into_latest(); let result = async { - let Some(session) = self.accounts.current_session() else { + let Some(authority_session) = self.accounts.current_session() else { return Err(CallError::Domain(latest::HostCreateTransactionError::Rejected)); }; - let signer = - self.classify_legacy_signer(cx, &session, inner.signer).await.map_err(|err| { + let signer = self + .classify_legacy_signer(cx, &authority_session, inner.signer) + .await + .map_err(|err| { CallError::Domain(latest::HostCreateTransactionError::Unknown { reason: err.into_reason(LEGACY_PRODUCT_ACCOUNT_MISMATCH_REASON), }) @@ -368,23 +269,6 @@ impl Signing for ProductRuntimeHost { self.connection .require_chain_submit(latest::HostCreateTransactionError::PermissionDenied) .await?; - let confirmed = until_cancelled( - cx, - self.connection.platform.confirm_user_action( - UserConfirmationReview::CreateTransaction( - CreateTransactionReview::LegacyAccount(inner.clone()), - ), - ), - ) - .await - .map_err(transaction_call_error)? - .map_err(|err| CallError::HostFailure { - reason: format!("create transaction confirmation failed: {err:?}"), - })?; - if !confirmed { - return Err(CallError::Domain(latest::HostCreateTransactionError::Rejected)); - } - let cx = remote_authority_context(cx); let authority_request = match signer { LegacySigner::Product => CreateTransactionAuthorityRequest::LegacyAccount { product_account: latest::ProductAccountId { @@ -397,15 +281,18 @@ impl Signing for ProductRuntimeHost { CreateTransactionAuthorityRequest::IdentityAccount(inner) }, }; - remote_authority_call( - &cx, - self.accounts.create_transaction(&session, &cx, authority_request), - ) - .await - .map(|response| latest::HostCreateTransactionWithLegacyAccountResponse { - transaction: response.transaction, - }) - .map_err(transaction_call_error) + self.accounts + .create_transaction( + &authority_session, + cx, + &self.connection.temporary_permissions, + authority_request, + ) + .await + .map(|response| latest::HostCreateTransactionWithLegacyAccountResponse { + transaction: response.transaction, + }) + .map_err(transaction_call_error) } .await; result @@ -418,14 +305,27 @@ impl Signing for ProductRuntimeHost { } } -impl ProductRuntimeHost { - /// Whether the local authority can sign without confirmation. - async fn auto_signing_status( +impl ProductRuntimeHost { + /// The session to sign under and the product account a request names, once + /// the caller may submit to chain and its manifest grants that account. + async fn product_signer( &self, - session: &AuthoritySession, - account: &latest::ProductAccountId, - ) -> Result { - self.accounts.auto_signing_status(session, account).await + cx: &CallContext, + account: latest::ProductAccountId, + denied: impl Fn() -> E, + rejected: E, + ) -> Result<(AuthoritySession, latest::ProductAccountId), CallError> { + let mut account = Self::normalize_product_account_id(account) + .map_err(|()| CallError::Domain(denied()))?; + let authority_session = self.accounts.current_session(); + self.connection.require_chain_submit(denied()).await?; + let authority_session = authority_session.ok_or(CallError::Domain(rejected))?; + account.dot_ns_identifier = self + .accounts + .authorized_product_account(&account.dot_ns_identifier, cx) + .await + .ok_or_else(|| CallError::Domain(denied()))?; + Ok((authority_session, account)) } async fn sign_raw_with_watermark( @@ -436,65 +336,25 @@ impl ProductRuntimeHost { ) -> Result> { debug!("sign_raw: requesting signing-host signature"); let mut inner = request; - inner.account = Self::normalize_product_account_id(inner.account) - .map_err(|()| CallError::Domain(latest::HostSignPayloadError::PermissionDenied))?; - self.connection - .require_chain_submit(latest::HostSignPayloadError::PermissionDenied) + let (authority_session, account) = self + .product_signer( + cx, + inner.account, + || latest::HostSignPayloadError::PermissionDenied, + latest::HostSignPayloadError::Rejected, + ) .await?; - let Some(session) = self.accounts.current_session() else { - return Err(CallError::Domain(latest::HostSignPayloadError::Rejected)); - }; - let Some(owner) = - self.authorized_product_account(&inner.account.dot_ns_identifier, cx).await - else { - return Err(CallError::Domain(latest::HostSignPayloadError::PermissionDenied)); - }; - inner.account.dot_ns_identifier = owner; - let grant = if watermarked { - self.auto_signing_status(&session, &inner.account) - .await - .map_err(signing_call_error)? - } else { - AutoSigningGrant::Absent - }; - if grant == AutoSigningGrant::Absent { - let review = || { - UserConfirmationReview::SignRaw(SignRawReview::Product { - calling_product_id: Some(self.connection.product_id()), - request: inner.clone(), - watermarked, - }) - }; - let confirmed = until_cancelled(cx, async { - if watermarked { - self.connection - .confirm_signature(CrossProductSignatureKind::Raw, &inner.account, review) - .await - } else { - self.connection.confirm_product_action(review()).await - } - }) - .await - .map_err(signing_call_error)? - .map_err(|err| CallError::HostFailure { - reason: format!("sign raw confirmation failed: {err:?}"), - })?; - if !confirmed { - return Err(CallError::Domain(latest::HostSignPayloadError::Rejected)); - } - } - let cx = remote_authority_context(cx); - remote_authority_call( - &cx, - self.accounts.sign_raw( - &session, - &cx, + inner.account = account; + self.accounts + .sign_raw( + &authority_session, + cx, + &self.connection.temporary_permissions, SignRawAuthorityRequest::Product(inner), watermarked, - ), - ) - .await - .map_err(signing_call_error) + ) + .await + .map_err(raw_signing_call_error) } async fn sign_raw_with_legacy_account_with_watermark( @@ -504,48 +364,48 @@ impl ProductRuntimeHost { watermarked: bool, ) -> Result> { let inner = request; - let Some(session) = self.accounts.current_session() else { + let Some(authority_session) = self.accounts.current_session() else { return Err(CallError::Domain(latest::HostSignPayloadError::Rejected)); }; - let signer = - self.classify_legacy_address_signer(cx, &session, &inner.signer).await.map_err( - |err| CallError::Domain(err.into_host_error(LEGACY_ACCOUNT_UNAVAILABLE_REASON)), - )?; + let signer = self + .classify_legacy_address_signer(cx, &authority_session, &inner.signer) + .await + .map_err(|err| { + CallError::Domain(err.into_host_error(LEGACY_ACCOUNT_UNAVAILABLE_REASON)) + })?; self.connection .require_chain_submit(latest::HostSignPayloadError::PermissionDenied) .await?; - let confirmed = until_cancelled( - cx, - self.connection.platform.confirm_user_action(UserConfirmationReview::SignRaw( - SignRawReview::LegacyAccount { request: inner.clone(), watermarked }, - )), - ) - .await - .map_err(signing_call_error)? - .map_err(|err| CallError::HostFailure { - reason: format!("sign raw confirmation failed: {err:?}"), - })?; - if !confirmed { - return Err(CallError::Domain(latest::HostSignPayloadError::Rejected)); - } - let cx = remote_authority_context(cx); let authority_request = match signer { - LegacySigner::Product => SignRawAuthorityRequest::Product(latest::HostSignRawRequest { - account: latest::ProductAccountId { + LegacySigner::Product => SignRawAuthorityRequest::LegacyAccount { + product_account: latest::ProductAccountId { dot_ns_identifier: self.connection.product_id(), derivation_index: latest::DerivationIndex::Index(0), }, - payload: inner.payload, - }), + request: inner, + }, LegacySigner::Identity(account) => { - SignRawAuthorityRequest::LegacyAccount { account, request: inner } + SignRawAuthorityRequest::IdentityAccount { account, request: inner } }, }; - remote_authority_call( - &cx, - self.accounts.sign_raw(&session, &cx, authority_request, watermarked), - ) - .await - .map_err(signing_call_error) + self.accounts + .sign_raw( + &authority_session, + cx, + &self.connection.temporary_permissions, + authority_request, + watermarked, + ) + .await + .map_err(raw_signing_call_error) + } +} + +fn raw_signing_call_error(error: AuthorityError) -> CallError { + match error { + AuthorityError::ConfirmationFailed(error) => { + CallError::HostFailure { reason: format!("sign raw confirmation failed: {error:?}") } + }, + error => signing_call_error(error), } } diff --git a/rust/crates/truapi/src/runtime/contacts.rs b/rust/crates/truapi/src/runtime/contacts.rs index b539f9d382..95d6b4b193 100644 --- a/rust/crates/truapi/src/runtime/contacts.rs +++ b/rust/crates/truapi/src/runtime/contacts.rs @@ -27,7 +27,7 @@ pub const CONTACT_HANDLE_CONTEXT: &[u8] = b"truapi-contact-handle"; /// Domain-separate the session's root entropy source into the contact-handle /// key, so this key cannot collide with another derived from the same source. -pub fn handle_key_from_root_source(root_entropy_source: &[u8; 32]) -> [u8; 32] { +fn handle_key_from_root_source(root_entropy_source: &[u8; 32]) -> [u8; 32] { blake2b256_keyed(root_entropy_source, CONTACT_HANDLE_CONTEXT) } @@ -58,14 +58,8 @@ pub struct ContactHandles { impl ContactHandles { /// Derive the handle key from the session's root entropy source. - #[cfg(test)] pub fn from_root_entropy_source(root_entropy_source: &[u8; 32]) -> Self { - Self::from_handle_key(handle_key_from_root_source(root_entropy_source)) - } - - /// Take the handle key an authority already derived. - pub fn from_handle_key(handle_key: [u8; 32]) -> Self { - Self { handle_key } + Self { handle_key: handle_key_from_root_source(root_entropy_source) } } /// The handle this user knows `account` by. @@ -73,9 +67,10 @@ impl ContactHandles { contact_handle(&self.handle_key, account) } - /// The key handles are minted under, which a host needs to look them up. - pub fn handle_key(&self) -> [u8; 32] { - self.handle_key + /// Ask the host for the accounts `handles` name; the host needs the key + /// handles are minted under to look them up. + pub fn lookup(&self, handles: Vec<[u8; 32]>) -> crate::platform::HostContactLookup { + crate::platform::HostContactLookup { handle_key: self.handle_key, handles } } /// Whether `account` is the one `handle` names. diff --git a/rust/crates/truapi/src/runtime/host_accounts.rs b/rust/crates/truapi/src/runtime/host_accounts.rs new file mode 100644 index 0000000000..dc7b2c8d66 --- /dev/null +++ b/rust/crates/truapi/src/runtime/host_accounts.rs @@ -0,0 +1,1424 @@ +//! Product account policy and retained grants, independent of account-holder transport. + +use super::{ + allowances::{AllowanceResource, GrantScope}, + authority::{ + AccountCaller, AccountGrant, AccountGrantOutcome, AccountHolder, AccountInvocation, + AuthorityError, AuthoritySession, AutoSigningKey, BulletinAllowanceKey, + CreateTransactionAuthorityRequest, SignPayloadAuthorityRequest, SignRawAuthorityRequest, + StatementStoreAllowanceKey, SubtreeResolution, + }, + host_grants::{GrantBarrier, HostGrantGuard, HostGrantStore}, + product_consent::ProductConsent, + ring_vrf_registry::{RingVrfRegistryStore, apply_ring_vrf_disclosure, validate_owner_listing}, + services::RuntimeServices, + signing_host::{ChainRingResolver, MemberCandidate, RingResolver, context_bytes, create_proof}, + sso_request_service::PairedSessionOwner, + vrf, +}; +use crate::{ + host_internal::{ + extrinsic::{Sr25519Signer, build_signed_transaction, local_transaction_metadata}, + permissions::TemporaryPermissions, + sso_messages::{OnExistingAllowancePolicy, RingVrfError}, + transaction::sign_extrinsic_payload, + }, + host_logic::{ + product_account::{ + SR25519_SIGNING_CONTEXT, derivation_index_bytes, + derive_product_keypair_from_subtree_secret, derive_product_public_key, + derive_ring_vrf_entropy_from_domain, + }, + raw_signing::raw_payload_bytes, + session::{SessionInfo, SessionState}, + }, + platform::{ + CrossProductSignatureKind, PermissionAuthorizationStatus, ProductContext, + ProductSubtreeReview, ResourceAllocationReview, SignVrfReview, + StatementStoreProductSignReview, UserConfirmationReview, normalize_product_identifier, + }, +}; +use futures::StreamExt; +use std::sync::Arc; +#[cfg(not(target_arch = "wasm32"))] +use std::time::Instant; +use truapi::{CallContext, CallError, latest as api}; +#[cfg(target_arch = "wasm32")] +use web_time::Instant; +use zeroize::Zeroizing; + +/// Receives, retains and uses product grants from one selected account holder. +pub struct HostAccounts { + services: Arc, + holder: Arc, + session_state: Arc, + grants: Arc, + ring_resolver: Arc, + ring_vrf_registry: Arc, + consent: Arc, + #[cfg(feature = "test-host")] + resource_controls: Arc, + #[cfg(feature = "test-host")] + submit_preimages_locally: core::sync::atomic::AtomicBool, +} + +#[async_trait::async_trait] +impl PairedSessionOwner for HostAccounts { + async fn write_barrier(&self) -> GrantBarrier { + self.grants.barrier().await + } + + fn session_ended(&self, previous: Option<&SessionInfo>, revoked: bool) { + self.grants.session_ended(previous, revoked); + self.consent.forget_allowed_once(); + } + + fn begin_cleanup(&self, barrier: &GrantBarrier) -> bool { + self.grants.persistence_under(barrier).begin_cleanup() + } + + async fn finish_cleanup(&self, barrier: &GrantBarrier) -> Result<(), String> { + self.grants.persistence_under(barrier).drain_cleanup().await + } + + async fn prepare_session( + &self, + barrier: &GrantBarrier, + previous: Option<&SessionInfo>, + next: &SessionInfo, + ) { + self.grants.persistence_under(barrier).prepare_session(previous, next).await; + } +} + +impl HostAccounts { + /// Compose one host's policy with its canonical session and grant owners. + pub fn new( + services: Arc, + holder: Arc, + session_state: Arc, + ring_vrf_registry: Arc, + consent: Arc, + #[cfg(feature = "test-host")] resource_controls: Arc< + super::test_resource_controls::TestResourceControls, + >, + ) -> Self { + Self { + ring_resolver: ChainRingResolver::new(services.chain.clone()), + grants: Arc::new(HostGrantStore::new(services.platform.clone(), H::GRANT_KEEPING)), + services, + holder, + session_state, + ring_vrf_registry, + consent, + #[cfg(feature = "test-host")] + resource_controls, + #[cfg(feature = "test-host")] + submit_preimages_locally: core::sync::atomic::AtomicBool::new(false), + } + } + + /// Clear one product's grants and Allow once answers, keeping the session and other products. + pub async fn clear_product_state(&self, product_id: &str) -> Result<(), String> { + let product_id = + normalize_product_identifier(product_id).map_err(|error| error.to_string())?; + self.consent.forget_allowed_once_for(&product_id); + self.grants.clear_product_grants(&self.session_state, &product_id).await + } + + /// Revoke the grants `owner`'s wallet left on this host. When that wallet + /// is the active one, its activation ends through `end_activation` before + /// any grant can be used again, and this returns true. + pub fn revoke_wallet(&self, owner: [u8; 32], end_activation: impl FnOnce()) -> bool { + let mut lifecycle = self.grants.lifecycle(); + lifecycle.revoke_native_owner(owner); + let active = + self.session_state.current().is_some_and(|session| session.public_key == owner); + if active { + lifecycle.clear_memory(); + self.consent.forget_allowed_once(); + end_activation(); + } + active + } + + /// Finish deleting revoked grants from storage. + pub async fn finish_grant_cleanup(&self) -> Result<(), String> { + self.grants.persistence().await.drain_cleanup().await + } + + /// Forget this activation's grants and Allow once answers, then make + /// `change` before any grant can be used again. + pub fn change_activation(&self, change: impl FnOnce() -> T) -> T { + let mut lifecycle = self.grants.lifecycle(); + lifecycle.clear_memory(); + self.consent.forget_allowed_once(); + change() + } + + /// Drop the kept AutoSigning keys, as a paired host's logout requires. + pub async fn forget_auto_signing_keys(&self) -> Result<(), String> { + self.grants.persistence().await.clear_auto_signing_keys().await + } + + /// Keep test submissions in memory when no on-chain allowance exists. + #[cfg(feature = "test-host")] + pub fn set_submit_preimages_locally(&self, local: bool) { + self.submit_preimages_locally + .store(local, core::sync::atomic::Ordering::Relaxed); + } + + /// Unchecked allowances cannot authorize a real Bulletin submission. + #[cfg(feature = "test-host")] + pub fn submits_preimages_locally(&self) -> bool { + self.resource_controls.grants_allowances_unchecked() || + self.submit_preimages_locally.load(core::sync::atomic::Ordering::Relaxed) + } + + /// Selected account holder identity. + pub fn current_session(&self) -> Option { + self.holder.current_session() + } + + fn hold_session( + &self, + session: &AuthoritySession, + ) -> Result, AuthorityError> { + let lifecycle = self.grants.lifecycle(); + self.holder.require_current_session(session)?; + Ok(lifecycle) + } + + fn hold_grant( + &self, + session: &AuthoritySession, + revision: u64, + ) -> Result, AuthorityError> { + let lifecycle = self.hold_session(session)?; + lifecycle.require_revision(revision)?; + Ok(lifecycle) + } + + fn grant_session( + &self, + session: &AuthoritySession, + ) -> Result<(SessionInfo, u64), AuthorityError> { + let lifecycle = self.hold_session(session)?; + let session = self.session_state.current().ok_or(AuthorityError::Disconnected)?; + Ok((session, lifecycle.revision())) + } + + /// Canonical account owner authorized by the calling product's manifest grants. + pub async fn authorized_product_account( + &self, + product: &ProductContext, + dot_ns_identifier: &str, + cx: &CallContext, + ) -> Option { + let owner = normalize_product_identifier(dot_ns_identifier).ok()?; + if owner == product.product_id || + crate::platform::is_localhost_product_identifier(&product.product_id) + { + return Some(owner); + } + let cx = super::remote_authority_context(cx); + super::until_cancelled( + &cx, + super::product_manifest::grants_scope( + &self.services, + self.services.platform.as_ref(), + &product.product_id, + &owner, + crate::host_internal::product_manifest::Granted::Context, + ), + ) + .await + .ok()? + .then_some(owner) + } + + /// Disclose an account only after the calling product may access it. + pub async fn get_account( + &self, + cx: &CallContext, + product: &ProductContext, + mut account: api::ProductAccountId, + ) -> Result<[u8; 32], CallError> { + account.dot_ns_identifier = normalize_product_identifier(&account.dot_ns_identifier) + .map_err(|_| CallError::Domain(api::HostAccountGetError::DomainNotValid))?; + let authority_session = self + .current_session() + .ok_or(CallError::Domain(api::HostAccountGetError::NotConnected))?; + if account.dot_ns_identifier != product.product_id { + match self + .consent + .account_access(&product.product_id, &account.dot_ns_identifier) + .await + { + Ok(PermissionAuthorizationStatus::Authorized) => {}, + Ok( + PermissionAuthorizationStatus::Denied | + PermissionAuthorizationStatus::NotDetermined, + ) => { + return Err(CallError::Domain(api::HostAccountGetError::Rejected)); + }, + Err(error) => { + return Err(CallError::HostFailure { reason: error.to_string() }); + }, + } + } + let subtree_review = (account.dot_ns_identifier == product.product_id).then(|| { + UserConfirmationReview::ProductSubtree(ProductSubtreeReview { + product_id: product.product_id.clone(), + }) + }); + self.resolve_product_subtree( + &authority_session, + cx, + product, + account.dot_ns_identifier.clone(), + subtree_review, + ) + .await + .and_then(|subtree| Self::derive_account(subtree, &account)) + .map_err(super::account_get_authority_error) + } + + /// Derive an account beneath a subtree selected by an already authorized operation. + pub async fn product_account_public_key( + &self, + cx: &CallContext, + authority_session: &AuthoritySession, + product: &ProductContext, + product_account_id: &api::ProductAccountId, + ) -> Result<[u8; 32], AuthorityError> { + let subtree = self + .product_subtree_public_key( + authority_session, + cx, + product, + product_account_id.dot_ns_identifier.clone(), + ) + .await?; + Self::derive_account(subtree, product_account_id) + } + + fn derive_account( + subtree: [u8; 32], + product_account_id: &api::ProductAccountId, + ) -> Result<[u8; 32], AuthorityError> { + derive_product_public_key( + subtree, + derivation_index_bytes(&product_account_id.derivation_index), + ) + .map_err(|err| AuthorityError::Unknown { reason: err.to_string() }) + } + + /// Resolve a retained subtree before asking the holder to resolve it. + pub async fn product_subtree_public_key( + &self, + authority_session: &AuthoritySession, + cx: &CallContext, + product: &ProductContext, + product_id: String, + ) -> Result<[u8; 32], AuthorityError> { + self.resolve_product_subtree(authority_session, cx, product, product_id, None) + .await + } + + /// Confirm `review` only when the subtree must be asked of the account holder. + async fn resolve_product_subtree( + &self, + authority_session: &AuthoritySession, + cx: &CallContext, + product: &ProductContext, + product_id: String, + review: Option, + ) -> Result<[u8; 32], AuthorityError> { + let invocation = AccountInvocation { + call: cx, + session: authority_session, + caller: AccountCaller::Local { product }, + }; + let (session, revision) = self.grant_session(authority_session)?; + let cache_key = (GrantScope::from_session(&session), product_id.clone()); + if let Some(key) = self + .grants + .known_product_subtree(&self.session_state, &session, cache_key.clone()) + .await + { + self.holder.require_current_session(authority_session)?; + return Ok(key); + } + let resolution = self.holder.product_subtree_public_key(invocation, product_id).await?; + if let Some(review) = review && + matches!(resolution, SubtreeResolution::Ask(_)) + { + self.confirm(invocation, review).await?; + } + let cx = super::remote_authority_context(invocation.call); + super::remote_authority_call(&cx, async { + let key = resolution.resolve().await?; + self.holder.require_current_session(authority_session)?; + if !self + .grants + .persist_product_subtree_if_current( + &self.session_state, + &session, + revision, + cache_key, + key, + ) + .await + { + return Err(AuthorityError::Disconnected); + } + Ok(key) + }) + .await + } + + /// Review one allocation, then retain each issued capability for the selected wallet. + pub async fn allocate_resources( + &self, + cx: &CallContext, + authority_session: &AuthoritySession, + product: &ProductContext, + request: api::HostRequestResourceAllocationRequest, + ) -> Result { + let revision = self.hold_session(authority_session)?.revision(); + #[cfg(feature = "test-host")] + let resources = request.resources.clone(); + let count = request.resources.len(); + let invocation = AccountInvocation { + call: cx, + session: authority_session, + caller: AccountCaller::Local { product }, + }; + let review = UserConfirmationReview::ResourceAllocation(ResourceAllocationReview { + calling_product_id: product.product_id.clone(), + resources: request.resources.clone(), + }); + let grants = async { + self.confirm(invocation, review).await?; + self.holder + .allocate_grants(invocation, request, OnExistingAllowancePolicy::Increase) + .await + }; + let mut grants = match grants.await { + Ok(grants) => grants, + Err(AuthorityError::Rejected) => { + return Ok(api::HostRequestResourceAllocationResponse { + outcomes: vec![api::AllocationOutcome::Rejected; count], + }); + }, + Err(error) => return Err(error), + }; + let cx = super::remote_authority_context_with_default( + cx, + super::RESOURCE_ALLOCATION_REMOTE_AUTHORITY_RESPONSE_TIMEOUT, + ); + super::remote_authority_call(&cx, async { + self.hold_grant(authority_session, revision)?; + #[cfg(feature = "test-host")] + if self.resource_controls.grants_allowances_unchecked() { + drop(grants); + return Ok(api::HostRequestResourceAllocationResponse { + outcomes: resources + .iter() + .map(|resource| { + if self.resource_controls.withholds(resource) { + api::AllocationOutcome::Rejected + } else { + api::AllocationOutcome::Allocated + } + }) + .collect(), + }); + } + let mut outcomes = Vec::new(); + loop { + self.hold_grant(authority_session, revision)?; + let Some(outcome) = grants.next().await else { + break; + }; + self.hold_grant(authority_session, revision)?; + outcomes.push(match outcome? { + AccountGrantOutcome::Allocated(grant) => { + self.retain_grant(&cx, authority_session, revision, product, grant).await?; + api::AllocationOutcome::Allocated + }, + AccountGrantOutcome::Rejected => api::AllocationOutcome::Rejected, + AccountGrantOutcome::NotAvailable { reason } => { + if let Some(reason) = reason { + tracing::warn!(product_id = %product.product_id, %reason, "resource allocation item failed"); + } + api::AllocationOutcome::NotAvailable + }, + }); + } + Ok(api::HostRequestResourceAllocationResponse { outcomes }) + }) + .await + } + + async fn retain_grant( + &self, + cx: &CallContext, + authority_session: &AuthoritySession, + revision: u64, + product: &ProductContext, + grant: AccountGrant, + ) -> Result<(), AuthorityError> { + let (session, _) = self.grant_session(authority_session)?; + self.hold_grant(authority_session, revision)?; + match grant { + grant @ (AccountGrant::StatementStore { .. } | AccountGrant::Bulletin(_)) => { + self.grants + .retain_allowance( + &self.session_state, + &session, + revision, + &product.product_id, + &grant, + ) + .await?; + }, + AccountGrant::AutoSigning(key) => { + let expected = self + .product_subtree_public_key( + authority_session, + cx, + product, + product.product_id.clone(), + ) + .await?; + self.grants + .remember_auto_signing_key( + &self.session_state, + &session, + revision, + &product.product_id, + expected, + key, + ) + .await?; + }, + AccountGrant::SmartContract => {}, + } + self.holder.require_current_session(authority_session) + } + + /// Reuse a retained sponsorship key or request its initial issuance. + async fn statement_store_allowance_key( + &self, + cx: &CallContext, + authority_session: &AuthoritySession, + product_id: String, + ) -> Result { + let (session, revision) = self.grant_session(authority_session)?; + #[cfg(feature = "test-host")] + self.resource_controls + .refuse_withheld(&api::AllocatableResource::StatementStoreAllowance)?; + if let Some(AccountGrant::StatementStore { key, .. }) = self + .grants + .cached_allowance( + &self.session_state, + &session, + revision, + &product_id, + AllowanceResource::StatementStore, + ) + .await? + { + self.holder.require_current_session(authority_session)?; + return Ok(key); + } + let product = ProductContext::new(product_id.clone()) + .map_err(|error| AuthorityError::Unavailable { reason: error.to_string() })?; + let grant = self + .holder + .ensure_allowance( + AccountInvocation { + call: cx, + session: authority_session, + caller: AccountCaller::Local { product: &product }, + }, + AllowanceResource::StatementStore, + OnExistingAllowancePolicy::Ignore, + ) + .await?; + match grant { + AccountGrant::StatementStore { ref key, .. } => { + self.holder.require_current_session(authority_session)?; + self.grants + .retain_allowance(&self.session_state, &session, revision, &product_id, &grant) + .await?; + Ok(key.clone()) + }, + _ => Err(AuthorityError::Unknown { + reason: "Unexpected statement-store allowance response resource".to_string(), + }), + } + } + + /// Renew only statements signed by this product's retained sponsorship key. + pub async fn renew_statement_sponsorship( + &self, + cx: &CallContext, + product: &ProductContext, + signer: [u8; 32], + ) -> Result<(), AuthorityError> { + let Some(authority_session) = self.current_session() else { + return Ok(()); + }; + let (session, revision) = self.grant_session(&authority_session)?; + let grant = match self + .grants + .cached_allowance( + &self.session_state, + &session, + revision, + &product.product_id, + AllowanceResource::StatementStore, + ) + .await + { + Ok(grant) => grant, + Err(error) => { + tracing::warn!(%error, product_id = %product.product_id, "could not identify statement sponsorship"); + return Ok(()); + }, + }; + if !matches!(grant, Some(AccountGrant::StatementStore { key, .. }) if key.public_key == signer) + { + return Ok(()); + } + self.hold_grant(&authority_session, revision)?; + self.holder + .renew_statement_sponsorship( + AccountInvocation { + call: cx, + session: &authority_session, + caller: AccountCaller::Local { product }, + }, + signer, + ) + .await?; + self.hold_grant(&authority_session, revision)?; + Ok(()) + } + + /// Reuse a retained Bulletin key without contacting its issuer. + async fn bulletin_allowance_key( + &self, + cx: &CallContext, + authority_session: &AuthoritySession, + product_id: String, + ) -> Result { + let (session, revision) = self.grant_session(authority_session)?; + #[cfg(feature = "test-host")] + self.resource_controls + .refuse_withheld(&api::AllocatableResource::BulletinAllowance)?; + if let Some(AccountGrant::Bulletin(key)) = self + .grants + .cached_allowance( + &self.session_state, + &session, + revision, + &product_id, + AllowanceResource::Bulletin, + ) + .await? + { + self.holder.require_current_session(authority_session)?; + return Ok(key); + } + self.allocate_bulletin_allowance_key( + cx, + authority_session, + product_id, + OnExistingAllowancePolicy::Ignore, + ) + .await + } + + async fn allocate_bulletin_allowance_key( + &self, + cx: &CallContext, + authority_session: &AuthoritySession, + product_id: String, + policy: OnExistingAllowancePolicy, + ) -> Result { + let (session, revision) = self.grant_session(authority_session)?; + let product = ProductContext::new(product_id.clone()) + .map_err(|error| AuthorityError::Unavailable { reason: error.to_string() })?; + let grant = self + .holder + .ensure_allowance( + AccountInvocation { + call: cx, + session: authority_session, + caller: AccountCaller::Local { product: &product }, + }, + AllowanceResource::Bulletin, + policy, + ) + .await?; + match grant { + AccountGrant::Bulletin(ref key) => { + self.holder.require_current_session(authority_session)?; + self.grants + .retain_allowance(&self.session_state, &session, revision, &product_id, &grant) + .await?; + Ok(key.clone()) + }, + _ => Err(AuthorityError::Unknown { + reason: "Unexpected bulletin allowance response resource".to_string(), + }), + } + } + + /// Derive entropy while the original host selection remains locked. + pub fn derive_entropy( + &self, + authority_session: &AuthoritySession, + product_id: &str, + context: &[u8], + ) -> Result<[u8; 32], AuthorityError> { + self.holder.derive_entropy(authority_session, product_id, context) + } + + /// Contact handles under the selected account holder. + pub fn contact_handles( + &self, + authority_session: &AuthoritySession, + ) -> Result { + self.holder.contact_handles(authority_session) + } + + /// Retained grants exercised by lifecycle tests. + #[cfg(test)] + pub fn grants_for_tests(&self) -> &HostGrantStore { + &self.grants + } + + /// Seed a subtree without an account-holder exchange. + #[cfg(test)] + pub fn cache_product_subtree_for_test( + &self, + session: &SessionInfo, + product_id: &str, + public_key: [u8; 32], + ) { + self.grants.cache_product_subtree_for_test(session, product_id, public_key); + } +} + +impl HostAccounts { + async fn product_signing_grant( + &self, + authority_session: &AuthoritySession, + product: &ProductContext, + account: &api::ProductAccountId, + ) -> Result, AuthorityError> { + if product.product_id != account.dot_ns_identifier { + return Ok(None); + } + let (session, _) = self.grant_session(authority_session)?; + let grant = self.grants.auto_signing_key(&session, &account.dot_ns_identifier).await?; + self.holder.require_current_session(authority_session)?; + Ok(grant) + } + + fn grant_keypair( + grant: &AutoSigningKey, + account: &api::ProductAccountId, + ) -> Result { + derive_product_keypair_from_subtree_secret( + *grant.as_secret_bytes(), + derivation_index_bytes(&account.derivation_index), + ) + .map_err(|error| AuthorityError::Unknown { reason: error.to_string() }) + } + + /// Sign with a retained product key, or confirm with the user and ask the holder. + pub async fn sign_payload( + &self, + authority_session: &AuthoritySession, + cx: &CallContext, + product: &ProductContext, + approvals: &TemporaryPermissions, + request: SignPayloadAuthorityRequest, + ) -> Result { + let revision = self.grants.lifecycle().revision(); + let invocation = AccountInvocation { + call: cx, + session: authority_session, + caller: AccountCaller::Local { product }, + }; + if let SignPayloadAuthorityRequest::Product(payload) = &request && + let Some(grant) = + self.product_signing_grant(authority_session, product, &payload.account).await? + { + let cx = super::remote_authority_context(invocation.call); + return super::remote_authority_call(&cx, async { + let _lifecycle = self.hold_grant(authority_session, revision)?; + let keypair = Self::grant_keypair(&grant, &payload.account)?; + Ok(sign_extrinsic_payload(&keypair, payload.payload.clone())?) + }) + .await; + } + let review = request.review(invocation.caller); + match &request { + SignPayloadAuthorityRequest::Product(payload) => { + self.confirm_signature( + invocation, + product, + approvals, + CrossProductSignatureKind::Payload, + &payload.account, + review, + ) + .await? + }, + _ => self.confirm(invocation, review).await?, + } + self.holder.sign_payload(invocation, request).await + } + + /// Confirm with the user before the selected holder is asked to act. + async fn confirm( + &self, + invocation: AccountInvocation<'_>, + review: UserConfirmationReview, + ) -> Result<(), AuthorityError> { + self.holder.require_current_session(invocation.session)?; + self.consent.review(invocation.call, invocation.caller, review).await + } + + /// Confirm a signature with `account`, once per kind for this execution when + /// another product owns it. + async fn confirm_signature( + &self, + invocation: AccountInvocation<'_>, + product: &ProductContext, + approvals: &TemporaryPermissions, + kind: CrossProductSignatureKind, + account: &api::ProductAccountId, + review: UserConfirmationReview, + ) -> Result<(), AuthorityError> { + self.holder.require_current_session(invocation.session)?; + self.consent + .review_signature( + invocation.call, + product, + approvals, + kind, + &account.dot_ns_identifier, + review, + ) + .await + } + + /// Preserve legacy review even when a retained product key can sign locally. + pub async fn sign_raw( + &self, + authority_session: &AuthoritySession, + cx: &CallContext, + product: &ProductContext, + approvals: &TemporaryPermissions, + request: SignRawAuthorityRequest, + watermarked: bool, + ) -> Result { + let revision = self.grants.lifecycle().revision(); + let invocation = AccountInvocation { + call: cx, + session: authority_session, + caller: AccountCaller::Local { product }, + }; + let account = match &request { + SignRawAuthorityRequest::Product(payload) => Some(&payload.account), + SignRawAuthorityRequest::LegacyAccount { product_account, .. } => Some(product_account), + SignRawAuthorityRequest::IdentityAccount { .. } => None, + }; + let grant = if watermarked && let Some(account) = account { + self.product_signing_grant(authority_session, product, account).await? + } else { + None + }; + if grant.is_none() || !matches!(request, SignRawAuthorityRequest::Product(_)) { + let review = request.review(invocation.caller, watermarked); + match &request { + SignRawAuthorityRequest::Product(payload) if watermarked => { + self.confirm_signature( + invocation, + product, + approvals, + CrossProductSignatureKind::Raw, + &payload.account, + review, + ) + .await? + }, + _ => self.confirm(invocation, review).await?, + } + } + if let (Some(grant), Some(account)) = (grant, account) { + let payload = match &request { + SignRawAuthorityRequest::Product(request) => &request.payload, + SignRawAuthorityRequest::LegacyAccount { request, .. } | + SignRawAuthorityRequest::IdentityAccount { request, .. } => &request.payload, + }; + let message = raw_payload_bytes(payload.clone(), watermarked)?; + let cx = super::remote_authority_context(invocation.call); + return super::remote_authority_call(&cx, async { + let _lifecycle = self.hold_grant(authority_session, revision)?; + let keypair = Self::grant_keypair(&grant, account)?; + Ok(api::HostSignPayloadResponse { + signature: keypair + .secret + .sign_simple(SR25519_SIGNING_CONTEXT, &message, &keypair.public) + .to_bytes() + .to_vec(), + signed_transaction: None, + }) + }) + .await; + } + self.holder.sign_raw(invocation, request, watermarked).await + } + + /// Prepare chain metadata before using a retained key with the selected grant. + pub async fn create_transaction( + &self, + authority_session: &AuthoritySession, + cx: &CallContext, + product: &ProductContext, + approvals: &TemporaryPermissions, + request: CreateTransactionAuthorityRequest, + ) -> Result { + let revision = self.grants.lifecycle().revision(); + let invocation = AccountInvocation { + call: cx, + session: authority_session, + caller: AccountCaller::Local { product }, + }; + if let CreateTransactionAuthorityRequest::Product(payload) = &request && + let Some(grant) = + self.product_signing_grant(authority_session, product, &payload.signer).await? + { + if !payload.contacts.is_empty() { + self.confirm(invocation, request.review(invocation.caller)).await?; + } + let cx = super::remote_authority_context(invocation.call); + return super::remote_authority_call(&cx, async { + let metadata = + local_transaction_metadata(&self.services.chain, payload.genesis_hash).await?; + let _lifecycle = self.hold_grant(authority_session, revision)?; + let keypair = Self::grant_keypair(&grant, &payload.signer)?; + Ok(api::HostCreateTransactionResponse { + transaction: build_signed_transaction( + &Sr25519Signer::from_keypair(&keypair), + payload.genesis_hash, + &payload.call_data, + &payload.extensions, + payload.tx_ext_version, + metadata, + )?, + }) + }) + .await; + } + let review = request.review(invocation.caller); + match &request { + CreateTransactionAuthorityRequest::Product(payload) if payload.contacts.is_empty() => { + self.confirm_signature( + invocation, + product, + approvals, + CrossProductSignatureKind::Transaction, + &payload.signer, + review, + ) + .await? + }, + _ => self.confirm(invocation, review).await?, + } + self.holder.create_transaction(invocation, request).await + } + + /// Keep retained VRF signing restricted to this host's bound product caller. + pub async fn sign_vrf( + &self, + authority_session: &AuthoritySession, + cx: &CallContext, + product: &ProductContext, + request: api::HostAccountSignVrfRequest, + ) -> Result { + let revision = self.grants.lifecycle().revision(); + let invocation = AccountInvocation { + call: cx, + session: authority_session, + caller: AccountCaller::Local { product }, + }; + if let Some(grant) = + self.product_signing_grant(authority_session, product, &request.account).await? + { + let _lifecycle = self.hold_grant(authority_session, revision)?; + let keypair = Self::grant_keypair(&grant, &request.account)?; + let (pre_output, proof) = crate::dynamic_vrf::sign_dynamic_vrf( + &keypair, + &request.transcript_label, + request.items.iter().map(|item| (item.label.as_slice(), item.value.as_slice())), + ); + return Ok(api::VrfSignature { pre_output, proof }); + } + let review = UserConfirmationReview::SignVrf(SignVrfReview { + calling_product_id: product.product_id.clone(), + request: request.clone(), + }); + self.confirm(invocation, review).await?; + self.holder.sign_vrf(invocation, request).await + } + + /// Sign exact proof bytes, without using a raw-signing wire message as a substitute. + pub async fn sign_statement_store_product_payload( + &self, + authority_session: &AuthoritySession, + cx: &CallContext, + product: &ProductContext, + approvals: &TemporaryPermissions, + account: api::ProductAccountId, + payload: Vec, + ) -> Result<[u8; 64], AuthorityError> { + let revision = self.grants.lifecycle().revision(); + self.holder.require_current_session(authority_session)?; + let invocation = AccountInvocation { + call: cx, + session: authority_session, + caller: AccountCaller::Local { product }, + }; + if let Some(grant) = + self.product_signing_grant(authority_session, product, &account).await? + { + let _lifecycle = self.hold_grant(authority_session, revision)?; + let keypair = Self::grant_keypair(&grant, &account)?; + return Ok(keypair + .secret + .sign_simple(SR25519_SIGNING_CONTEXT, &payload, &keypair.public) + .to_bytes()); + } + let review = + UserConfirmationReview::StatementStoreProductSign(StatementStoreProductSignReview { + calling_product_id: Some(product.product_id.clone()), + account: account.clone(), + payload: payload.clone(), + }); + self.confirm_signature( + invocation, + product, + approvals, + CrossProductSignatureKind::StatementProof, + &account, + review, + ) + .await?; + self.holder + .sign_statement_store_product_payload(invocation, account, payload) + .await + } +} + +impl HostAccounts { + /// Providers in the selected wallet's registry. + pub async fn ring_vrf_providers( + &self, + ring: &api::RingLocation, + ) -> Result, RingVrfError> { + let authority_session = self.current_session().ok_or(AuthorityError::Disconnected)?; + let entries = self.ring_vrf_registry.providers(authority_session.public_key, ring).await?; + self.holder.require_current_session(&authority_session)?; + Ok(entries) + } + + /// Selected provider in the same canonical registry used by the wallet. + pub async fn selected_ring_vrf_provider( + &self, + ring: &api::RingLocation, + ) -> Result, RingVrfError> { + let authority_session = self.current_session().ok_or(AuthorityError::Disconnected)?; + let provider = self + .ring_vrf_registry + .selected_provider(authority_session.public_key, ring) + .await?; + self.holder.require_current_session(&authority_session)?; + Ok(provider) + } + + /// Prepare persistence before checking the originally selected account. + pub async fn select_ring_vrf_provider( + &self, + ring: api::RingLocation, + handle: api::ProductAccountId, + ) -> Result<(), RingVrfError> { + let authority_session = self.current_session().ok_or(AuthorityError::Disconnected)?; + let mut update = + self.ring_vrf_registry.prepare_update(authority_session.public_key).await?; + { + let _lifecycle = self.hold_session(&authority_session)?; + update.select_provider(ring, handle)?; + } + update.persist().await + } + + /// Register fixture material in the actual durable registry. + #[cfg(test)] + pub async fn register_ring_vrf_key_for_tests( + &self, + session: &SessionInfo, + handle: api::ProductAccountId, + ring: api::RingLocation, + public_key: [u8; 32], + ) -> Result<(), RingVrfError> { + self.ring_vrf_registry + .register(session.public_key, handle, ring, public_key) + .await + } + + async fn require_ring_vrf_key_access( + &self, + cx: &CallContext, + product: &ProductContext, + handle: &api::ProductAccountId, + ) -> Result<(api::ProductAccountId, super::product_manifest::AuthorizedAccess), RingVrfError> { + let access = super::until_cancelled( + cx, + super::product_manifest::ring_vrf_key_access_granted( + &self.services, + self.services.platform.as_ref(), + &product.product_id, + handle, + ), + ) + .await + .map_err(|_| RingVrfError::NotAllowlisted)??; + Ok(( + api::ProductAccountId { + dot_ns_identifier: access.owner.clone(), + derivation_index: handle.derivation_index.clone(), + }, + access, + )) + } + + async fn local_ring_vrf_entropy( + &self, + authority_session: &AuthoritySession, + handle: &api::ProductAccountId, + ring: Option<&api::RingLocation>, + ) -> Result, vrf::Vrf)>, RingVrfError> { + let (session, revision) = self.grant_session(authority_session)?; + let Some(grant) = self.grants.auto_signing_key(&session, &handle.dot_ns_identifier).await? + else { + return Ok(None); + }; + let entry = self + .ring_vrf_registry + .entry(session.public_key, handle) + .await? + .ok_or(RingVrfError::KeyNotRegistered)?; + if ring.is_some_and(|ring| !entry.rings.contains(ring)) { + return Err(RingVrfError::KeyNotInRing); + } + let vrf = vrf::load().await?; + let _lifecycle = self.hold_grant(authority_session, revision)?; + let entropy = Zeroizing::new(derive_ring_vrf_entropy_from_domain( + grant.ring_vrf_domain_entropy(), + &handle.derivation_index, + )); + if entry.public_key != Some(vrf.member(&entropy)?) { + return Err(RingVrfError::Unknown { + reason: "registered ring-VRF public key does not match the AutoSigning capability" + .to_string(), + }); + } + Ok(Some((entropy, vrf))) + } + + /// Own aliases can use retained material after validating the registered ring. + pub async fn account_alias( + &self, + authority_session: &AuthoritySession, + cx: &CallContext, + product: &ProductContext, + request: api::HostAccountGetAliasRequest, + ) -> Result { + let revision = self.grants.lifecycle().revision(); + let invocation = AccountInvocation { + call: cx, + session: authority_session, + caller: AccountCaller::Local { product }, + }; + if product.product_id == request.key_handle.dot_ns_identifier && + let Some((entropy, vrf)) = self + .local_ring_vrf_entropy( + authority_session, + &request.key_handle, + Some(&request.ring_location), + ) + .await? + { + self.ring_resolver.validate(&request.ring_location).await?; + let _lifecycle = self.hold_grant(authority_session, revision)?; + let context = context_bytes(&request.context); + return Ok(api::ContextualAlias { + alias: vrf.alias(&entropy, &context)?.to_vec(), + context, + }); + } + self.holder.account_alias(invocation, request).await + } + + /// Resolve the ring before the final guarded proof computation. + pub async fn create_proof( + &self, + authority_session: &AuthoritySession, + cx: &CallContext, + product: &ProductContext, + request: api::HostAccountCreateProofRequest, + ) -> Result { + let revision = self.grants.lifecycle().revision(); + self.holder.require_current_session(authority_session)?; + let invocation = AccountInvocation { + call: cx, + session: authority_session, + caller: AccountCaller::Local { product }, + }; + let (handle, access) = + self.require_ring_vrf_key_access(cx, product, &request.key_handle).await?; + super::remote_authority_call(cx, async { + super::product_manifest::require_context_access( + &self.services, + &access, + &request.context, + ) + .await?; + if let Some((entropy, vrf)) = self + .local_ring_vrf_entropy(authority_session, &handle, Some(&request.ring_location)) + .await? + { + let member = { + let _lifecycle = self.hold_grant(authority_session, revision)?; + vrf.member(&entropy)? + }; + let resolved = self + .ring_resolver + .resolve(&request.ring_location, &[MemberCandidate { member }]) + .await?; + let _lifecycle = self.hold_grant(authority_session, revision)?; + let context = context_bytes(&request.context); + let (proof, alias) = + create_proof(&vrf, &entropy, &resolved, &context, &request.message)?; + return Ok(api::HostAccountCreateProofResponse { + proof, + contextual_alias: api::ContextualAlias { context, alias: alias.to_vec() }, + ring_index: resolved.ring_index, + ring_revision: resolved.ring_revision, + }); + } + self.holder.create_proof(invocation, request).await + }) + .await + } + + /// Register with a kept AutoSigning key when one exists; the account holder records it either + /// way. + pub async fn register_ring_vrf_key( + &self, + authority_session: &AuthoritySession, + cx: &CallContext, + product: &ProductContext, + request: api::HostAccountRegisterRingVrfKeyRequest, + ) -> Result<[u8; 32], RingVrfError> { + let invocation = AccountInvocation { + call: cx, + session: authority_session, + caller: AccountCaller::Local { product }, + }; + let (session, revision) = self.grant_session(authority_session)?; + let Some(grant) = self.grants.auto_signing_key(&session, &product.product_id).await? else { + return self.holder.register_ring_vrf_key(invocation, request).await; + }; + self.ring_resolver.validate(&request.ring).await?; + let vrf = vrf::load().await?; + let public_key = { + let _lifecycle = self.hold_grant(authority_session, revision)?; + let entropy = Zeroizing::new(derive_ring_vrf_entropy_from_domain( + grant.ring_vrf_domain_entropy(), + &request.index, + )); + vrf.member(&entropy)? + }; + self.holder.record_ring_vrf_key(invocation, request, public_key).await?; + Ok(public_key) + } + + /// Answer an own complete listing locally; otherwise the account holder lists and keeps the + /// registry current. + pub async fn list_ring_vrf_keys( + &self, + authority_session: &AuthoritySession, + cx: &CallContext, + product: &ProductContext, + request: api::HostAccountListRingVrfKeysRequest, + ) -> Result, RingVrfError> { + self.holder.require_current_session(authority_session)?; + let invocation = AccountInvocation { + call: cx, + session: authority_session, + caller: AccountCaller::Local { product }, + }; + let owner = normalize_product_identifier(&request.owner) + .map_err(|error| RingVrfError::Unknown { reason: error.to_string() })?; + if product.product_id == owner && + let Some(mut entries) = self + .ring_vrf_registry + .complete_owner_entries(authority_session.public_key, &owner) + .await? + { + self.holder.require_current_session(authority_session)?; + apply_ring_vrf_disclosure(&mut entries, request.disclosure); + return Ok(entries); + } + let entries = self.holder.list_ring_vrf_keys(invocation, request).await?; + validate_owner_listing(&owner, &entries)?; + self.holder.require_current_session(authority_session)?; + Ok(entries) + } + + /// Sign registered-key bytes only while the retained capability remains selected. + pub async fn ring_vrf_sign( + &self, + authority_session: &AuthoritySession, + cx: &CallContext, + product: &ProductContext, + request: api::HostAccountRingVrfSignRequest, + ) -> Result, RingVrfError> { + let revision = self.grants.lifecycle().revision(); + self.holder.require_current_session(authority_session)?; + let invocation = AccountInvocation { + call: cx, + session: authority_session, + caller: AccountCaller::Local { product }, + }; + let (handle, _) = + self.require_ring_vrf_key_access(cx, product, &request.key_handle).await?; + super::remote_authority_call(cx, async { + if let Some((entropy, vrf)) = + self.local_ring_vrf_entropy(authority_session, &handle, None).await? + { + let _lifecycle = self.hold_grant(authority_session, revision)?; + return vrf.sign(&entropy, &request.message); + } + self.holder.ring_vrf_sign(invocation, request).await + }) + .await + } +} + +impl HostAccounts { + /// Check the retained allowance before preparing and signing a Bulletin submission. + pub async fn submit_preimage( + &self, + cx: &CallContext, + deadline: Instant, + authority_session: &AuthoritySession, + product_id: String, + value: &[u8], + ) -> Result, super::bulletin_rpc::BulletinSubmitError> { + use super::{ + bulletin_rpc::BulletinSubmitError, + statement_allowance::{fetch_bulletin_allowance, wait_bulletin_authorization}, + }; + use crate::host_internal::bulletin::allowance_signer; + use subxt::tx::Signer; + + let revision = self.grants.lifecycle().revision(); + let mut allowance = + self.bulletin_allowance_key(cx, authority_session, product_id.clone()).await?; + #[cfg(feature = "test-host")] + if self.submits_preimages_locally() { + self.hold_grant(authority_session, revision)?; + return Ok(crate::host_internal::bulletin::preimage_key(value).to_vec()); + } + let rpc = self + .services + .bulletin + .client("Bulletin authorization") + .await + .map_err(BulletinSubmitError::Host)?; + let rpc = super::statement_allowance::rpc::RpcClient::new(rpc); + let target = allowance_signer(&allowance) + .map_err(BulletinSubmitError::InvalidAllowanceKey)? + .account_id() + .0; + let current = fetch_bulletin_allowance(&rpc, &target) + .await + .map_err(|error| AuthorityError::Unavailable { reason: error.to_string() })?; + if !current.is_some_and(|info| info.can_store(value.len() as u64)) { + self.hold_grant(authority_session, revision)?; + allowance = self + .allocate_bulletin_allowance_key( + cx, + authority_session, + product_id, + OnExistingAllowancePolicy::Increase, + ) + .await?; + let replacement = allowance_signer(&allowance) + .map_err(BulletinSubmitError::InvalidAllowanceKey)? + .account_id() + .0; + let baseline = (replacement == target).then_some(current).flatten(); + let timeout = deadline.saturating_duration_since(Instant::now()); + wait_bulletin_authorization(&rpc, &replacement, baseline, value.len() as u64, timeout) + .await + .map_err(|error| AuthorityError::Unavailable { reason: error.to_string() })?; + } + let signer = + allowance_signer(&allowance).map_err(BulletinSubmitError::InvalidAllowanceKey)?; + self.services + .bulletin + .submit_preimage( + cx, + deadline, + &signer.account_id(), + &|bytes| { + let _grant = self.hold_grant(authority_session, revision)?; + Ok(signer.sign(bytes)) + }, + value, + ) + .await + } + + /// Acquire and sign a statement while its retained grant remains selected. + pub async fn create_authorized_statement_proof( + &self, + cx: &CallContext, + authority_session: &AuthoritySession, + product_id: String, + statement: api::Statement, + ) -> Result { + let revision = self.grants.lifecycle().revision(); + let allowance = self + .statement_store_allowance_key(cx, authority_session, product_id) + .await + .map_err(super::statement_store::statement_authority_failure)?; + let _lifecycle = self + .hold_grant(authority_session, revision) + .map_err(super::statement_store::statement_authority_failure)?; + super::statement_store::create_statement_proof_with_key(statement, &allowance) + } +} diff --git a/rust/crates/truapi/src/runtime/host_grants.rs b/rust/crates/truapi/src/runtime/host_grants.rs new file mode 100644 index 0000000000..10ff39d942 --- /dev/null +++ b/rust/crates/truapi/src/runtime/host_grants.rs @@ -0,0 +1,1221 @@ +//! Retained host capabilities and their persistence barrier. + +mod native_allowances; + +use native_allowances::NativeAllowanceDeletion; + +use super::{ + allowances::{self, AllowanceCacheKey, AllowanceResource, GrantScope}, + authority::{ + AccountGrant, AuthorityError, AutoSigningKey, BulletinAllowanceKey, GrantKeeping, + StatementStoreAllowanceKey, + }, + product_subtree, +}; +use crate::{ + host_logic::session::{SessionInfo, SessionState}, + platform::{CoreStorage, CoreStorageKey}, +}; +use futures::lock::OwnedMutexGuard; +use parity_scale_codec::{Decode, Encode}; +use schnorrkel::SecretKey; +use std::{ + collections::HashMap, + sync::{Arc, Mutex, MutexGuard}, +}; +use tracing::warn; +use zeroize::{Zeroize, Zeroizing}; + +#[derive(Clone, PartialEq, Eq, Hash, Encode, Decode)] +struct AutoSigningOwner { + root_public_key: [u8; 32], + authenticated_sso_identity: Option<[u8; 32]>, +} + +impl AutoSigningOwner { + fn from_session(session: &SessionInfo) -> Self { + Self { + root_public_key: session.public_key, + authenticated_sso_identity: session.sso.as_ref().map(|sso| sso.identity_account_id), + } + } +} + +#[derive(Encode, Decode, zeroize::ZeroizeOnDrop)] +struct PersistedAutoSigningKey { + #[zeroize(skip)] + owner: AutoSigningOwner, + #[zeroize(skip)] + product_id: String, + #[zeroize(skip)] + expected_product_subtree_public_key: [u8; 32], + secret: [u8; 64], + ring_vrf_domain_entropy: [u8; 32], +} + +type AutoSigningCacheKey = (AutoSigningOwner, String); + +fn decode_auto_signing_keys(blob: &[u8]) -> Result, AuthorityError> { + let mut input = blob; + let keys = Vec::::decode(&mut input).map_err(|_| { + AuthorityError::Unavailable { + reason: "persisted AutoSigning capabilities are invalid".to_string(), + } + })?; + if !input.is_empty() { + return Err(AuthorityError::Unavailable { + reason: "persisted AutoSigning capabilities contain trailing bytes".to_string(), + }); + } + Ok(keys) +} + +fn validate_auto_signing_key( + secret: [u8; 64], + expected_product_subtree_public_key: [u8; 32], + ring_vrf_domain_entropy: [u8; 32], +) -> Result { + let secret = Zeroizing::new(secret); + let ring_vrf_domain_entropy = Zeroizing::new(ring_vrf_domain_entropy); + let secret_key = + SecretKey::from_bytes(&secret[..]).map_err(|_| AuthorityError::Unavailable { + reason: "AutoSigning capability contains an invalid subtree secret".to_string(), + })?; + if secret_key.to_public().to_bytes() != expected_product_subtree_public_key { + return Err(AuthorityError::Unavailable { + reason: "AutoSigning capability does not match the authenticated product subtree" + .to_string(), + }); + } + Ok(AutoSigningKey::from_parts(*secret, *ring_vrf_domain_entropy)) +} + +#[derive(Clone, PartialEq, Eq)] +enum PendingDeletion { + Core(CoreStorageKey), + NativeAllowance(NativeAllowanceDeletion), +} + +#[derive(Default)] +struct GrantState { + revision: u64, + pending_deletions: Vec, +} + +impl GrantState { + fn queue_deletion(&mut self, key: PendingDeletion) { + if !self.pending_deletions.contains(&key) { + self.pending_deletions.push(key); + } + } +} + +/// Retained grants, host revision and unfinished durable revocation. +pub struct HostGrantStore { + storage: Arc, + keeping: GrantKeeping, + state: Mutex, + persistence: Arc>, + statement_store_allowances: + Mutex, StatementStoreAllowanceKey)>>, + bulletin_allowances: Mutex>, + product_subtrees: Mutex>, + auto_signing_keys: Mutex>, +} + +/// Holds the host revision through validation and synchronous key use. +pub struct HostGrantGuard<'a> { + store: &'a HostGrantStore, + state: MutexGuard<'a, GrantState>, +} + +/// Orders durable grants and session writes against revocation. +pub struct HostGrantPersistence<'a> { + store: &'a HostGrantStore, + _barrier: Barrier<'a>, +} + +/// Holds back grant writes while a session changes or its grants are revoked. +pub struct GrantBarrier { + _guard: OwnedMutexGuard<()>, +} + +enum Barrier<'a> { + Owned { _barrier: GrantBarrier }, + Held { _barrier: &'a GrantBarrier }, +} + +impl HostGrantStore { + /// Bind retained capabilities to the host's storage, kept as `keeping` says. + pub fn new(storage: Arc, keeping: GrantKeeping) -> Self { + Self { + storage, + keeping, + state: Mutex::new(GrantState::default()), + persistence: Arc::new(futures::lock::Mutex::new(())), + statement_store_allowances: Mutex::new(HashMap::new()), + bulletin_allowances: Mutex::new(HashMap::new()), + product_subtrees: Mutex::new(HashMap::new()), + auto_signing_keys: Mutex::new(HashMap::new()), + } + } + + /// Hold revision validation through a host operation. + pub fn lifecycle(&self) -> HostGrantGuard<'_> { + HostGrantGuard { + store: self, + state: self.state.lock().expect("host grant state mutex poisoned"), + } + } + + /// Serialize persistence and replacement publication. + pub async fn persistence(&self) -> HostGrantPersistence<'_> { + HostGrantPersistence { + store: self, + _barrier: Barrier::Owned { _barrier: self.barrier().await }, + } + } + + async fn allowance_persistence( + &self, + session_state: &SessionState, + session: &SessionInfo, + lifecycle_epoch: u64, + product_id: &str, + ) -> Result, AuthorityError> { + let storage = self.persistence().await; + if self.keeping == GrantKeeping::Wallet + && let Err(reason) = storage.drain_cleanup().await + && self.lifecycle().state.pending_deletions.iter().any(|deletion| { + matches!(deletion, PendingDeletion::NativeAllowance(scope) if scope.includes(session.public_key, product_id)) + }) + { + return Err(AuthorityError::Unavailable { reason }); + } + if !self.session_secret_allocation_is_current(session_state, session, lifecycle_epoch) { + return Err(AuthorityError::Disconnected); + } + Ok(storage) + } + + /// Revoke one product's grants: under every wallet a wallet's own host + /// keeps them for, locked or not, or under the current pairing. + pub async fn clear_product_grants( + &self, + session_state: &SessionState, + product_id: &str, + ) -> Result<(), String> { + match self.keeping { + GrantKeeping::Wallet => { + self.lifecycle().revoke_native_product(product_id); + let storage = self.persistence().await; + storage.begin_cleanup(); + storage.drain_cleanup().await + }, + GrantKeeping::Paired => { + let session = { + let mut lifecycle = self.lifecycle(); + lifecycle.revoke_product(product_id); + session_state.current() + }; + self.persistence().await.clear_product(session.as_ref(), product_id).await + }, + } + } + + /// Hold back grant writes across another component's session change. + pub async fn barrier(&self) -> GrantBarrier { + GrantBarrier { _guard: self.persistence.clone().lock_owned().await } + } + + /// Persist under a barrier this store already handed out. + pub fn persistence_under<'a>(&'a self, barrier: &'a GrantBarrier) -> HostGrantPersistence<'a> { + HostGrantPersistence { store: self, _barrier: Barrier::Held { _barrier: barrier } } + } + + /// Stop grant work for `previous`, revoking its durable grants when it was cleared. + pub fn session_ended(&self, previous: Option<&SessionInfo>, revoked: bool) { + let mut lifecycle = self.lifecycle(); + if revoked { + lifecycle.revoke_session(previous); + } else { + lifecycle.advance(); + } + drop(lifecycle); + self.clear_statement_store_allowance_keys(previous); + self.clear_bulletin_allowance_keys(previous); + self.clear_product_subtrees(previous); + } + + fn session_secret_allocation_is_current( + &self, + session_state: &SessionState, + session: &SessionInfo, + lifecycle_epoch: u64, + ) -> bool { + GrantScope::from_session(session).matches(session_state) && + self.lifecycle().revision() == lifecycle_epoch + } + + fn cache_auto_signing_key_if_current( + &self, + session_state: &SessionState, + session: &SessionInfo, + lifecycle_epoch: u64, + cache_key: AutoSigningCacheKey, + key: AutoSigningKey, + ) -> bool { + let lifecycle = self.lifecycle(); + if lifecycle.revision() != lifecycle_epoch { + return false; + } + if !GrantScope::from_session(session).matches(session_state) { + return false; + } + self.auto_signing_keys + .lock() + .expect("AutoSigning key cache mutex poisoned") + .insert(cache_key, key); + true + } + + /// Load a retained public subtree from memory or durable storage. + pub async fn known_product_subtree( + &self, + session_state: &SessionState, + session: &SessionInfo, + cache_key: (GrantScope, String), + ) -> Option<[u8; 32]> { + let lifecycle_epoch = self.lifecycle().revision(); + if let Some(public_key) = self + .product_subtrees + .lock() + .expect("product subtree cache mutex poisoned") + .get(&cache_key) + .copied() + { + return Some(public_key); + } + self.stored_product_subtree(session_state, session, lifecycle_epoch, cache_key) + .await + } + + /// Restore a public subtree only into the selected live session. + async fn stored_product_subtree( + &self, + session_state: &SessionState, + session: &SessionInfo, + lifecycle_epoch: u64, + cache_key: (GrantScope, String), + ) -> Option<[u8; 32]> { + if self.keeping == GrantKeeping::Wallet { + return None; + } + let public_key = match product_subtree::read_product_subtree( + &*self.storage, + session, + &cache_key.1, + ) + .await + { + Ok(public_key) => public_key?, + Err(error) => { + warn!(reason = %error, "stored product subtree read failed"); + return None; + }, + }; + // A stored key still has to belong to the live pairing before it is + // served, exactly as a freshly fetched one does. + self.cache_product_subtree_if_current( + session_state, + session, + lifecycle_epoch, + cache_key, + public_key, + ) + .then_some(public_key) + } + + /// Retain a public subtree, rolling back a stale persisted receipt. + pub async fn persist_product_subtree_if_current( + &self, + session_state: &SessionState, + session: &SessionInfo, + lifecycle_epoch: u64, + cache_key: (GrantScope, String), + public_key: [u8; 32], + ) -> bool { + let product_id = cache_key.1.clone(); + if self.keeping == GrantKeeping::Wallet { + return self.cache_product_subtree_if_current( + session_state, + session, + lifecycle_epoch, + cache_key, + public_key, + ); + } + let _storage_guard = self.persistence().await; + if !self.session_secret_allocation_is_current(session_state, session, lifecycle_epoch) { + return false; + } + let persisted = match product_subtree::write_product_subtree( + &*self.storage, + session, + &product_id, + public_key, + ) + .await + { + Ok(()) => true, + Err(error) => { + warn!(reason = %error, "product subtree persist failed"); + false + }, + }; + if self.cache_product_subtree_if_current( + session_state, + session, + lifecycle_epoch, + cache_key, + public_key, + ) { + return true; + } + if persisted { + let _ = + product_subtree::remove_product_subtree(&*self.storage, session, &product_id).await; + } + false + } + + /// Cache a public subtree only for the selected live session. + fn cache_product_subtree_if_current( + &self, + session_state: &SessionState, + session: &SessionInfo, + lifecycle_epoch: u64, + cache_key: (GrantScope, String), + public_key: [u8; 32], + ) -> bool { + let lifecycle = self.lifecycle(); + if lifecycle.revision() != lifecycle_epoch { + return false; + } + if !GrantScope::from_session(session).matches(session_state) { + return false; + } + self.product_subtrees + .lock() + .expect("product subtree cache mutex poisoned") + .insert(cache_key, public_key); + true + } + + /// Publish an allowance only after its durable write and owner checks succeed. + pub async fn retain_allowance( + &self, + session_state: &SessionState, + session: &SessionInfo, + lifecycle_epoch: u64, + product_id: &str, + allowance: &AccountGrant, + ) -> Result<(), AuthorityError> { + let (resource, secret) = match allowance { + AccountGrant::StatementStore { key, .. } => { + (AllowanceResource::StatementStore, key.as_secret_bytes()) + }, + AccountGrant::Bulletin(key) => (AllowanceResource::Bulletin, key.as_secret_bytes()), + _ => return Err(AuthorityError::Rejected), + }; + let storage = self + .allowance_persistence(session_state, session, lifecycle_epoch, product_id) + .await?; + if self.keeping == GrantKeeping::Wallet { + native_allowances::retain_native_allowance( + &storage, + session_state, + session, + lifecycle_epoch, + product_id, + allowance, + ) + .await?; + } else { + allowances::write_allowance_key( + &*self.storage, + session, + product_id, + resource, + secret.to_vec(), + ) + .await?; + } + if let Err(error) = + self.remember_allowance(session_state, session, lifecycle_epoch, product_id, allowance) + { + if self.keeping == GrantKeeping::Paired { + let _ = + allowances::remove_allowance_key(&*self.storage, session, product_id, resource) + .await; + } + return Err(error); + } + Ok(()) + } + + fn remember_allowance( + &self, + session_state: &SessionState, + session: &SessionInfo, + lifecycle_epoch: u64, + product_id: &str, + allowance: &AccountGrant, + ) -> Result<(), AuthorityError> { + let lifecycle = self.lifecycle(); + if lifecycle.revision() != lifecycle_epoch || + !GrantScope::from_session(session).matches(session_state) + { + return Err(AuthorityError::Disconnected); + } + match allowance { + AccountGrant::StatementStore { key, period } => { + self.statement_store_allowances + .lock() + .expect("statement-store allowance cache mutex poisoned") + .insert( + AllowanceCacheKey::new( + session, + product_id, + AllowanceResource::StatementStore, + ), + (*period, key.clone()), + ); + }, + AccountGrant::Bulletin(key) => { + self.bulletin_allowances + .lock() + .expect("bulletin allowance cache mutex poisoned") + .insert( + AllowanceCacheKey::new(session, product_id, AllowanceResource::Bulletin), + key.clone(), + ); + }, + _ => return Err(AuthorityError::Rejected), + } + Ok(()) + } + + /// Load a retained resource grant only into its current host session. + pub async fn cached_allowance( + &self, + session_state: &SessionState, + session: &SessionInfo, + lifecycle_epoch: u64, + product_id: &str, + resource: AllowanceResource, + ) -> Result, AuthorityError> { + let cache_key = AllowanceCacheKey::new(session, product_id, resource); + let storage = self + .allowance_persistence(session_state, session, lifecycle_epoch, product_id) + .await?; + let cached = match resource { + AllowanceResource::StatementStore => self + .statement_store_allowances + .lock() + .expect("statement-store allowance cache mutex poisoned") + .get(&cache_key) + .cloned() + .map(|(period, key)| AccountGrant::StatementStore { key, period }), + AllowanceResource::Bulletin => self + .bulletin_allowances + .lock() + .expect("bulletin allowance cache mutex poisoned") + .get(&cache_key) + .cloned() + .map(AccountGrant::Bulletin), + }; + if cached.is_some() { + return Ok(cached); + } + let allowance = if self.keeping == GrantKeeping::Wallet { + let Some(allowance) = native_allowances::native_allowance( + &storage, + session.public_key, + product_id, + resource, + ) + .await? + else { + return Ok(None); + }; + allowance + } else { + let Some(secret) = + allowances::read_allowance_key(&*self.storage, session, product_id, resource) + .await? + else { + return Ok(None); + }; + match resource { + AllowanceResource::StatementStore => AccountGrant::StatementStore { + key: StatementStoreAllowanceKey::from_secret_bytes(secret)?, + period: None, + }, + AllowanceResource::Bulletin => { + AccountGrant::Bulletin(BulletinAllowanceKey::from_secret_bytes(secret)?) + }, + } + }; + self.remember_allowance(session_state, session, lifecycle_epoch, product_id, &allowance)?; + Ok(Some(allowance)) + } + + /// Drop memory-cached statement-store allowance keys, scoped to `session` + /// when given, otherwise all. + pub fn clear_statement_store_allowance_keys(&self, session: Option<&SessionInfo>) { + let mut allowances = self + .statement_store_allowances + .lock() + .expect("statement-store allowance cache mutex poisoned"); + let Some(session) = session else { + allowances.clear(); + return; + }; + let session_key = GrantScope::from_session(session); + allowances.retain(|key, _| !key.is_for_session(session_key)); + } + + /// Drop memory-cached Bulletin allowance keys, scoped to `session` when + /// given, otherwise all. + pub fn clear_bulletin_allowance_keys(&self, session: Option<&SessionInfo>) { + let mut allowances = self + .bulletin_allowances + .lock() + .expect("bulletin allowance cache mutex poisoned"); + let Some(session) = session else { + allowances.clear(); + return; + }; + let session_key = GrantScope::from_session(session); + allowances.retain(|key, _| !key.is_for_session(session_key)); + } + + /// Validate and retain delegated signing authority for the selected session. + /// + /// A wallet's own host keeps it in memory for this activation; a paired host stores it. + pub async fn remember_auto_signing_key( + &self, + session_state: &SessionState, + session: &SessionInfo, + lifecycle_epoch: u64, + product_id: &str, + expected_product_subtree_public_key: [u8; 32], + grant: AutoSigningKey, + ) -> Result<(), AuthorityError> { + let secret = *grant.as_secret_bytes(); + let ring_vrf_domain_entropy = *grant.ring_vrf_domain_entropy(); + let key = validate_auto_signing_key( + secret, + expected_product_subtree_public_key, + ring_vrf_domain_entropy, + )?; + let owner = AutoSigningOwner::from_session(session); + let cache_key = (owner.clone(), product_id.to_string()); + if self.keeping == GrantKeeping::Wallet { + let kept = self.cache_auto_signing_key_if_current( + session_state, + session, + lifecycle_epoch, + cache_key, + key, + ); + return if kept { Ok(()) } else { Err(AuthorityError::Disconnected) }; + } + let _storage_guard = self.persistence().await; + if !self.session_secret_allocation_is_current(session_state, session, lifecycle_epoch) { + return Err(AuthorityError::Disconnected); + } + _storage_guard.clear_legacy_auto_signing_key(product_id).await?; + let mut keys = + match self.storage.read_core_storage(CoreStorageKey::AutoSigningKeys).await.map_err( + |err| AuthorityError::Unknown { + reason: format!("failed to read AutoSigning capabilities: {}", err.reason), + }, + )? { + Some(mut blob) => { + let decoded = decode_auto_signing_keys(&blob).unwrap_or_default(); + blob.zeroize(); + decoded + }, + None => Vec::new(), + }; + keys.retain(|persisted| persisted.owner == owner && persisted.product_id != product_id); + keys.push(PersistedAutoSigningKey { + owner, + product_id: product_id.to_string(), + expected_product_subtree_public_key, + secret, + ring_vrf_domain_entropy, + }); + if !self.session_secret_allocation_is_current(session_state, session, lifecycle_epoch) { + return Err(AuthorityError::Disconnected); + } + self.storage + .write_core_storage(CoreStorageKey::AutoSigningKeys, keys.encode()) + .await + .map_err(|err| AuthorityError::Unknown { + reason: format!("failed to persist AutoSigning capability: {}", err.reason), + })?; + if !self.cache_auto_signing_key_if_current( + session_state, + session, + lifecycle_epoch, + cache_key, + key, + ) { + let _ = _storage_guard.clear_auto_signing_product(product_id).await; + return Err(AuthorityError::Disconnected); + } + Ok(()) + } + + /// Load and validate retained signing authority for this owner and product. + pub async fn auto_signing_key( + &self, + session: &SessionInfo, + product_id: &str, + ) -> Result, AuthorityError> { + let owner = AutoSigningOwner::from_session(session); + let cache_key = (owner.clone(), product_id.to_string()); + if let Some(key) = self + .auto_signing_keys + .lock() + .expect("AutoSigning key cache mutex poisoned") + .get(&cache_key) + .cloned() + { + return Ok(Some(key)); + } + if self.keeping == GrantKeeping::Wallet { + return Ok(None); + } + + let _storage_guard = self.persistence().await; + if let Some(key) = self + .auto_signing_keys + .lock() + .expect("AutoSigning key cache mutex poisoned") + .get(&cache_key) + .cloned() + { + return Ok(Some(key)); + } + let legacy_present = _storage_guard.clear_legacy_auto_signing_key(product_id).await?; + let Some(mut blob) = + self.storage.read_core_storage(CoreStorageKey::AutoSigningKeys).await.map_err( + |err| AuthorityError::Unknown { + reason: format!("failed to read AutoSigning capabilities: {}", err.reason), + }, + )? + else { + return if legacy_present { + Err(AuthorityError::Unavailable { + reason: "legacy unscoped AutoSigning capability was rejected".to_string(), + }) + } else { + Ok(None) + }; + }; + let decoded = decode_auto_signing_keys(&blob); + blob.zeroize(); + let keys = match decoded { + Ok(keys) => keys, + Err(err) => { + let _ = _storage_guard.clear_auto_signing_keys().await; + return Err(err); + }, + }; + if keys.iter().any(|persisted| persisted.owner != owner) { + let _ = _storage_guard.clear_auto_signing_keys().await; + return Ok(None); + } + let Some(persisted) = keys.iter().find(|persisted| persisted.product_id == product_id) + else { + return if legacy_present { + Err(AuthorityError::Unavailable { + reason: "legacy unscoped AutoSigning capability was rejected".to_string(), + }) + } else { + Ok(None) + }; + }; + let current_expected_subtree = self + .product_subtrees + .lock() + .expect("product subtree cache mutex poisoned") + .get(&(GrantScope::from_session(session), product_id.to_string())) + .copied(); + if current_expected_subtree + .is_some_and(|expected| expected != persisted.expected_product_subtree_public_key) + { + let _ = _storage_guard.clear_auto_signing_keys().await; + return Err(AuthorityError::Unavailable { + reason: "AutoSigning capability is not for the current product subtree".to_string(), + }); + } + let key = match validate_auto_signing_key( + persisted.secret, + persisted.expected_product_subtree_public_key, + persisted.ring_vrf_domain_entropy, + ) { + Ok(key) => key, + Err(err) => { + let _ = _storage_guard.clear_auto_signing_keys().await; + return Err(err); + }, + }; + self.auto_signing_keys + .lock() + .expect("AutoSigning key cache mutex poisoned") + .insert(cache_key, key.clone()); + Ok(Some(key)) + } + + /// Evict public subtrees for one session, or all sessions. + pub fn clear_product_subtrees(&self, session: Option<&SessionInfo>) { + let mut subtrees = + self.product_subtrees.lock().expect("product subtree cache mutex poisoned"); + let Some(session) = session else { + subtrees.clear(); + return; + }; + let session_key = GrantScope::from_session(session); + subtrees.retain(|(key, _), _| *key != session_key); + } + + /// Cache sizes for lifecycle regression checks. + #[cfg(test)] + pub fn capability_cache_sizes_for_tests(&self) -> (usize, usize, usize, usize) { + ( + self.statement_store_allowances + .lock() + .expect("statement-store allowance cache mutex poisoned") + .len(), + self.bulletin_allowances + .lock() + .expect("bulletin allowance cache mutex poisoned") + .len(), + self.product_subtrees + .lock() + .expect("product subtree cache mutex poisoned") + .len(), + self.auto_signing_keys + .lock() + .expect("AutoSigning key cache mutex poisoned") + .len(), + ) + } + + /// Seed an authenticated subtree for existing runtime fixtures. + #[cfg(test)] + pub fn cache_product_subtree_for_test( + &self, + session: &SessionInfo, + product_id: &str, + public_key: [u8; 32], + ) { + self.product_subtrees + .lock() + .expect("product subtree cache mutex poisoned") + .insert((GrantScope::from_session(session), product_id.to_string()), public_key); + } +} + +impl HostGrantGuard<'_> { + /// Revoke transient grants before replacing or locking the local wallet. + pub fn clear_memory(&mut self) { + self.advance(); + self.store + .statement_store_allowances + .lock() + .expect("statement-store allowance cache mutex poisoned") + .clear(); + self.store + .bulletin_allowances + .lock() + .expect("bulletin allowance cache mutex poisoned") + .clear(); + self.store + .product_subtrees + .lock() + .expect("product subtree cache mutex poisoned") + .clear(); + self.store + .auto_signing_keys + .lock() + .expect("AutoSigning key cache mutex poisoned") + .clear(); + } + + /// Invalidate one product without touching another product's wallet permission. + pub fn revoke_product(&mut self, product_id: &str) { + self.advance(); + self.store + .statement_store_allowances + .lock() + .expect("statement-store allowance cache mutex poisoned") + .retain(|key, _| !key.is_for_product(product_id)); + self.store + .bulletin_allowances + .lock() + .expect("bulletin allowance cache mutex poisoned") + .retain(|key, _| !key.is_for_product(product_id)); + self.store + .auto_signing_keys + .lock() + .expect("AutoSigning key cache mutex poisoned") + .retain(|(_, owner), _| owner != product_id); + self.store + .product_subtrees + .lock() + .expect("product subtree cache mutex poisoned") + .retain(|(_, owner), _| owner != product_id); + } + + /// Revoke this product across native owners, including while locked. + pub fn revoke_native_product(&mut self, product_id: &str) { + self.revoke_product(product_id); + self.state.queue_deletion(PendingDeletion::NativeAllowance( + NativeAllowanceDeletion::Product(product_id.to_string()), + )); + } + + /// Delete an owner's retained grants without revoking another wallet's cache. + pub fn revoke_native_owner(&mut self, owner: [u8; 32]) { + self.advance(); + self.store + .statement_store_allowances + .lock() + .expect("statement-store allowance cache mutex poisoned") + .retain(|key, _| !key.is_for_owner(owner)); + self.store + .bulletin_allowances + .lock() + .expect("bulletin allowance cache mutex poisoned") + .retain(|key, _| !key.is_for_owner(owner)); + self.state.queue_deletion(PendingDeletion::NativeAllowance( + NativeAllowanceDeletion::Owner(owner), + )); + } + + /// Revision selected by the held guard. + pub fn revision(&self) -> u64 { + self.state.revision + } + + /// Invalidate host operations without selecting a wallet session. + pub fn advance(&mut self) -> u64 { + self.state.revision = + self.state.revision.checked_add(1).expect("session lifecycle epoch exhausted"); + self.state.revision + } + + /// Reject grant work that began before revocation. + pub fn require_revision(&self, revision: u64) -> Result<(), AuthorityError> { + if self.state.revision != revision { + return Err(AuthorityError::Disconnected); + } + Ok(()) + } + + /// Queue the old session's durable grants before its caches are detached. + pub fn revoke_session(&mut self, previous: Option<&SessionInfo>) { + self.advance(); + self.state + .queue_deletion(PendingDeletion::Core(CoreStorageKey::AutoSigningKeys)); + if let Some(sso) = previous.and_then(|session| session.sso.as_ref()) { + let session_id = allowances::session_storage_id(sso); + self.state.queue_deletion(PendingDeletion::Core(CoreStorageKey::AllowanceKeys { + session_id: session_id.clone(), + })); + let session_key = GrantScope::from_session(previous.expect("paired session exists")); + for (key, product_id) in self + .store + .product_subtrees + .lock() + .expect("product subtree cache mutex poisoned") + .keys() + { + if *key == session_key { + self.state.queue_deletion(PendingDeletion::Core( + CoreStorageKey::ProductSubtree { + session_id: session_id.clone(), + product_id: product_id.clone(), + }, + )); + } + } + } + } +} + +impl HostGrantPersistence<'_> { + /// Evict keys loaded before revocation acquired persistence. + pub fn begin_cleanup(&self) -> bool { + if !self + .store + .lifecycle() + .state + .pending_deletions + .iter() + .any(|deletion| matches!(deletion, PendingDeletion::Core(_))) + { + return false; + } + self.store.clear_statement_store_allowance_keys(None); + self.store.clear_bulletin_allowance_keys(None); + self.store.clear_product_subtrees(None); + self.store + .auto_signing_keys + .lock() + .expect("AutoSigning key cache mutex poisoned") + .clear(); + true + } + + /// Attempt all queued deletions, retaining failures for a later drain. + pub async fn drain_cleanup(&self) -> Result<(), String> { + let mut attempted: Vec = Vec::new(); + let mut first_error = None; + loop { + let next = { + let lifecycle = self.store.lifecycle(); + lifecycle + .state + .pending_deletions + .iter() + .find(|key| !attempted.contains(key)) + .cloned() + }; + let Some(key) = next else { + break; + }; + attempted.push(key.clone()); + let result = match &key { + PendingDeletion::Core(key) => { + self.store.storage.clear_core_storage(key.clone()).await + }, + PendingDeletion::NativeAllowance(deletion) => { + native_allowances::delete_native_allowances(self, deletion) + .await + .map_err(|error| crate::latest::GenericError { reason: error.to_string() }) + }, + }; + match result { + Ok(()) => { + self.store.lifecycle().state.pending_deletions.retain(|pending| *pending != key) + }, + Err(error) if first_error.is_none() => first_error = Some(error.reason), + Err(_) => {}, + } + } + match first_error { + Some(error) => Err(error), + None => Ok(()), + } + } + + /// Reconcile retained grants with the session about to be published. + pub async fn prepare_session(&self, previous: Option<&SessionInfo>, session: &SessionInfo) { + let identity_replaced = previous.is_some_and(|previous| { + AutoSigningOwner::from_session(previous) != AutoSigningOwner::from_session(session) + }); + if identity_replaced { + if let Err(reason) = self.clear_auto_signing_keys().await { + warn!(%reason, "AutoSigning capability clear failed during identity replacement"); + } + } else if previous.is_none() && + let Err(reason) = self.clear_auto_signing_keys_for_other_owner(session).await + { + warn!(%reason, "AutoSigning capability owner reconciliation failed"); + } + if let Some(previous) = previous.filter(|previous| *previous != session) { + self.store.clear_statement_store_allowance_keys(Some(previous)); + self.store.clear_bulletin_allowance_keys(Some(previous)); + if let Err(reason) = + allowances::clear_session_allowance_keys(&*self.store.storage, previous).await + { + warn!(%reason, "allowance capability clear failed during session replacement"); + } + } + } + + /// Remove one paired product's capabilities without changing session selection. + async fn clear_product( + &self, + session: Option<&SessionInfo>, + product_id: &str, + ) -> Result<(), String> { + self.store + .statement_store_allowances + .lock() + .expect("statement-store allowance cache mutex poisoned") + .retain(|key, _| !key.is_for_product(product_id)); + self.store + .bulletin_allowances + .lock() + .expect("bulletin allowance cache mutex poisoned") + .retain(|key, _| !key.is_for_product(product_id)); + self.store + .product_subtrees + .lock() + .expect("product subtree cache mutex poisoned") + .retain(|(_, cached_product_id), _| cached_product_id != product_id); + self.store + .auto_signing_keys + .lock() + .expect("AutoSigning key cache mutex poisoned") + .retain(|(_, cached_product_id), _| cached_product_id != product_id); + + let mut first_error = self.clear_auto_signing_product(product_id).await.err(); + if let Some(session) = session && + let Err(error) = allowances::clear_product_allowance_keys( + &*self.store.storage, + session, + product_id, + ) + .await && first_error.is_none() + { + first_error = Some(error.to_string()); + } + match first_error { + Some(error) => Err(error), + None => Ok(()), + } + } + /// Erase all retained signing authority. + pub async fn clear_auto_signing_keys(&self) -> Result<(), String> { + self.store + .auto_signing_keys + .lock() + .expect("AutoSigning key cache mutex poisoned") + .clear(); + self.store + .storage + .clear_core_storage(CoreStorageKey::AutoSigningKeys) + .await + .map_err(|err| err.reason) + } + + /// Erase signing authority for one product. + async fn clear_auto_signing_product(&self, product_id: &str) -> Result<(), String> { + let aggregate_result = + match self.store.storage.read_core_storage(CoreStorageKey::AutoSigningKeys).await { + Err(error) => Err(error.reason), + Ok(None) => Ok(()), + Ok(Some(mut blob)) => { + let decoded = decode_auto_signing_keys(&blob); + blob.zeroize(); + match decoded { + Err(_) => self + .store + .storage + .clear_core_storage(CoreStorageKey::AutoSigningKeys) + .await + .map_err(|error| error.reason), + Ok(mut keys) => { + let before = keys.len(); + keys.retain(|key| key.product_id != product_id); + if keys.len() == before { + Ok(()) + } else if keys.is_empty() { + self.store + .storage + .clear_core_storage(CoreStorageKey::AutoSigningKeys) + .await + .map_err(|error| error.reason) + } else { + self.store + .storage + .write_core_storage( + CoreStorageKey::AutoSigningKeys, + keys.encode(), + ) + .await + .map_err(|error| error.reason) + } + }, + } + }, + }; + let legacy_result = self + .clear_legacy_auto_signing_key(product_id) + .await + .map_err(|error| error.to_string()); + aggregate_result.and(legacy_result.map(|_| ())) + } + + /// Reject retained authority belonging to a different owner. + async fn clear_auto_signing_keys_for_other_owner( + &self, + session: &SessionInfo, + ) -> Result<(), String> { + let owner = AutoSigningOwner::from_session(session); + let Some(mut blob) = self + .store + .storage + .read_core_storage(CoreStorageKey::AutoSigningKeys) + .await + .map_err(|err| err.reason)? + else { + return Ok(()); + }; + let decoded = decode_auto_signing_keys(&blob); + blob.zeroize(); + let should_clear = decoded + .as_ref() + .map(|keys| keys.iter().any(|key| key.owner != owner)) + .unwrap_or(true); + if !should_clear { + return Ok(()); + } + self.store + .auto_signing_keys + .lock() + .expect("AutoSigning key cache mutex poisoned") + .clear(); + self.store + .storage + .clear_core_storage(CoreStorageKey::AutoSigningKeys) + .await + .map_err(|err| err.reason) + } + + async fn clear_legacy_auto_signing_key( + &self, + product_id: &str, + ) -> Result { + let storage_key = CoreStorageKey::AutoSigningKey { product_id: product_id.to_string() }; + let legacy = + self.store.storage.read_core_storage(storage_key.clone()).await.map_err(|err| { + AuthorityError::Unknown { + reason: format!("failed to inspect legacy AutoSigning key: {}", err.reason), + } + })?; + let present = if let Some(mut secret) = legacy { + secret.zeroize(); + true + } else { + false + }; + if present { + self.store.storage.clear_core_storage(storage_key).await.map_err(|err| { + AuthorityError::Unknown { + reason: format!("failed to clear legacy AutoSigning key: {}", err.reason), + } + })?; + } + Ok(present) + } +} diff --git a/rust/crates/truapi/src/runtime/host_grants/native_allowances.rs b/rust/crates/truapi/src/runtime/host_grants/native_allowances.rs new file mode 100644 index 0000000000..7e92695f1d --- /dev/null +++ b/rust/crates/truapi/src/runtime/host_grants/native_allowances.rs @@ -0,0 +1,206 @@ +use super::{ + AccountGrant, AuthorityError, BulletinAllowanceKey, CoreStorageKey, HostGrantPersistence, + StatementStoreAllowanceKey, +}; +use crate::{platform::normalize_product_identifier, runtime::allowances::AllowanceResource}; +use parity_scale_codec::DecodeAll; +use zeroize::Zeroizing; + +/// Durable cleanup independent of the currently selected wallet or stored values. +#[derive(Clone, PartialEq, Eq)] +pub enum NativeAllowanceDeletion { + /// Remove grants when a product is uninstalled. + Product(String), + /// Remove grants when a wallet is deleted. + Owner([u8; 32]), +} + +impl NativeAllowanceDeletion { + /// Whether a pending deletion prevents use of this grant. + pub fn includes(&self, owner: [u8; 32], product_id: &str) -> bool { + match self { + Self::Product(product) => product == product_id, + Self::Owner(root) => *root == owner, + } + } + + fn matches(&self, key: &CoreStorageKey) -> bool { + let CoreStorageKey::NativeAllowanceKey { root_public_key, product_id, .. } = key else { + return false; + }; + match self { + Self::Product(product) => product_id == product, + Self::Owner(owner) => root_public_key == owner, + } + } +} + +fn storage_key( + owner: [u8; 32], + product_id: &str, + resource: AllowanceResource, +) -> Result { + if normalize_product_identifier(product_id).ok().as_deref() != Some(product_id) { + return Err(AuthorityError::Unavailable { + reason: "invalid allowance product identifier".to_string(), + }); + } + Ok(CoreStorageKey::NativeAllowanceKey { + root_public_key: owner, + product_id: product_id.to_string(), + resource, + }) +} + +/// Load only the selected wallet, product and resource, without reading other grants. +pub async fn native_allowance( + storage: &HostGrantPersistence<'_>, + owner: [u8; 32], + product_id: &str, + resource: AllowanceResource, +) -> Result, AuthorityError> { + let Some(secret) = storage + .store + .storage + .read_core_storage(storage_key(owner, product_id, resource)?) + .await + .map_err(storage_error)? + else { + return Ok(None); + }; + let secret = Zeroizing::new(secret); + match resource { + AllowanceResource::StatementStore => Ok(Some(AccountGrant::StatementStore { + key: StatementStoreAllowanceKey::from_secret_bytes(secret.to_vec())?, + period: None, + })), + AllowanceResource::Bulletin => Ok(Some(AccountGrant::Bulletin( + BulletinAllowanceKey::from_secret_bytes(secret.to_vec())?, + ))), + } +} + +/// Replace one grant only after the host has validated the issuing activation. +pub async fn retain_native_allowance( + storage: &HostGrantPersistence<'_>, + session_state: &crate::host_logic::session::SessionState, + session: &crate::host_logic::session::SessionInfo, + revision: u64, + product_id: &str, + allowance: &AccountGrant, +) -> Result<(), AuthorityError> { + let (resource, secret) = match allowance { + AccountGrant::StatementStore { key, .. } => { + (AllowanceResource::StatementStore, key.as_secret_bytes()) + }, + AccountGrant::Bulletin(key) => (AllowanceResource::Bulletin, key.as_secret_bytes()), + _ => { + return Err(AuthorityError::Unavailable { + reason: "expected an allowance grant".to_string(), + }); + }, + }; + let key = storage_key(session.public_key, product_id, resource)?; + if !storage + .store + .session_secret_allocation_is_current(session_state, session, revision) + { + return Err(AuthorityError::Disconnected); + } + storage + .store + .storage + .write_core_storage(key, secret.to_vec()) + .await + .map_err(storage_error) +} + +/// Clear matching slots even when their values cannot be decoded or decrypted. +pub async fn delete_native_allowances( + storage: &HostGrantPersistence<'_>, + deletion: &NativeAllowanceDeletion, +) -> Result<(), AuthorityError> { + let keys = storage.store.storage.core_storage_keys().await.map_err(storage_error)?; + let mut first_error = None; + for encoded in keys.encoded_keys { + let Ok(key) = CoreStorageKey::decode_all(&mut encoded.as_slice()) else { + continue; + }; + if deletion.matches(&key) && + let Err(error) = storage.store.storage.clear_core_storage(key).await && + first_error.is_none() + { + first_error = Some(storage_error(error)); + } + } + first_error.map_or(Ok(()), Err) +} + +fn storage_error(error: crate::latest::GenericError) -> AuthorityError { + AuthorityError::Unavailable { reason: error.reason } +} + +#[cfg(test)] +mod tests { + use super::{super::HostGrantStore, *}; + use crate::{ + platform::CoreStorage, runtime::authority::GrantKeeping, test_support::StubPlatform, + }; + use futures::executor::block_on; + use std::sync::Arc; + + #[test] + fn corrupt_grants_are_isolated_and_removable_by_product_or_owner() { + block_on(async { + let platform = Arc::new(StubPlatform::default()); + let store = HostGrantStore::new(platform.clone(), GrantKeeping::Wallet); + let guard = store.persistence().await; + let secret = crate::host_logic::product_account::derive_sr25519_hard_path( + &[7; 16], + &["allowance", "bulletin"], + ) + .unwrap() + .secret + .to_bytes() + .to_vec(); + let broken = + storage_key([7; 32], "myapp.dot", AllowanceResource::StatementStore).unwrap(); + let other_product = + storage_key([7; 32], "other.dot", AllowanceResource::Bulletin).unwrap(); + let other_wallet = + storage_key([8; 32], "other.dot", AllowanceResource::Bulletin).unwrap(); + platform.write_core_storage(broken.clone(), vec![0xff]).await.unwrap(); + for key in [&other_product, &other_wallet] { + platform.write_core_storage(key.clone(), secret.clone()).await.unwrap(); + } + assert!( + native_allowance(&guard, [7; 32], "myapp.dot", AllowanceResource::StatementStore) + .await + .is_err() + ); + assert!(matches!( + native_allowance(&guard, [7; 32], "other.dot", AllowanceResource::Bulletin) + .await + .unwrap(), + Some(AccountGrant::Bulletin(_)) + )); + delete_native_allowances( + &guard, + &NativeAllowanceDeletion::Product("myapp.dot".to_string()), + ) + .await + .unwrap(); + assert_eq!(platform.read_core_storage(broken).await.unwrap(), None); + delete_native_allowances(&guard, &NativeAllowanceDeletion::Owner([7; 32])) + .await + .unwrap(); + assert_eq!( + ( + platform.read_core_storage(other_product).await.unwrap(), + platform.read_core_storage(other_wallet).await.unwrap() + ), + (None, Some(secret.clone())) + ); + }); + } +} diff --git a/rust/crates/truapi/src/runtime/pairing_host.rs b/rust/crates/truapi/src/runtime/pairing_host.rs index 036c5009f2..bac1fff816 100644 --- a/rust/crates/truapi/src/runtime/pairing_host.rs +++ b/rust/crates/truapi/src/runtime/pairing_host.rs @@ -1,2663 +1,66 @@ -//! Pairing role: product calls reach a wallet on a paired phone. +//! The pairing role: product calls reach a wallet on a paired phone. -mod sso_channel; - -#[cfg(test)] -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::{ - collections::HashMap, - sync::{Arc, Mutex, Weak}, -}; -use truapi::latest::{ - HostAccountCreateProofRequest, HostAccountGetAliasRequest, HostAccountListRingVrfKeysRequest, - HostAccountRegisterRingVrfKeyRequest, HostAccountRingVrfSignRequest, -}; - -use futures::channel::oneshot; -use parity_scale_codec::{Decode, Encode}; -use schnorrkel::SecretKey; -use sso_channel::SsoDisconnectMonitor; -use zeroize::Zeroize; +use std::sync::{Arc, Weak}; use super::{ - HostSession, - allowances::{self, AllowanceCacheKey, AllowanceResource}, - auth_state::AuthStateMachine, - authority::{ - AuthorityError, AuthoritySession, AutoSigningGrant, AutoSigningKey, BulletinAllowanceKey, - CreateTransactionAuthorityRequest, ProductAuthority, SignPayloadAuthorityRequest, - SignRawAuthorityRequest, StatementStoreAllowanceKey, authority_session, - require_current_session, - }, - connected_session_ui_info, - identity::resolve_session_identity_with_chain, - product_subtree, + HostAccounts, HostSession, RemoteAccountHolder, + product_consent::ProductConsent, + ring_vrf_registry::RingVrfRegistryStore, services::RuntimeServices, - sso_pairing::{SsoPairingFlow, SsoPairingOutcome}, - sso_remote::{SSO_PEER_DISCONNECT_REASON, SessionDisconnects, SsoSessionKey, sso_message_id}, - statement_store_rpc::StatementStoreRpc, -}; -use crate::{ - chain_runtime::ChainRuntime, - host_internal::{ - extrinsic::build_local_transaction, - sso_messages::{ProductRequest, RingVrfError}, - transaction::sign_extrinsic_payload, - }, - host_logic::{ - entropy::derive_product_entropy_from_source, - product_account::{ - SR25519_SIGNING_CONTEXT, derivation_index_bytes, - derive_product_keypair_from_subtree_secret, derive_ring_vrf_entropy_from_domain, - }, - raw_signing::raw_payload_bytes, - session::{SessionInfo, SessionState, encode_persisted_session}, - session_store::SessionStoreChangeNotifier, - }, - runtime::vrf, - session_usernames::SessionUsernames, - subscription::Spawner, -}; - -use crate::platform::{ - CoreStorageKey, PairingHostConfig, Platform, ProductContext, SignVrfReview, - UserConfirmationReview, normalize_product_identifier, -}; -use futures::StreamExt; -use tracing::{instrument, warn}; -use truapi::{ - CallContext, CallError, latest, - versioned::{ - IntoLatest, - account::{HostRequestLoginError, HostRequestLoginResponse}, - }, -}; -use zeroize::Zeroizing; - -use super::{ - ring_vrf_registry::{RingVrfRegistryStore, apply_ring_vrf_disclosure, validate_owner_listing}, - signing_host::ring_vrf::{ - ChainRingResolver, MemberCandidate, RingResolver, context_bytes, create_proof, - }, + sso_request_service::{PairedSessionOwner, SsoRequestService}, }; +use crate::platform::PairingHostConfig; -struct LoginInFlight { - waiters: Vec>>, -} - -struct LoginInFlightOwner<'a> { - host: &'a PairingHost, - active: bool, -} - -impl<'a> LoginInFlightOwner<'a> { - fn new(host: &'a PairingHost) -> Self { - Self { host, active: true } - } - - fn finish(&mut self, result: Result<(), String>) { - if self.active { - self.active = false; - self.host.finish_login_in_flight(result); - } - } -} - -impl Drop for LoginInFlightOwner<'_> { - fn drop(&mut self) { - if self.active { - self.host.finish_login_in_flight(Err("login request aborted".to_string())); - } - } -} - -#[derive(Clone, PartialEq, Eq, Hash, Encode, Decode)] -struct AutoSigningOwner { - root_public_key: [u8; 32], - authenticated_sso_identity: Option<[u8; 32]>, -} - -impl AutoSigningOwner { - fn from_session(session: &SessionInfo) -> Self { - Self { - root_public_key: session.public_key, - authenticated_sso_identity: session.sso.as_ref().map(|sso| sso.identity_account_id), - } - } -} - -#[derive(Encode, Decode, zeroize::ZeroizeOnDrop)] -struct PersistedAutoSigningKey { - #[zeroize(skip)] - owner: AutoSigningOwner, - #[zeroize(skip)] - product_id: String, - #[zeroize(skip)] - expected_product_subtree_public_key: [u8; 32], - secret: [u8; 64], - ring_vrf_domain_entropy: [u8; 32], -} - -type AutoSigningCacheKey = (AutoSigningOwner, String); - -fn decode_auto_signing_keys(blob: &[u8]) -> Result, AuthorityError> { - let mut input = blob; - let keys = Vec::::decode(&mut input).map_err(|_| { - AuthorityError::Unavailable { - reason: "persisted AutoSigning capabilities are invalid".to_string(), - } - })?; - if !input.is_empty() { - return Err(AuthorityError::Unavailable { - reason: "persisted AutoSigning capabilities contain trailing bytes".to_string(), - }); - } - Ok(keys) -} - -fn validate_auto_signing_key( - secret: [u8; 64], - expected_product_subtree_public_key: [u8; 32], - ring_vrf_domain_entropy: [u8; 32], -) -> Result { - let secret = Zeroizing::new(secret); - let ring_vrf_domain_entropy = Zeroizing::new(ring_vrf_domain_entropy); - let secret_key = - SecretKey::from_bytes(&secret[..]).map_err(|_| AuthorityError::Unavailable { - reason: "AutoSigning capability contains an invalid subtree secret".to_string(), - })?; - if secret_key.to_public().to_bytes() != expected_product_subtree_public_key { - return Err(AuthorityError::Unavailable { - reason: "AutoSigning capability does not match the authenticated product subtree" - .to_string(), - }); - } - Ok(AutoSigningKey::from_parts(*secret, *ring_vrf_domain_entropy)) -} - -#[derive(Default)] -struct SessionLifecycle { - epoch: u64, - external_session_active: bool, -} - -impl SessionLifecycle { - fn advance(&mut self) -> u64 { - self.epoch = self.epoch.checked_add(1).expect("session lifecycle epoch exhausted"); - self.external_session_active = false; - self.epoch - } - - fn advance_preserving_source(&mut self) -> u64 { - let external_session_active = self.external_session_active; - let epoch = self.advance(); - self.external_session_active = external_session_active; - epoch - } -} - -#[derive(Debug, derive_more::Display)] -enum StoredSessionActivationError { - #[display("stored auth session is absent")] - Missing, - #[display("invalid stored auth session: {_0}")] - Invalid(String), - #[display("failed to read stored auth session: {_0}")] - Read(String), - #[display("stored auth session changed during activation")] - Changed, -} - -/// State carried across the reconciles of one session store sync task. -#[derive(Default)] -struct SessionStoreSync { - // Storage clears can notify this subscription; clear at most once per read-error streak. - cleared_after_read_error: bool, -} - -impl SessionStoreSync { - /// Re-read the persisted auth session and reconcile the in-memory one. - async fn reconcile(&mut self, pairing_host: &PairingHost) { - self.cleared_after_read_error = matches!( - pairing_host - .reconcile_stored_session(!self.cleared_after_read_error, true) - .await, - Err(StoredSessionActivationError::Read(_)) - ); - } -} - -/// Remote account authority for a pairing host. +/// Builds a paired host's parts and serves the intents that span them. +#[derive(Clone)] pub struct PairingHost { - /// Shared runtime services. Held, not just borrowed at construction, so - /// this role can resolve a product manifest for itself when it adjudicates - /// a cross-product grant. `RuntimeServices` does not hold the pairing host - /// back (`host_core` owns both), so this is not a cycle. - services: Arc, - /// Host platform backing all syscalls. - pub platform: Arc, - /// Pairing configuration supplied by the embedding host. - pub host_config: PairingHostConfig, - /// Shared chain runtime, used to resolve session identity. - pub chain: ChainRuntime, - /// Active inter-host session with a signing host. - session_state: Arc, - session_store_changes: Arc, - /// Core-owned auth-state machine emitting to the host. - pub auth_state: AuthStateMachine, - /// People-chain statement store RPC client. - pub statement_store: StatementStoreRpc, - session_disconnects: Arc, - /// `message_id` of the request the session's request channel carries, - /// the only one a `Cancel` may name without replacing another request. - newest_request: Mutex>, - disconnect_monitor: Mutex>, - login_in_flight: Mutex>, - login_generation: Mutex, - statement_store_allowances: Mutex>, - bulletin_allowances: Mutex>, - product_subtrees: Mutex>, - auto_signing_keys: Mutex>, - ring_resolver: Arc, - ring_vrf_registry: Arc, - /// Orders session-secret cache/storage writes against teardown and activation. - session_secret_storage: futures::lock::Mutex<()>, - session_store_activation: futures::lock::Mutex<()>, - session_lifecycle: Mutex, - /// Keep preimage submissions in the core instead of sending them to the - /// Bulletin chain. A test host whose wallet answers allowances in-page - /// holds no on-chain authorization to submit with. - #[cfg(feature = "test-host")] - submit_preimages_locally: core::sync::atomic::AtomicBool, - #[cfg(test)] - external_session_activation_pause: Mutex, oneshot::Receiver<()>)>>, - /// Change notifications the sync task has finished reconciling. - #[cfg(test)] - session_store_change_ticks: AtomicUsize, - /// Self-reference captured by the spawned disconnect-monitor task. - weak_self: Weak, - /// Task spawner for background monitors. - pub spawner: Spawner, + accounts: Arc>, + session: Arc, } impl PairingHost { - /// Keep preimage submissions in the core instead of the Bulletin chain. - /// - /// For test hosts only, with the `test-host` feature enabled. - #[cfg(feature = "test-host")] - pub fn set_submit_preimages_locally(&self, local: bool) { - self.submit_preimages_locally - .store(local, core::sync::atomic::Ordering::Relaxed); - } - - /// Build a pairing host over the shared runtime services. - pub fn new(services: Arc, host_config: PairingHostConfig) -> Arc { - if services.asset_hub_chain_genesis_hash().is_none() { - // Said once at startup rather than inferred from every grant - // refusing, matching the signing role. Cross-product refusals are - // deliberately indistinguishable from a product that granted - // nothing, so a pairing host with no Asset Hub resolves no manifest - // and refuses every grant while looking exactly like one whose - // publishers granted nothing. The hash itself now arrives by - // construction, so there is nothing to install here, only to report. - tracing::warn!( - "no Asset Hub configured on the pairing role: no product manifest \ - will resolve, so every cross-product grant is refused" - ); - } - let platform = services.platform.clone(); - let auth_state = AuthStateMachine::new(platform.clone()); - Arc::new_cyclic(|weak_self| Self { - services: services.clone(), - platform, - host_config, - chain: services.chain.clone(), - session_state: SessionState::new(), - session_store_changes: SessionStoreChangeNotifier::new(), - auth_state, - statement_store: services.statement_store.clone(), - session_disconnects: Arc::new(SessionDisconnects::default()), - newest_request: Mutex::new(None), - disconnect_monitor: Mutex::new(None), - login_in_flight: Mutex::new(None), - login_generation: Mutex::new(0), - statement_store_allowances: Mutex::new(HashMap::new()), - bulletin_allowances: Mutex::new(HashMap::new()), - product_subtrees: Mutex::new(HashMap::new()), - auto_signing_keys: Mutex::new(HashMap::new()), - ring_resolver: ChainRingResolver::new(services.chain.clone()), - ring_vrf_registry: RingVrfRegistryStore::new(services.platform.clone()), - session_secret_storage: futures::lock::Mutex::new(()), - session_store_activation: futures::lock::Mutex::new(()), - session_lifecycle: Mutex::new(SessionLifecycle::default()), - #[cfg(feature = "test-host")] - submit_preimages_locally: core::sync::atomic::AtomicBool::new(false), - #[cfg(test)] - external_session_activation_pause: Mutex::new(None), - #[cfg(test)] - session_store_change_ticks: AtomicUsize::new(0), - weak_self: weak_self.clone(), - spawner: services.spawner.clone(), - }) - } - - /// Shared session holder for connection-status subscriptions. - pub fn session_state(&self) -> Arc { - self.session_state.clone() - } - - /// Signal that the persisted auth session may have changed; the sync task - /// re-reads it. - pub fn notify_session_store_changed(&self) { - self.advance_session_lifecycle(); - self.session_store_changes.notify(); - } - - fn advance_session_lifecycle(&self) -> u64 { - let mut lifecycle = - self.session_lifecycle.lock().expect("session lifecycle mutex poisoned"); - lifecycle.advance() - } - - pub fn current_session_lifecycle_epoch(&self) -> u64 { - self.session_lifecycle.lock().expect("session lifecycle mutex poisoned").epoch - } - - pub fn is_session_lifecycle_current(&self, epoch: u64) -> bool { - self.current_session_lifecycle_epoch() == epoch - } - - /// Test hook for [`Self::start_session_store_sync`]. - #[cfg(test)] - pub fn start_session_store_sync_for_tests(self: Arc, spawner: Spawner) { - self.start_session_store_sync(spawner); - } - - /// `message_id` of the request the session's request channel carries. - #[cfg(test)] - pub fn newest_request_for_tests(&self) -> Option { - self.newest_request.lock().expect("newest request mutex poisoned").clone() - } - - /// Change notifications the sync task has finished reconciling. - #[cfg(test)] - pub fn session_store_change_ticks_for_tests(&self) -> usize { - self.session_store_change_ticks.load(Ordering::SeqCst) - } - - /// Test alias for [`Self::start_remote_monitor_for_current_session`]. - #[cfg(test)] - pub fn start_session_supervision_for_current_session(&self) { - self.start_remote_monitor_for_current_session(); - } - - /// Pause replacement before the guarded installation for race tests. - #[cfg(test)] - pub fn pause_external_session_activation_for_tests( - &self, - ) -> (oneshot::Receiver<()>, oneshot::Sender<()>) { - let (entered_tx, entered_rx) = oneshot::channel(); - let (resume_tx, resume_rx) = oneshot::channel(); - *self - .external_session_activation_pause - .lock() - .expect("external activation pause mutex poisoned") = Some((entered_tx, resume_rx)); - (entered_rx, resume_tx) - } - - #[cfg(test)] - async fn wait_at_external_session_activation_pause(&self) { - let pause = self - .external_session_activation_pause - .lock() - .expect("external activation pause mutex poisoned") - .take(); - if let Some((entered, resume)) = pause { - let _ = entered.send(()); - let _ = resume.await; - } - } - - fn current_session(&self) -> Option { - self.session_state.current().as_ref().map(authority_session) - } - - /// Providers registered for this ring under the active account. - pub async fn ring_vrf_providers( - &self, - ring: &latest::RingLocation, - ) -> Result, RingVrfError> { - let session = self - .session_state - .current() - .ok_or(RingVrfError::Unknown { reason: "no active session".to_string() })?; - self.ring_vrf_registry.providers(session.public_key, ring).await - } - - /// Provider selected for this ring under the active account. - pub async fn selected_ring_vrf_provider( - &self, - ring: &latest::RingLocation, - ) -> Result, RingVrfError> { - let session = self - .session_state - .current() - .ok_or(RingVrfError::Unknown { reason: "no active session".to_string() })?; - self.ring_vrf_registry.selected_provider(session.public_key, ring).await - } - - /// Persist the provider choice under the active account. - pub async fn select_ring_vrf_provider( - &self, - ring: latest::RingLocation, - handle: latest::ProductAccountId, - ) -> Result<(), RingVrfError> { - let session = self - .session_state - .current() - .ok_or(RingVrfError::Unknown { reason: "no active session".to_string() })?; - self.ring_vrf_registry.select_provider(session.public_key, ring, handle).await - } - - /// Start the disconnect monitor when a session is already active. - #[cfg(test)] - pub fn start_remote_monitor_for_current_session(&self) { - if let Some(session) = self.session_state.current() { - self.start_disconnect_monitor(&session); - } - } - - /// Validate, resolve, and install an externally persisted canonical - /// session blob without copying it into core storage. Reports the resulting - /// auth state to the host, including when the blob failed to decode and the - /// active session was therefore left alone. - pub async fn activate_external_session(&self, blob: &[u8]) -> Result<(), String> { - let installed = self.install_external_session(blob).await; - self.auth_state.announce_current(); - installed - } - - async fn install_external_session(&self, blob: &[u8]) -> Result<(), String> { - let _activation = self.session_store_activation.lock().await; - let session = crate::host_logic::session::decode_persisted_session(blob)?; - let activation_epoch = self.advance_session_lifecycle(); - let resolved = resolve_session_identity_with_chain( - &self.chain, - self.host_config.asset_hub_chain_genesis_hash, - session, - ) - .await; - #[cfg(test)] - self.wait_at_external_session_activation_pause().await; - self.set_connected_session_if_current(resolved, activation_epoch, true).await; - Ok(()) - } - - /// Restore the persisted session and report the resulting connection state. - pub async fn activate_stored_session(&self) -> Result<(), String> { - let restored = self - .reconcile_stored_session(true, false) - .await - .map_err(|error| error.to_string()); - self.auth_state.announce_current(); - restored - } - - async fn reconcile_stored_session( - &self, - clear_after_read_error: bool, - preserve_external_session: bool, - ) -> Result<(), StoredSessionActivationError> { - let _activation = self.session_store_activation.lock().await; - if preserve_external_session && - self.session_lifecycle - .lock() - .expect("session lifecycle mutex poisoned") - .external_session_active - { - return Ok(()); - } - let blob = match self.platform.read_core_storage(CoreStorageKey::AuthSession).await { - Ok(Some(blob)) => blob, - Ok(None) => { - self.clear_disconnected_session(false).await; - return Err(StoredSessionActivationError::Missing); - }, - Err(error) => { - self.clear_disconnected_session(false).await; - if clear_after_read_error { - let _ = self.platform.clear_core_storage(CoreStorageKey::AuthSession).await; - } - return Err(StoredSessionActivationError::Read(error.reason)); - }, - }; - let session = match crate::host_logic::session::decode_persisted_session(&blob) { - Ok(session) => session, - Err(error) => { - self.clear_disconnected_session(true).await; - return Err(StoredSessionActivationError::Invalid(error)); - }, - }; - let resolved = resolve_session_identity_with_chain( - &self.chain, - self.host_config.asset_hub_chain_genesis_hash, - session, - ) - .await; - - // Identity resolution can await chain I/O. Re-read the slot before - // installation so an older activation cannot overwrite or expose a - // session replaced while that lookup was in flight. - let latest = match self.platform.read_core_storage(CoreStorageKey::AuthSession).await { - Ok(latest) => latest, - Err(error) => { - self.clear_disconnected_session(false).await; - if clear_after_read_error { - let _ = self.platform.clear_core_storage(CoreStorageKey::AuthSession).await; - } - return Err(StoredSessionActivationError::Read(error.reason)); - }, - }; - if latest.as_deref() != Some(blob.as_slice()) { - self.clear_disconnected_session(false).await; - return Err(StoredSessionActivationError::Changed); - } - - let resolved_blob = encode_persisted_session(&resolved); - if resolved_blob != blob { - let _ = self - .platform - .write_core_storage(CoreStorageKey::AuthSession, resolved_blob) - .await; - } - self.set_connected_session(resolved).await; - Ok(()) - } - - /// Reconcile at boot and after storage notifications, reporting auth state. - #[instrument(skip_all, fields(runtime.method = "session_store.sync"))] - pub fn start_session_store_sync(self: Arc, spawner: Spawner) { - let pairing_host = Arc::downgrade(&self); - drop(self); - spawner(Box::pin(async move { - let Some(booting) = pairing_host.upgrade() else { - return; - }; - let mut ticks = booting.session_store_changes.subscribe(); - let mut sync = SessionStoreSync::default(); - sync.reconcile(&booting).await; - booting.auth_state.announce_current(); - drop(booting); - while ticks.next().await.is_some() { - let Some(pairing_host) = pairing_host.upgrade() else { - break; - }; - sync.reconcile(&pairing_host).await; - #[cfg(test)] - pairing_host.session_store_change_ticks.fetch_add(1, Ordering::SeqCst); - } - })); - } - - #[instrument(skip_all, fields(runtime.method = "account.request_login", product = %product.product_id))] - async fn request_login( - &self, - product: &ProductContext, - ) -> Result> { - let _ = product; - if let Some(session) = self.session_state.current() { - self.auth_state.connected(&connected_session_ui_info(&session)); - return Ok(HostRequestLoginResponse::V1( - latest::HostRequestLoginResponse::AlreadyConnected, - )); - } - - if let Some(waiter) = self.login_waiter() { - match waiter.await { - Ok(Ok(())) => { - return Ok(HostRequestLoginResponse::V1( - if self.session_state.current().is_some() { - latest::HostRequestLoginResponse::AlreadyConnected - } else { - latest::HostRequestLoginResponse::Rejected - }, - )); - }, - Ok(Err(reason)) => { - return Err(CallError::Domain(HostRequestLoginError::V1( - latest::HostRequestLoginError::Unknown { reason }, - ))); - }, - Err(_) => { - return Err(CallError::Domain(HostRequestLoginError::V1( - latest::HostRequestLoginError::Unknown { - reason: "login waiter dropped".to_string(), - }, - ))); - }, - } - } - - let mut login_owner = LoginInFlightOwner::new(self); - let login_generation = self.begin_login_attempt(); - let outcome = match SsoPairingFlow::new(self).request_session().await { - Ok(outcome) => outcome, - Err(err) => { - login_owner.finish(Err(login_error_reason(&err))); - return Err(err); - }, - }; - match outcome { - SsoPairingOutcome::Cancelled => { - login_owner.finish(Ok(())); - if self.session_state.current().is_some() { - Ok(HostRequestLoginResponse::V1( - latest::HostRequestLoginResponse::AlreadyConnected, - )) - } else { - Ok(HostRequestLoginResponse::V1(latest::HostRequestLoginResponse::Rejected)) - } - }, - SsoPairingOutcome::Success(session) => { - if !self.is_current_login_attempt(login_generation) { - let _ = self.platform.clear_core_storage(CoreStorageKey::AuthSession).await; - login_owner.finish(Ok(())); - return Ok(HostRequestLoginResponse::V1( - latest::HostRequestLoginResponse::Rejected, - )); - } - self.set_connected_session(*session).await; - login_owner.finish(Ok(())); - Ok(HostRequestLoginResponse::V1(latest::HostRequestLoginResponse::Success)) - }, - } - } - - #[instrument(skip_all, fields(runtime.method = "account.disconnect"))] - async fn disconnect(&self) { - self.cancel_login(); - let session = self.session_state.current(); - self.clear_disconnected_session(true).await; - if let Some(session) = session { - let weak_self = self.weak_self.clone(); - (self.spawner)(Box::pin(async move { - if let Some(host) = weak_self.upgrade() { - let _ = host.submit_disconnected_message(&session).await; - } - })); - } - } - - /// Disconnect and discard pairing bootstrap material so the next login - /// generates a new device keypair and topic. - pub async fn logout_and_reset_pairing(&self) -> Result<(), String> { - self.disconnect().await; - self.clear_auto_signing_keys().await.map_err(|reason| { - format!("session disconnected, but AutoSigning reset failed: {reason}") - })?; - self.platform - .clear_core_storage(CoreStorageKey::PairingDeviceIdentity) - .await - .map_err(|error| { - format!("session disconnected, but pairing identity reset failed: {}", error.reason) - })?; - self.platform - .clear_core_storage(CoreStorageKey::LastProcessedPairingStatement) - .await - .map_err(|error| { - format!( - "session disconnected and pairing identity reset, but pairing history reset failed: {}", - error.reason - ) - }) - } - - /// Clear all capability material owned by one product while preserving the - /// active session and unrelated products. - pub async fn clear_product_state(&self, product_id: &str) -> Result<(), String> { - let product_id = - normalize_product_identifier(product_id).map_err(|error| error.to_string())?; - let session = { - let mut lifecycle = - self.session_lifecycle.lock().expect("session lifecycle mutex poisoned"); - lifecycle.advance_preserving_source(); - self.session_state.current() - }; - let _storage_guard = self.session_secret_storage.lock().await; - - self.statement_store_allowances - .lock() - .expect("statement-store allowance cache mutex poisoned") - .retain(|key, _| !key.is_for_product(&product_id)); - self.bulletin_allowances - .lock() - .expect("bulletin allowance cache mutex poisoned") - .retain(|key, _| !key.is_for_product(&product_id)); - self.product_subtrees - .lock() - .expect("product subtree cache mutex poisoned") - .retain(|(_, cached_product_id), _| cached_product_id != &product_id); - self.auto_signing_keys - .lock() - .expect("AutoSigning key cache mutex poisoned") - .retain(|(_, cached_product_id), _| cached_product_id != &product_id); - - let mut first_error = - self.clear_auto_signing_product_under_storage_guard(&product_id).await.err(); - if let Some(session) = session.as_ref() && - let Err(error) = - allowances::clear_product_allowance_keys(&*self.platform, session, &product_id) - .await && first_error.is_none() - { - first_error = Some(error.to_string()); - } - match first_error { - Some(error) => Err(error), - None => Ok(()), - } - } - - /// Clear the canonical local session and all session capabilities without - /// sending a peer-disconnect statement. Reports the resulting auth state to - /// the host, including when there was no session to clear. - pub async fn reset_session_state(&self) { - self.cancel_login(); - self.clear_disconnected_session(true).await; - let _storage_guard = self.session_secret_storage.lock().await; - self.clear_statement_store_allowance_keys(None); - self.clear_bulletin_allowance_keys(None); - self.clear_product_subtrees(None); - self.auth_state.announce_current(); - } - - /// Invalidate in-flight login attempts and emit the cancelled auth state. - #[instrument(skip_all, fields(runtime.method = "account.cancel_login"))] - pub fn cancel_login(&self) { - self.invalidate_login_attempts(); - self.auth_state.login_cancelled(); - } - - fn begin_login_attempt(&self) -> u64 { - let mut generation = self.login_generation.lock().expect("login generation mutex poisoned"); - *generation = generation.wrapping_add(1); - *generation - } - - fn invalidate_login_attempts(&self) { - let mut generation = self.login_generation.lock().expect("login generation mutex poisoned"); - *generation = generation.wrapping_add(1); - } - - fn is_current_login_attempt(&self, generation: u64) -> bool { - *self.login_generation.lock().expect("login generation mutex poisoned") == generation - } - - fn login_waiter(&self) -> Option>> { - let mut in_flight = self.login_in_flight.lock().expect("login in-flight mutex poisoned"); - if let Some(in_flight) = in_flight.as_mut() { - let (tx, rx) = oneshot::channel(); - in_flight.waiters.push(tx); - Some(rx) - } else { - *in_flight = Some(LoginInFlight { waiters: Vec::new() }); - None - } - } - - fn finish_login_in_flight(&self, result: Result<(), String>) { - let waiters = self - .login_in_flight - .lock() - .expect("login in-flight mutex poisoned") - .take() - .map(|in_flight| in_flight.waiters) - .unwrap_or_default(); - for waiter in waiters { - let _ = waiter.send(result.clone()); - } - } - - #[instrument(skip_all, fields(runtime.method = "session_store.clear_disconnected"))] - async fn clear_disconnected_session(&self, clear_auth_session: bool) { - let previous = { - let mut lifecycle = - self.session_lifecycle.lock().expect("session lifecycle mutex poisoned"); - lifecycle.advance(); - let previous = self.session_state.current(); - self.session_state.clear_session(); - previous - }; - self.stop_session_channel(previous.as_ref()); - if clear_auth_session { - let _ = self.platform.clear_core_storage(CoreStorageKey::AuthSession).await; - } - let _storage_guard = self.session_secret_storage.lock().await; - if let Some(session) = previous.as_ref() { - self.clear_statement_store_allowance_keys(Some(session)); - self.clear_bulletin_allowance_keys(Some(session)); - if let Err(reason) = - allowances::clear_session_allowance_keys(&*self.platform, session).await - { - warn!(%reason, "allowance capability clear failed during disconnect"); - } - self.clear_stored_product_subtrees(session).await; - } - self.clear_product_subtrees(previous.as_ref()); - if let Err(reason) = self.clear_auto_signing_keys_under_storage_guard().await { - warn!(%reason, "AutoSigning capability clear failed during disconnect"); - } - self.auth_state.store_disconnected(); - } - - async fn set_connected_session(&self, session: SessionInfo) { - let activation_epoch = self.advance_session_lifecycle(); - self.set_connected_session_if_current(session, activation_epoch, false).await; - } - - async fn set_connected_session_if_current( - &self, - session: SessionInfo, - activation_epoch: u64, - external_session: bool, - ) -> bool { - if !self.is_session_lifecycle_current(activation_epoch) { - return false; - } - let previous = self.session_state.current(); - let identity_replaced = previous.as_ref().is_some_and(|previous| { - AutoSigningOwner::from_session(previous) != AutoSigningOwner::from_session(&session) - }); - if identity_replaced { - if let Err(reason) = self.clear_auto_signing_keys().await { - warn!(%reason, "AutoSigning capability clear failed during identity replacement"); - } - } else if previous.is_none() { - if let Err(reason) = self.clear_auto_signing_keys_for_other_owner(&session).await { - warn!(%reason, "AutoSigning capability owner reconciliation failed"); - } - } else { - let _storage_guard = self.session_secret_storage.lock().await; - } - if let Some(previous) = previous.as_ref().filter(|previous| *previous != &session) { - let _storage_guard = self.session_secret_storage.lock().await; - self.clear_statement_store_allowance_keys(Some(previous)); - self.clear_bulletin_allowance_keys(Some(previous)); - if let Err(reason) = - allowances::clear_session_allowance_keys(&*self.platform, previous).await - { - warn!(%reason, "allowance capability clear failed during session replacement"); - } - } - let previous = { - let mut lifecycle = - self.session_lifecycle.lock().expect("session lifecycle mutex poisoned"); - if lifecycle.epoch != activation_epoch { - return false; - } - let previous = self.session_state.current(); - self.session_state.set_session(session.clone()); - lifecycle.external_session_active = external_session; - previous - }; - if previous.as_ref() != Some(&session) { - self.stop_session_channel(previous.as_ref()); - } - self.start_disconnect_monitor(&session); - vrf::prefetch(&self.spawner); - self.auth_state.connected(&connected_session_ui_info(&session)); - true - } - - /// Install a selected session through the real lifecycle. - #[cfg(test)] - pub async fn set_connected_session_for_tests(&self, session: SessionInfo) { - self.set_connected_session(session).await; - } - - #[cfg(test)] - pub async fn has_auto_signing_key_for_tests( - &self, - session: &SessionInfo, - product_id: &str, - ) -> Result { - self.auto_signing_key(session, product_id).await.map(|key| key.is_some()) - } - - #[cfg(test)] - pub async fn remember_auto_signing_key_for_tests( - &self, - session: &SessionInfo, - lifecycle_epoch: u64, - product_id: &str, - expected_product_subtree_public_key: [u8; 32], - secret: [u8; 64], - ring_vrf_domain_entropy: [u8; 32], - ) -> Result<(), AuthorityError> { - self.remember_auto_signing_key( - session, - lifecycle_epoch, - product_id, - expected_product_subtree_public_key, - secret, - ring_vrf_domain_entropy, - ) - .await - } - - /// Register fixture material in the actual durable registry. - #[cfg(test)] - pub async fn register_ring_vrf_key_for_tests( - &self, - session: &SessionInfo, - handle: latest::ProductAccountId, - ring: latest::RingLocation, - public_key: [u8; 32], - ) -> Result<(), RingVrfError> { - self.ring_vrf_registry - .register(session.public_key, handle, ring, public_key) - .await - } - - /// Cache sizes for lifecycle regression checks. - #[cfg(test)] - pub fn capability_cache_sizes_for_tests(&self) -> (usize, usize, usize, usize) { - ( - self.statement_store_allowances - .lock() - .expect("statement-store allowance cache mutex poisoned") - .len(), - self.bulletin_allowances - .lock() - .expect("bulletin allowance cache mutex poisoned") - .len(), - self.product_subtrees - .lock() - .expect("product subtree cache mutex poisoned") - .len(), - self.auto_signing_keys - .lock() - .expect("AutoSigning key cache mutex poisoned") - .len(), - ) - } - - /// Single funnel for peer-initiated disconnects. Every detection source - /// (monitor task, request-path error) must route here: it wakes in-flight - /// waiters for `key`, then clears the session when `key` is still current, - /// so stale notifications for replaced sessions only wake their own - /// waiters. - async fn handle_signing_host_disconnected(&self, key: SsoSessionKey) { - self.session_disconnects.notify_key(key, SSO_PEER_DISCONNECT_REASON); - if !self.current_sso_session_matches(key) { - return; - } - - self.clear_disconnected_session(true).await; - } - - fn current_sso_session_matches(&self, key: SsoSessionKey) -> bool { - sso_channel::session_matches_key(&self.session_state, key) - } - - fn session_secret_allocation_is_current( - &self, - session: &SessionInfo, - lifecycle_epoch: u64, - ) -> bool { - session.sso.as_ref().is_some_and(|sso| { - self.current_sso_session_matches(SsoSessionKey::from_session(sso)) && - self.is_session_lifecycle_current(lifecycle_epoch) - }) - } - - fn cache_auto_signing_key_if_current( - &self, - session: &SessionInfo, - lifecycle_epoch: u64, - cache_key: AutoSigningCacheKey, - key: AutoSigningKey, - ) -> bool { - let lifecycle = self.session_lifecycle.lock().expect("session lifecycle mutex poisoned"); - if lifecycle.epoch != lifecycle_epoch { - return false; - } - let Some(sso) = session.sso.as_ref() else { - return false; - }; - if !self.current_sso_session_matches(SsoSessionKey::from_session(sso)) { - return false; - } - self.auto_signing_keys - .lock() - .expect("AutoSigning key cache mutex poisoned") - .insert(cache_key, key); - true - } - - /// Whether resolving `product_id`'s subtree would reach the Account Holder, - /// i.e. neither the memory cache nor the persisted slot already holds it. - /// - /// A stale or missing session resolves as `false` so a broken state falls - /// through to the resolution's own error rather than a spurious prompt. - pub async fn subtree_reaches_account_holder( - &self, - session: &AuthoritySession, - product_id: &str, - ) -> bool { - let Ok(session) = self.current_private_session(session) else { - return false; - }; - let Some(sso) = session.sso.as_ref() else { - return false; - }; - let cache_key = (SsoSessionKey::from_session(sso), product_id.to_string()); - self.known_product_subtree(&session, cache_key).await.is_none() - } - - /// Load a retained public subtree without contacting the account holder. - pub async fn known_product_subtree( - &self, - session: &SessionInfo, - cache_key: (SsoSessionKey, String), - ) -> Option<[u8; 32]> { - let lifecycle_epoch = self.current_session_lifecycle_epoch(); - if let Some(public_key) = self - .product_subtrees - .lock() - .expect("product subtree cache mutex poisoned") - .get(&cache_key) - .copied() - { - return Some(public_key); - } - self.stored_product_subtree(session, lifecycle_epoch, cache_key).await - } - - /// Restore a public subtree only into the selected live session. - pub async fn stored_product_subtree( - &self, - session: &SessionInfo, - lifecycle_epoch: u64, - cache_key: (SsoSessionKey, String), - ) -> Option<[u8; 32]> { - let public_key = - match product_subtree::read_product_subtree(&*self.platform, session, &cache_key.1) - .await - { - Ok(public_key) => public_key?, - Err(error) => { - warn!(reason = %error, "stored product subtree read failed"); - return None; - }, - }; - // A stored key still has to belong to the live pairing before it is - // served, exactly as a freshly fetched one does. - self.cache_product_subtree_if_current(session, lifecycle_epoch, cache_key, public_key) - .then_some(public_key) - } - - /// Persist and memory-cache a freshly fetched product subtree public key. - /// - /// Persistence is an optimisation: a storage failure still serves the key - /// and leaves the next launch to re-ask the wallet, which is what happens - /// today. Losing the pairing mid-write is not, so the storage entry is - /// rolled back rather than left addressing a session that has gone. - pub async fn persist_product_subtree_if_current( - &self, - session: &SessionInfo, - lifecycle_epoch: u64, - cache_key: (SsoSessionKey, String), - public_key: [u8; 32], - ) -> bool { - let product_id = cache_key.1.clone(); - let _storage_guard = self.session_secret_storage.lock().await; - if !self.session_secret_allocation_is_current(session, lifecycle_epoch) { - return false; - } - let persisted = match product_subtree::write_product_subtree( - &*self.platform, - session, - &product_id, - public_key, - ) - .await - { - Ok(()) => true, - Err(error) => { - warn!(reason = %error, "product subtree persist failed"); - false - }, - }; - if self.cache_product_subtree_if_current(session, lifecycle_epoch, cache_key, public_key) { - return true; - } - if persisted { - let _ = product_subtree::remove_product_subtree(&*self.platform, session, &product_id) - .await; - } - false - } - - /// Cache a public subtree only for the selected live session. - pub fn cache_product_subtree_if_current( - &self, - session: &SessionInfo, - lifecycle_epoch: u64, - cache_key: (SsoSessionKey, String), - public_key: [u8; 32], - ) -> bool { - let lifecycle = self.session_lifecycle.lock().expect("session lifecycle mutex poisoned"); - if lifecycle.epoch != lifecycle_epoch { - return false; - } - let Some(sso) = session.sso.as_ref() else { - return false; - }; - if !self.current_sso_session_matches(SsoSessionKey::from_session(sso)) { - return false; - } - self.product_subtrees - .lock() - .expect("product subtree cache mutex poisoned") - .insert(cache_key, public_key); - true - } - - fn current_private_session( - &self, - session: &AuthoritySession, - ) -> Result { - require_current_session(&self.session_state, session) - } - - async fn refresh_current_session_identity(&self) -> Option { - let current = self.session_state.current()?; - if current.has_username() || self.host_config.asset_hub_chain_genesis_hash == [0; 32] { - return Some(authority_session(¤t)); - } - - let resolved = resolve_session_identity_with_chain( - &self.chain, - self.host_config.asset_hub_chain_genesis_hash, - current.clone(), - ) - .await; - if !resolved.has_username() || resolved == current { - return self.current_session(); - } - - if !self.session_state.replace_session_if_current(¤t, resolved.clone()) { - return self.current_session(); - } - self.auth_state.connected(&connected_session_ui_info(&resolved)); - - if let Err(err) = self - .platform - .write_core_storage(CoreStorageKey::AuthSession, encode_persisted_session(&resolved)) - .await - { - warn!(reason = %err.reason, "refreshed session identity persist failed"); - } - - match self.session_state.current() { - Some(live) if live != resolved => { - if let Err(err) = self - .platform - .write_core_storage( - CoreStorageKey::AuthSession, - encode_persisted_session(&live), - ) - .await - { - warn!(reason = %err.reason, "live session identity persist repair failed"); - } - Some(authority_session(&live)) - }, - None => { - if let Err(err) = - self.platform.clear_core_storage(CoreStorageKey::AuthSession).await - { - warn!(reason = %err.reason, "cleared session identity persist repair failed"); - } - None - }, - _ => Some(authority_session(&resolved)), - } - } - - /// Persist and memory-cache a freshly allocated statement-store allowance - /// key. - pub async fn cache_statement_store_allowance_key( - &self, - session: &SessionInfo, - lifecycle_epoch: u64, - product_id: &str, - slot_account_key: Vec, - ) -> Result { - let allowance = StatementStoreAllowanceKey::from_secret_bytes(slot_account_key)?; - let _storage_guard = self.session_secret_storage.lock().await; - if !self.session_secret_allocation_is_current(session, lifecycle_epoch) { - return Err(AuthorityError::Disconnected); - } - allowances::write_allowance_key( - &*self.platform, - session, - product_id, - AllowanceResource::StatementStore, - allowance.secret.to_vec(), - ) - .await?; - if let Err(error) = self.remember_statement_store_allowance_key( - session, - lifecycle_epoch, - product_id, - allowance.clone(), - ) { - let _ = allowances::remove_allowance_key( - &*self.platform, - session, - product_id, - AllowanceResource::StatementStore, - ) - .await; - return Err(error); - } - Ok(allowance) - } - - fn remember_statement_store_allowance_key( - &self, - session: &SessionInfo, - lifecycle_epoch: u64, - product_id: &str, - allowance: StatementStoreAllowanceKey, - ) -> Result<(), AuthorityError> { - let cache_key = - AllowanceCacheKey::new(session, product_id, AllowanceResource::StatementStore)?; - let lifecycle = self.session_lifecycle.lock().expect("session lifecycle mutex poisoned"); - if lifecycle.epoch != lifecycle_epoch || - !session.sso.as_ref().is_some_and(|sso| { - self.current_sso_session_matches(SsoSessionKey::from_session(sso)) - }) { - return Err(AuthorityError::Disconnected); - } - self.statement_store_allowances - .lock() - .expect("statement-store allowance cache mutex poisoned") - .insert(cache_key, allowance); - Ok(()) - } - - /// Cached statement-store allowance key for the product, falling back to - /// persisted storage. - pub async fn cached_statement_store_allowance_key( - &self, - session: &SessionInfo, - lifecycle_epoch: u64, - product_id: &str, - ) -> Result, AuthorityError> { - let cache_key = - AllowanceCacheKey::new(session, product_id, AllowanceResource::StatementStore)?; - let _storage_guard = self.session_secret_storage.lock().await; - if !self.session_secret_allocation_is_current(session, lifecycle_epoch) { - return Err(AuthorityError::Disconnected); - } - if let Some(allowance) = self - .statement_store_allowances - .lock() - .expect("statement-store allowance cache mutex poisoned") - .get(&cache_key) - .cloned() - { - return Ok(Some(allowance)); - } - let Some(secret) = allowances::read_allowance_key( - &*self.platform, - session, - product_id, - AllowanceResource::StatementStore, - ) - .await? - else { - return Ok(None); - }; - let allowance = StatementStoreAllowanceKey::from_secret_bytes(secret)?; - self.remember_statement_store_allowance_key( - session, - lifecycle_epoch, - product_id, - allowance.clone(), - )?; - Ok(Some(allowance)) - } - - /// Persist and memory-cache a freshly allocated Bulletin allowance key. - pub async fn cache_bulletin_allowance_key( - &self, - session: &SessionInfo, - lifecycle_epoch: u64, - product_id: &str, - slot_account_key: Vec, - ) -> Result { - let allowance = BulletinAllowanceKey::from_secret_bytes(slot_account_key)?; - let _storage_guard = self.session_secret_storage.lock().await; - if !self.session_secret_allocation_is_current(session, lifecycle_epoch) { - return Err(AuthorityError::Disconnected); - } - allowances::write_allowance_key( - &*self.platform, - session, - product_id, - AllowanceResource::Bulletin, - allowance.as_secret_bytes().to_vec(), - ) - .await?; - if let Err(error) = self.remember_bulletin_allowance_key( - session, - lifecycle_epoch, - product_id, - allowance.clone(), - ) { - let _ = allowances::remove_allowance_key( - &*self.platform, - session, - product_id, - AllowanceResource::Bulletin, + /// Bind account calls to a paired session whose changes reach the host that keeps its grants. + pub fn new(services: Arc, config: PairingHostConfig) -> Self { + let registry = RingVrfRegistryStore::new(services.platform.clone()); + let mut session = None; + let accounts = Arc::new_cyclic(|accounts: &Weak>| { + let owner: Weak = accounts.clone(); + let service = SsoRequestService::new(services.clone(), config, owner); + session = Some(service.clone()); + HostAccounts::new( + services.clone(), + Arc::new(RemoteAccountHolder::new( + service.clone(), + registry.clone(), + services.spawner.clone(), + )), + service.session_state(), + registry, + Arc::new(ProductConsent::new(services.platform.clone())), + #[cfg(feature = "test-host")] + Arc::default(), ) - .await; - return Err(error); - } - Ok(allowance) - } - - fn remember_bulletin_allowance_key( - &self, - session: &SessionInfo, - lifecycle_epoch: u64, - product_id: &str, - allowance: BulletinAllowanceKey, - ) -> Result<(), AuthorityError> { - let cache_key = AllowanceCacheKey::new(session, product_id, AllowanceResource::Bulletin)?; - let lifecycle = self.session_lifecycle.lock().expect("session lifecycle mutex poisoned"); - if lifecycle.epoch != lifecycle_epoch || - !session.sso.as_ref().is_some_and(|sso| { - self.current_sso_session_matches(SsoSessionKey::from_session(sso)) - }) { - return Err(AuthorityError::Disconnected); - } - self.bulletin_allowances - .lock() - .expect("bulletin allowance cache mutex poisoned") - .insert(cache_key, allowance); - Ok(()) - } - - /// Cached Bulletin allowance key for the product, falling back to - /// persisted storage. - pub async fn cached_bulletin_allowance_key( - &self, - session: &SessionInfo, - lifecycle_epoch: u64, - product_id: &str, - ) -> Result, AuthorityError> { - let cache_key = AllowanceCacheKey::new(session, product_id, AllowanceResource::Bulletin)?; - let _storage_guard = self.session_secret_storage.lock().await; - if !self.session_secret_allocation_is_current(session, lifecycle_epoch) { - return Err(AuthorityError::Disconnected); - } - if let Some(allowance) = self - .bulletin_allowances - .lock() - .expect("bulletin allowance cache mutex poisoned") - .get(&cache_key) - .cloned() - { - return Ok(Some(allowance)); - } - let Some(secret) = allowances::read_allowance_key( - &*self.platform, - session, - product_id, - AllowanceResource::Bulletin, - ) - .await? - else { - return Ok(None); - }; - let allowance = BulletinAllowanceKey::from_secret_bytes(secret)?; - self.remember_bulletin_allowance_key( - session, - lifecycle_epoch, - product_id, - allowance.clone(), - )?; - Ok(Some(allowance)) - } - - /// Drop the cached and persisted Bulletin allowance key for one product. - pub async fn evict_bulletin_allowance_key( - &self, - session: &SessionInfo, - lifecycle_epoch: u64, - product_id: &str, - ) -> Result<(), AuthorityError> { - let cache_key = AllowanceCacheKey::new(session, product_id, AllowanceResource::Bulletin)?; - let _storage_guard = self.session_secret_storage.lock().await; - if !self.session_secret_allocation_is_current(session, lifecycle_epoch) { - return Err(AuthorityError::Disconnected); - } - self.bulletin_allowances - .lock() - .expect("bulletin allowance cache mutex poisoned") - .remove(&cache_key); - allowances::remove_allowance_key( - &*self.platform, - session, - product_id, - AllowanceResource::Bulletin, - ) - .await?; - if !self.session_secret_allocation_is_current(session, lifecycle_epoch) { - return Err(AuthorityError::Disconnected); - } - Ok(()) - } - - /// Drop memory-cached statement-store allowance keys, scoped to `session` - /// when given, otherwise all. - pub fn clear_statement_store_allowance_keys(&self, session: Option<&SessionInfo>) { - let mut allowances = self - .statement_store_allowances - .lock() - .expect("statement-store allowance cache mutex poisoned"); - let Some(session) = session else { - allowances.clear(); - return; - }; - let Some(sso) = session.sso.as_ref() else { - return; - }; - let session_key = SsoSessionKey::from_session(sso); - allowances.retain(|key, _| !key.is_for_session(session_key)); - } - - /// Drop memory-cached Bulletin allowance keys, scoped to `session` when - /// given, otherwise all. - pub fn clear_bulletin_allowance_keys(&self, session: Option<&SessionInfo>) { - let mut allowances = self - .bulletin_allowances - .lock() - .expect("bulletin allowance cache mutex poisoned"); - let Some(session) = session else { - allowances.clear(); - return; - }; - let Some(sso) = session.sso.as_ref() else { - return; - }; - let session_key = SsoSessionKey::from_session(sso); - allowances.retain(|key, _| !key.is_for_session(session_key)); - } - - async fn clear_auto_signing_keys(&self) -> Result<(), String> { - let _storage_guard = self.session_secret_storage.lock().await; - self.clear_auto_signing_keys_under_storage_guard().await - } - - async fn clear_auto_signing_keys_under_storage_guard(&self) -> Result<(), String> { - self.auto_signing_keys - .lock() - .expect("AutoSigning key cache mutex poisoned") - .clear(); - self.platform - .clear_core_storage(CoreStorageKey::AutoSigningKeys) - .await - .map_err(|err| err.reason) - } - - async fn clear_auto_signing_product_under_storage_guard( - &self, - product_id: &str, - ) -> Result<(), String> { - let aggregate_result = match self - .platform - .read_core_storage(CoreStorageKey::AutoSigningKeys) - .await - { - Err(error) => Err(error.reason), - Ok(None) => Ok(()), - Ok(Some(mut blob)) => { - let decoded = decode_auto_signing_keys(&blob); - blob.zeroize(); - match decoded { - Err(_) => self - .platform - .clear_core_storage(CoreStorageKey::AutoSigningKeys) - .await - .map_err(|error| error.reason), - Ok(mut keys) => { - let before = keys.len(); - keys.retain(|key| key.product_id != product_id); - if keys.len() == before { - Ok(()) - } else if keys.is_empty() { - self.platform - .clear_core_storage(CoreStorageKey::AutoSigningKeys) - .await - .map_err(|error| error.reason) - } else { - self.platform - .write_core_storage(CoreStorageKey::AutoSigningKeys, keys.encode()) - .await - .map_err(|error| error.reason) - } - }, - } - }, - }; - let legacy_result = self - .clear_legacy_auto_signing_key(product_id) - .await - .map_err(|error| error.to_string()); - aggregate_result.and(legacy_result.map(|_| ())) - } - - async fn clear_auto_signing_keys_for_other_owner( - &self, - session: &SessionInfo, - ) -> Result<(), String> { - let owner = AutoSigningOwner::from_session(session); - let _storage_guard = self.session_secret_storage.lock().await; - let Some(mut blob) = self - .platform - .read_core_storage(CoreStorageKey::AutoSigningKeys) - .await - .map_err(|err| err.reason)? - else { - return Ok(()); - }; - let decoded = decode_auto_signing_keys(&blob); - blob.zeroize(); - let should_clear = decoded - .as_ref() - .map(|keys| keys.iter().any(|key| key.owner != owner)) - .unwrap_or(true); - if !should_clear { - return Ok(()); - } - self.auto_signing_keys - .lock() - .expect("AutoSigning key cache mutex poisoned") - .clear(); - self.platform - .clear_core_storage(CoreStorageKey::AutoSigningKeys) - .await - .map_err(|err| err.reason) - } - - async fn clear_legacy_auto_signing_key( - &self, - product_id: &str, - ) -> Result { - let storage_key = CoreStorageKey::AutoSigningKey { product_id: product_id.to_string() }; - let legacy = self.platform.read_core_storage(storage_key.clone()).await.map_err(|err| { - AuthorityError::Unknown { - reason: format!("failed to inspect legacy AutoSigning key: {}", err.reason), - } - })?; - let present = if let Some(mut secret) = legacy { - secret.zeroize(); - true - } else { - false - }; - if present { - self.platform.clear_core_storage(storage_key).await.map_err(|err| { - AuthorityError::Unknown { - reason: format!("failed to clear legacy AutoSigning key: {}", err.reason), - } - })?; - } - Ok(present) - } - - async fn remember_auto_signing_key( - &self, - session: &SessionInfo, - lifecycle_epoch: u64, - product_id: &str, - expected_product_subtree_public_key: [u8; 32], - secret: [u8; 64], - ring_vrf_domain_entropy: [u8; 32], - ) -> Result<(), AuthorityError> { - let key = validate_auto_signing_key( - secret, - expected_product_subtree_public_key, - ring_vrf_domain_entropy, - )?; - let owner = AutoSigningOwner::from_session(session); - let cache_key = (owner.clone(), product_id.to_string()); - let _storage_guard = self.session_secret_storage.lock().await; - if !self.session_secret_allocation_is_current(session, lifecycle_epoch) { - return Err(AuthorityError::Disconnected); - } - self.clear_legacy_auto_signing_key(product_id).await?; - let mut keys = - match self.platform.read_core_storage(CoreStorageKey::AutoSigningKeys).await.map_err( - |err| AuthorityError::Unknown { - reason: format!("failed to read AutoSigning capabilities: {}", err.reason), - }, - )? { - Some(mut blob) => { - let decoded = decode_auto_signing_keys(&blob).unwrap_or_default(); - blob.zeroize(); - decoded - }, - None => Vec::new(), - }; - keys.retain(|persisted| persisted.owner == owner && persisted.product_id != product_id); - keys.push(PersistedAutoSigningKey { - owner, - product_id: product_id.to_string(), - expected_product_subtree_public_key, - secret, - ring_vrf_domain_entropy, - }); - if !self.session_secret_allocation_is_current(session, lifecycle_epoch) { - return Err(AuthorityError::Disconnected); - } - self.platform - .write_core_storage(CoreStorageKey::AutoSigningKeys, keys.encode()) - .await - .map_err(|err| AuthorityError::Unknown { - reason: format!("failed to persist AutoSigning capability: {}", err.reason), - })?; - if !self.cache_auto_signing_key_if_current(session, lifecycle_epoch, cache_key, key) { - let _ = self.clear_auto_signing_product_under_storage_guard(product_id).await; - return Err(AuthorityError::Disconnected); - } - Ok(()) - } - - async fn auto_signing_key( - &self, - session: &SessionInfo, - product_id: &str, - ) -> Result, AuthorityError> { - let owner = AutoSigningOwner::from_session(session); - let cache_key = (owner.clone(), product_id.to_string()); - if let Some(key) = self - .auto_signing_keys - .lock() - .expect("AutoSigning key cache mutex poisoned") - .get(&cache_key) - .cloned() - { - return Ok(Some(key)); - } - - let _storage_guard = self.session_secret_storage.lock().await; - if let Some(key) = self - .auto_signing_keys - .lock() - .expect("AutoSigning key cache mutex poisoned") - .get(&cache_key) - .cloned() - { - return Ok(Some(key)); - } - let legacy_present = self.clear_legacy_auto_signing_key(product_id).await?; - let Some(mut blob) = - self.platform.read_core_storage(CoreStorageKey::AutoSigningKeys).await.map_err( - |err| AuthorityError::Unknown { - reason: format!("failed to read AutoSigning capabilities: {}", err.reason), - }, - )? - else { - return if legacy_present { - Err(AuthorityError::Unavailable { - reason: "legacy unscoped AutoSigning capability was rejected".to_string(), - }) - } else { - Ok(None) - }; - }; - let decoded = decode_auto_signing_keys(&blob); - blob.zeroize(); - let keys = match decoded { - Ok(keys) => keys, - Err(err) => { - self.auto_signing_keys - .lock() - .expect("AutoSigning key cache mutex poisoned") - .clear(); - let _ = self.platform.clear_core_storage(CoreStorageKey::AutoSigningKeys).await; - return Err(err); - }, - }; - if keys.iter().any(|persisted| persisted.owner != owner) { - self.auto_signing_keys - .lock() - .expect("AutoSigning key cache mutex poisoned") - .clear(); - let _ = self.platform.clear_core_storage(CoreStorageKey::AutoSigningKeys).await; - return Ok(None); - } - let Some(persisted) = keys.iter().find(|persisted| persisted.product_id == product_id) - else { - return if legacy_present { - Err(AuthorityError::Unavailable { - reason: "legacy unscoped AutoSigning capability was rejected".to_string(), - }) - } else { - Ok(None) - }; - }; - let current_expected_subtree = session.sso.as_ref().and_then(|sso| { - self.product_subtrees - .lock() - .expect("product subtree cache mutex poisoned") - .get(&(SsoSessionKey::from_session(sso), product_id.to_string())) - .copied() }); - if current_expected_subtree - .is_some_and(|expected| expected != persisted.expected_product_subtree_public_key) - { - self.auto_signing_keys - .lock() - .expect("AutoSigning key cache mutex poisoned") - .clear(); - let _ = self.platform.clear_core_storage(CoreStorageKey::AutoSigningKeys).await; - return Err(AuthorityError::Unavailable { - reason: "AutoSigning capability is not for the current product subtree".to_string(), - }); - } - let key = match validate_auto_signing_key( - persisted.secret, - persisted.expected_product_subtree_public_key, - persisted.ring_vrf_domain_entropy, - ) { - Ok(key) => key, - Err(err) => { - self.auto_signing_keys - .lock() - .expect("AutoSigning key cache mutex poisoned") - .clear(); - let _ = self.platform.clear_core_storage(CoreStorageKey::AutoSigningKeys).await; - return Err(err); - }, - }; - self.auto_signing_keys - .lock() - .expect("AutoSigning key cache mutex poisoned") - .insert(cache_key, key.clone()); - Ok(Some(key)) - } - - /// Drop the persisted subtree slots this run knows about for `session`. - /// - /// Scoped to the in-memory set, so a product never opened since launch - /// keeps its slot. Those address session ids that cannot recur, and - /// clearing them belongs to the host, as `CoreStorage` states. - async fn clear_stored_product_subtrees(&self, session: &SessionInfo) { - let Some(sso) = session.sso.as_ref() else { - return; - }; - let session_key = SsoSessionKey::from_session(sso); - let product_ids: Vec = self - .product_subtrees - .lock() - .expect("product subtree cache mutex poisoned") - .keys() - .filter(|(key, _)| *key == session_key) - .map(|(_, product_id)| product_id.clone()) - .collect(); - for product_id in product_ids { - if let Err(reason) = - product_subtree::remove_product_subtree(&*self.platform, session, &product_id).await - { - warn!(%reason, %product_id, "product subtree clear failed during disconnect"); - } - } - } - - fn clear_product_subtrees(&self, session: Option<&SessionInfo>) { - let mut subtrees = - self.product_subtrees.lock().expect("product subtree cache mutex poisoned"); - let Some(session) = session else { - subtrees.clear(); - return; - }; - let Some(sso) = session.sso.as_ref() else { - return; - }; - let session_key = SsoSessionKey::from_session(sso); - subtrees.retain(|(key, _), _| *key != session_key); - } - - /// Whether `calling_product_id` may act on `handle`'s ring-VRF key. - /// - /// Delegates to [`crate::runtime::ring_vrf_key_access_granted`], which - /// resolves the owner's manifest here rather than trusting the request: on - /// this role the request can have arrived over the pairing wire. - async fn require_ring_vrf_key_access( - &self, - calling_product_id: &str, - handle: &latest::ProductAccountId, - ) -> Result< - (latest::ProductAccountId, crate::runtime::product_manifest::AuthorizedAccess), - RingVrfError, - > { - let access = crate::runtime::product_manifest::ring_vrf_key_access_granted( - &self.services, - self.platform.as_ref(), - calling_product_id, - handle, - ) - .await?; - Ok(( - latest::ProductAccountId { - dot_ns_identifier: access.owner.clone(), - derivation_index: handle.derivation_index.clone(), - }, - access, - )) - } - - async fn local_ring_vrf_entropy( - &self, - session: &SessionInfo, - handle: &latest::ProductAccountId, - ) -> Result>, RingVrfError> { - let Some(auto_signing) = self - .auto_signing_key(session, &handle.dot_ns_identifier) - .await - .map_err(RingVrfError::from)? - else { - return Ok(None); - }; - let entry = self - .ring_vrf_registry - .entry(session.public_key, handle) - .await? - .ok_or(RingVrfError::KeyNotRegistered)?; - let entropy = Zeroizing::new(derive_ring_vrf_entropy_from_domain( - auto_signing.ring_vrf_domain_entropy(), - &handle.derivation_index, - )); - let vrf = vrf::load().await?; - if entry.public_key != Some(vrf.member(&entropy)?) { - return Err(RingVrfError::Unknown { - reason: "registered ring-VRF public key does not match the AutoSigning capability" - .to_string(), - }); - } - Ok(Some(entropy)) - } - - async fn local_ring_vrf_entropy_for_ring( - &self, - session: &SessionInfo, - handle: &latest::ProductAccountId, - ring: &latest::RingLocation, - ) -> Result>, RingVrfError> { - let Some(entropy) = self.local_ring_vrf_entropy(session, handle).await? else { - return Ok(None); - }; - let entry = self - .ring_vrf_registry - .entry(session.public_key, handle) - .await? - .ok_or(RingVrfError::KeyNotRegistered)?; - if !entry.rings.contains(ring) { - return Err(RingVrfError::KeyNotInRing); - } - Ok(Some(entropy)) + Self { accounts, session: session.expect("the session service is built with its owner") } } - fn mirror_ring_vrf_registration( - &self, - session: SessionInfo, - request: ProductRequest, - ) { - let weak_self = self.weak_self.clone(); - (self.spawner)(Box::pin(async move { - let Some(host) = weak_self.upgrade() else { - return; - }; - let cx = CallContext::with_request_id(format!( - "ring-vrf-registration-mirror:{}", - sso_message_id() - )); - if let Err(error) = host.remote_register_ring_vrf_key(&cx, &session, request).await { - warn!(?error, "ring-VRF registration mirror failed"); - } - })); + /// Account calls for products served by this host. + pub fn accounts(&self) -> &Arc> { + &self.accounts } - async fn product_subtree_public_key( - &self, - cx: &CallContext, - session: &AuthoritySession, - product_id: String, - ) -> Result<[u8; 32], AuthorityError> { - let session = self.current_private_session(session)?; - self.remote_product_subtree_public_key(cx, &session, product_id).await + /// The paired session: login, replacement and disconnection. + pub fn session(&self) -> &Arc { + &self.session } - /// The keypair that can serve `account` locally under an AutoSigning - /// capability, when this host holds one. - /// - /// The single source of truth for local serviceability: the grant - /// predicate and every local fast path call it, so a request can never be - /// told "no prompt" and then relayed to a signing host that will prompt - /// for it. - /// - /// A caller may name an account belonging to another product — - /// `authorized_product_account` admits that for a `localhost` caller, and - /// for one the owner granted `context` — and the grant covers the owning - /// product's subtree, not the caller's. Serving one locally would sign with a key the caller - /// was never granted and skip the confirmation the signing host raises for a - /// relayed request, so the binding is checked here rather than only at the - /// gate. - async fn local_product_signing_key( - &self, - session: &SessionInfo, - calling_product_id: Option<&str>, - account: &latest::ProductAccountId, - ) -> Result, AuthorityError> { - if calling_product_id != Some(account.dot_ns_identifier.as_str()) { - return Ok(None); - } - let Some(key) = self.auto_signing_key(session, &account.dot_ns_identifier).await? else { - return Ok(None); - }; - derive_product_keypair_from_subtree_secret( - *key.as_secret_bytes(), - derivation_index_bytes(&account.derivation_index), - ) - .map(Some) - .map_err(|err| AuthorityError::Unknown { reason: err.to_string() }) - } - - /// Whether an AutoSigning capability lets this host serve `account` - /// locally for `calling_product_id`. - /// - /// A capability this host cannot trust is an error, not a fall-through: - /// the lookup erases the slot as it rejects it, and prompting afterwards - /// would ask the user to approve a signature the host just refused to make. - async fn auto_signing_status( - &self, - session: &AuthoritySession, - calling_product_id: &str, - account: &latest::ProductAccountId, - ) -> Result { - let session = self.current_private_session(session)?; - Ok( - match self - .local_product_signing_key(&session, Some(calling_product_id), account) - .await? - { - Some(_) => AutoSigningGrant::Active, - None => AutoSigningGrant::Absent, - }, - ) - } - - async fn sign_vrf( - &self, - cx: &CallContext, - session: &AuthoritySession, - calling_product_id: String, - request: latest::HostAccountSignVrfRequest, - ) -> Result { - let session = self.current_private_session(session)?; - if calling_product_id == request.account.dot_ns_identifier && - let Some(auto_signing_key) = - self.auto_signing_key(&session, &request.account.dot_ns_identifier).await? - { - let keypair = derive_product_keypair_from_subtree_secret( - *auto_signing_key.as_secret_bytes(), - derivation_index_bytes(&request.account.derivation_index), - ) - .map_err(|err| AuthorityError::Unknown { reason: err.to_string() })?; - let (pre_output, proof) = crate::dynamic_vrf::sign_dynamic_vrf( - &keypair, - &request.transcript_label, - request.items.iter().map(|item| (item.label.as_slice(), item.value.as_slice())), - ); - return Ok(latest::VrfSignature { pre_output, proof }); - } - if !super::authority::is_blessed_owner( - &calling_product_id, - &request.account.dot_ns_identifier, - ) { - let confirmed = super::until_cancelled( - cx, - self.platform - .confirm_user_action(UserConfirmationReview::SignVrf(SignVrfReview { - calling_product_id: calling_product_id.clone(), - request: request.clone(), - })), - ) - .await? - .map_err(|err| AuthorityError::Unknown { - reason: format!("VRF signing confirmation failed: {err:?}"), - })?; - if !confirmed { - return Err(AuthorityError::Rejected); - } - } - self.remote_sign_vrf(cx, &session, calling_product_id, request).await - } - - async fn sign_payload( - &self, - cx: &CallContext, - session: &AuthoritySession, - calling_product_id: Option<&str>, - request: SignPayloadAuthorityRequest, - ) -> Result { - let session = self.current_private_session(session)?; - if let SignPayloadAuthorityRequest::Product(payload) = &request && - let Some(keypair) = self - .local_product_signing_key(&session, calling_product_id, &payload.account) - .await? - { - return Ok(sign_extrinsic_payload(&keypair, payload.payload.clone())?); - } - self.remote_sign_payload(cx, &session, request).await - } - - async fn sign_raw( - &self, - cx: &CallContext, - session: &AuthoritySession, - calling_product_id: Option<&str>, - request: SignRawAuthorityRequest, - watermarked: bool, - ) -> Result { - let session = self.current_private_session(session)?; - // The unwatermarked API is never grant-covered, so a local signature - // here would skip a prompt the gate deliberately raised. - if watermarked && - let SignRawAuthorityRequest::Product(payload) = &request && - let Some(keypair) = self - .local_product_signing_key(&session, calling_product_id, &payload.account) - .await? - { - let message = raw_payload_bytes(payload.payload.clone(), watermarked)?; - let signature = keypair - .secret - .sign_simple(SR25519_SIGNING_CONTEXT, &message, &keypair.public) - .to_bytes(); - return Ok(latest::HostSignPayloadResponse { - signature: signature.to_vec(), - signed_transaction: None, - }); - } - self.remote_sign_raw(cx, &session, request, watermarked).await - } - - async fn create_transaction( - &self, - cx: &CallContext, - session: &AuthoritySession, - calling_product_id: Option<&str>, - request: CreateTransactionAuthorityRequest, - ) -> Result { - let session = self.current_private_session(session)?; - if let CreateTransactionAuthorityRequest::Product(payload) = &request && - let Some(keypair) = self - .local_product_signing_key(&session, calling_product_id, &payload.signer) - .await? - { - // A payload this host cannot assemble is an error, not a - // fall-through to the relay: the gate already told the caller no - // prompt was coming, and relaying would raise one on the signing - // host after a chain timeout. - // - // Assembling needs runtime metadata, which a granted product - // moves from the signing host to this one. A pairing host is - // assumed to reach any genesis a product it has granted signs - // against; where it cannot, the call fails rather than producing - // the prompt-and-signature an ungranted product would have got. - // - // The failure is not prompt. An unreachable genesis leaves the - // metadata read waiting on the chain, so the caller can sit on the - // authority request timeout before it sees anything — the cost of - // assembling locally is paid before the grant can be found wanting. - return Ok(build_local_transaction( - &self.chain, - &keypair, - payload.genesis_hash, - &payload.call_data, - &payload.extensions, - payload.tx_ext_version, - ) - .await?); - } - self.remote_create_transaction(cx, &session, request).await - } - - async fn account_alias( - &self, - cx: &CallContext, - session: &AuthoritySession, - request: ProductRequest, - ) -> Result { - let private_session = self.current_private_session(session)?; - if request.calling_product_id == request.payload.key_handle.dot_ns_identifier && - let Some(entropy) = self - .local_ring_vrf_entropy_for_ring( - &private_session, - &request.payload.key_handle, - &request.payload.ring_location, - ) - .await? - { - self.ring_resolver.validate(&request.payload.ring_location).await?; - let vrf = vrf::load().await?; - self.current_private_session(session)?; - let context = context_bytes(&request.payload.context); - let alias = vrf.alias(&entropy, &context)?; - return Ok(latest::ContextualAlias { context, alias: alias.to_vec() }); - } - self.remote_account_alias(cx, &private_session, request).await - } - - async fn create_proof( - &self, - cx: &CallContext, - session: &AuthoritySession, - request: ProductRequest, - ) -> Result { - let (key_handle, access) = self - .require_ring_vrf_key_access(&request.calling_product_id, &request.payload.key_handle) - .await?; - // A grant cannot expose the owner's alias in a third product's context unless - // that product's manifest grants the caller `context`. - crate::runtime::product_manifest::require_context_access( - &self.services, - &access, - &request.payload.context, - ) - .await?; - let private_session = self.current_private_session(session)?; - if let Some(entropy) = self - .local_ring_vrf_entropy_for_ring( - &private_session, - &key_handle, - &request.payload.ring_location, - ) - .await? - { - let vrf = vrf::load().await?; - let member = vrf.member(&entropy)?; - let resolved = self - .ring_resolver - .resolve(&request.payload.ring_location, &[MemberCandidate { member }]) - .await?; - self.current_private_session(session)?; - let context = context_bytes(&request.payload.context); - let (proof, alias) = - create_proof(&vrf, &entropy, &resolved, &context, &request.payload.message)?; - return Ok(latest::HostAccountCreateProofResponse { - proof, - contextual_alias: latest::ContextualAlias { context, alias: alias.to_vec() }, - ring_index: resolved.ring_index, - ring_revision: resolved.ring_revision, - }); - } - self.remote_create_proof(cx, &private_session, request).await - } - - async fn register_ring_vrf_key( - &self, - cx: &CallContext, - session: &AuthoritySession, - request: ProductRequest, - ) -> Result<[u8; 32], RingVrfError> { - let private_session = self.current_private_session(session)?; - let handle = latest::ProductAccountId { - dot_ns_identifier: normalize_product_identifier(&request.calling_product_id) - .map_err(|error| RingVrfError::Unknown { reason: error.to_string() })?, - derivation_index: request.payload.index.clone(), - }; - if let Some(auto_signing) = self - .auto_signing_key(&private_session, &request.calling_product_id) - .await - .map_err(RingVrfError::from)? - { - self.ring_resolver.validate(&request.payload.ring).await?; - let vrf = vrf::load().await?; - self.current_private_session(session)?; - let entropy = Zeroizing::new(derive_ring_vrf_entropy_from_domain( - auto_signing.ring_vrf_domain_entropy(), - &request.payload.index, - )); - let public_key = vrf.member(&entropy)?; - self.ring_vrf_registry - .register( - private_session.public_key, - handle, - request.payload.ring.clone(), - public_key, - ) - .await?; - self.current_private_session(session)?; - self.mirror_ring_vrf_registration(private_session, request); - return Ok(public_key); - } - let public_key = - self.remote_register_ring_vrf_key(cx, &private_session, request.clone()).await?; - self.ring_vrf_registry - .register(private_session.public_key, handle, request.payload.ring, public_key) - .await?; - self.current_private_session(session)?; - Ok(public_key) - } - - async fn list_ring_vrf_keys( - &self, - cx: &CallContext, - session: &AuthoritySession, - request: ProductRequest, - ) -> Result, RingVrfError> { - let private_session = self.current_private_session(session)?; - let owner = normalize_product_identifier(&request.payload.owner) - .map_err(|error| RingVrfError::Unknown { reason: error.to_string() })?; - if request.calling_product_id == owner && - let Some(mut entries) = self - .ring_vrf_registry - .complete_owner_entries(private_session.public_key, &owner) - .await? - { - self.current_private_session(session)?; - apply_ring_vrf_disclosure(&mut entries, request.payload.disclosure); - return Ok(entries); - } - let requested_disclosure = request.payload.disclosure; - let mut remote_request = request; - if remote_request.calling_product_id == owner { - remote_request.payload.disclosure = latest::RingVrfKeyDisclosure::PublicKey; - } - let mut entries = - self.remote_list_ring_vrf_keys(cx, &private_session, remote_request).await?; - validate_owner_listing(&owner, &entries)?; - if entries.iter().all(|entry| entry.public_key.is_some()) { - entries = self - .ring_vrf_registry - .reconcile_owner(private_session.public_key, &owner, entries) - .await?; - } - self.current_private_session(session)?; - apply_ring_vrf_disclosure(&mut entries, requested_disclosure); - Ok(entries) - } - - async fn ring_vrf_sign( - &self, - cx: &CallContext, - session: &AuthoritySession, - request: ProductRequest, - ) -> Result, RingVrfError> { - let (key_handle, _access) = self - .require_ring_vrf_key_access(&request.calling_product_id, &request.payload.key_handle) - .await?; - let private_session = self.current_private_session(session)?; - if let Some(entropy) = self.local_ring_vrf_entropy(&private_session, &key_handle).await? { - let vrf = vrf::load().await?; - self.current_private_session(session)?; - return vrf.sign(&entropy, &request.payload.message); - } - self.remote_ring_vrf_sign(cx, &private_session, request).await - } - - async fn allocate_resources( - &self, - cx: &CallContext, - session: &AuthoritySession, - product_id: String, - request: latest::HostRequestResourceAllocationRequest, - ) -> Result { - let session = self.current_private_session(session)?; - self.remote_allocate_resources(cx, &session, product_id, request).await - } - - async fn statement_store_allowance_key( - &self, - cx: &CallContext, - session: &AuthoritySession, - product_id: String, - ) -> Result { - let session = self.current_private_session(session)?; - self.remote_statement_store_allowance_key(cx, &session, product_id).await - } - - async fn bulletin_allowance_key( - &self, - cx: &CallContext, - session: &AuthoritySession, - product_id: String, - ) -> Result { - let session = self.current_private_session(session)?; - self.remote_bulletin_allowance_key(cx, &session, product_id).await - } - - async fn refresh_bulletin_allowance_key( - &self, - cx: &CallContext, - session: &AuthoritySession, - product_id: String, - ) -> Result { - let session = self.current_private_session(session)?; - self.remote_refresh_bulletin_allowance_key(cx, &session, product_id).await - } - - async fn sign_statement_store_product_payload( - &self, - _cx: &CallContext, - session: &AuthoritySession, - calling_product_id: Option<&str>, - account: latest::ProductAccountId, - payload: Vec, - ) -> Result<[u8; 64], AuthorityError> { - let session = self.current_private_session(session)?; - // The SSO raw-signing protocol cannot carry an exact, unwatermarked - // payload, so the capability's own key is the only way this role signs - // one. Statement proofs raise no confirmation on either role, so this - // unlocks the operation rather than waiving a prompt. - let Some(keypair) = - self.local_product_signing_key(&session, calling_product_id, &account).await? - else { - return Err(AuthorityError::Unavailable { - reason: "pairing host: exact statement proof signing needs an AutoSigning \ - capability; the current SSO raw-signing protocol cannot carry it" - .to_string(), - }); - }; - Ok(keypair - .secret - .sign_simple(SR25519_SIGNING_CONTEXT, &payload, &keypair.public) - .to_bytes()) - } - - fn derive_entropy( - &self, - session: &AuthoritySession, - product_id: &str, - context: &[u8], - ) -> Result<[u8; 32], AuthorityError> { - let session = self.current_private_session(session)?; - if session.sso.is_none() { - return Err(AuthorityError::Disconnected); - } - let root_entropy_source = session.root_entropy_source.ok_or_else(|| { - AuthorityError::Unavailable { reason: "Session secret missing".to_string() } - })?; - derive_product_entropy_from_source(&root_entropy_source, product_id, context) - .map_err(|err| AuthorityError::Unknown { reason: err.to_string() }) - } - - /// Contact-handle key from the wallet-provided root entropy source. - /// - /// Unlike `derive_entropy` this does not require a live SSO channel: it - /// needs the key material the session already carries, not a round trip to - /// the wallet. - fn contacts_handle_key(&self, session: &AuthoritySession) -> Result<[u8; 32], AuthorityError> { - let session = self.current_private_session(session)?; - let root_entropy_source = session.root_entropy_source.ok_or_else(|| { - AuthorityError::Unavailable { reason: "Session secret missing".to_string() } + /// End the paired session, drop its kept AutoSigning keys and forget the + /// pairing identity, so the next login pairs anew. + pub async fn logout(&self) -> Result<(), String> { + self.session.disconnect().await; + self.accounts.forget_auto_signing_keys().await.map_err(|reason| { + format!("session disconnected, but AutoSigning reset failed: {reason}") })?; - Ok(crate::runtime::contacts::handle_key_from_root_source(&root_entropy_source)) - } -} - -fn login_error_reason(err: &CallError) -> String { - match err { - CallError::Domain(HostRequestLoginError::V1(latest::HostRequestLoginError::Unknown { - reason, - })) | - CallError::HostFailure { reason } => reason.clone(), - CallError::Unsupported => "login unsupported".to_string(), - CallError::Denied => "login denied".to_string(), - CallError::MalformedFrame { reason } => reason.clone(), - CallError::Cancelled => "login cancelled".to_string(), - } -} - -#[async_trait::async_trait] -impl HostSession for PairingHost { - fn session_state(&self) -> Arc { - PairingHost::session_state(self) - } - - async fn request_login( - &self, - product: &ProductContext, - ) -> Result> { - PairingHost::request_login(self, product) - .await - .map(IntoLatest::into_latest) - .map_err(|error| error.map_domain(IntoLatest::into_latest)) - } - - async fn disconnect(&self) { - PairingHost::disconnect(self).await; - } - - async fn primary_username(&self) -> Option { - self.refresh_current_session_identity() - .await? - .primary_username() - .map(str::to_string) - } -} - -#[async_trait::async_trait] -impl ProductAuthority for PairingHost { - fn current_session(&self) -> Option { - PairingHost::current_session(self) - } - - #[cfg(feature = "test-host")] - fn submits_preimages_locally(&self) -> bool { - self.submit_preimages_locally.load(core::sync::atomic::Ordering::Relaxed) - } - - #[cfg(test)] - fn cache_product_subtree_for_test( - &self, - session: &SessionInfo, - product_id: &str, - public_key: [u8; 32], - ) { - let sso = session.sso.as_ref().expect("test session must contain SSO"); - self.product_subtrees - .lock() - .expect("product subtree cache mutex poisoned") - .insert((SsoSessionKey::from_session(sso), product_id.to_string()), public_key); - } - - async fn product_subtree_public_key( - &self, - cx: &CallContext, - session: &AuthoritySession, - product_id: String, - ) -> Result<[u8; 32], AuthorityError> { - PairingHost::product_subtree_public_key(self, cx, session, product_id).await - } - - async fn subtree_resolution_reaches_account_holder( - &self, - session: &AuthoritySession, - product_id: &str, - ) -> bool { - PairingHost::subtree_reaches_account_holder(self, session, product_id).await - } - - async fn auto_signing_status( - &self, - session: &AuthoritySession, - calling_product_id: &str, - account: &latest::ProductAccountId, - ) -> Result { - PairingHost::auto_signing_status(self, session, calling_product_id, account).await - } - - async fn sign_vrf( - &self, - cx: &CallContext, - session: &AuthoritySession, - calling_product_id: String, - request: latest::HostAccountSignVrfRequest, - ) -> Result { - PairingHost::sign_vrf(self, cx, session, calling_product_id, request).await - } - - async fn sign_payload( - &self, - cx: &CallContext, - session: &AuthoritySession, - calling_product_id: Option<&str>, - request: SignPayloadAuthorityRequest, - ) -> Result { - PairingHost::sign_payload(self, cx, session, calling_product_id, request).await - } - - async fn sign_raw( - &self, - cx: &CallContext, - session: &AuthoritySession, - calling_product_id: Option<&str>, - request: SignRawAuthorityRequest, - watermarked: bool, - ) -> Result { - PairingHost::sign_raw(self, cx, session, calling_product_id, request, watermarked).await - } - - async fn create_transaction( - &self, - cx: &CallContext, - session: &AuthoritySession, - calling_product_id: Option<&str>, - request: CreateTransactionAuthorityRequest, - ) -> Result { - PairingHost::create_transaction(self, cx, session, calling_product_id, request).await - } - - async fn account_alias( - &self, - cx: &CallContext, - session: &AuthoritySession, - request: ProductRequest, - ) -> Result { - PairingHost::account_alias(self, cx, session, request).await - } - - async fn create_proof( - &self, - cx: &CallContext, - session: &AuthoritySession, - request: ProductRequest, - ) -> Result { - PairingHost::create_proof(self, cx, session, request).await - } - - async fn register_ring_vrf_key( - &self, - cx: &CallContext, - session: &AuthoritySession, - request: ProductRequest, - ) -> Result<[u8; 32], RingVrfError> { - PairingHost::register_ring_vrf_key(self, cx, session, request).await - } - - async fn list_ring_vrf_keys( - &self, - cx: &CallContext, - session: &AuthoritySession, - request: ProductRequest, - ) -> Result, RingVrfError> { - PairingHost::list_ring_vrf_keys(self, cx, session, request).await - } - - async fn ring_vrf_sign( - &self, - cx: &CallContext, - session: &AuthoritySession, - request: ProductRequest, - ) -> Result, RingVrfError> { - PairingHost::ring_vrf_sign(self, cx, session, request).await - } - - async fn allocate_resources( - &self, - cx: &CallContext, - session: &AuthoritySession, - product_id: String, - request: latest::HostRequestResourceAllocationRequest, - ) -> Result { - PairingHost::allocate_resources(self, cx, session, product_id, request).await - } - - async fn statement_store_allowance_key( - &self, - cx: &CallContext, - session: &AuthoritySession, - product_id: String, - ) -> Result { - PairingHost::statement_store_allowance_key(self, cx, session, product_id).await - } - - async fn bulletin_allowance_key( - &self, - cx: &CallContext, - session: &AuthoritySession, - product_id: String, - ) -> Result { - PairingHost::bulletin_allowance_key(self, cx, session, product_id).await - } - - async fn refresh_bulletin_allowance_key( - &self, - cx: &CallContext, - session: &AuthoritySession, - product_id: String, - ) -> Result { - PairingHost::refresh_bulletin_allowance_key(self, cx, session, product_id).await - } - - async fn sign_statement_store_product_payload( - &self, - cx: &CallContext, - session: &AuthoritySession, - calling_product_id: Option<&str>, - account: latest::ProductAccountId, - payload: Vec, - ) -> Result<[u8; 64], AuthorityError> { - PairingHost::sign_statement_store_product_payload( - self, - cx, - session, - calling_product_id, - account, - payload, - ) - .await - } - - fn derive_entropy( - &self, - session: &AuthoritySession, - product_id: &str, - context: &[u8], - ) -> Result<[u8; 32], AuthorityError> { - PairingHost::derive_entropy(self, session, product_id, context) - } - - fn contacts_handle_key(&self, session: &AuthoritySession) -> Result<[u8; 32], AuthorityError> { - PairingHost::contacts_handle_key(self, session) + self.session.forget_pairing_identity().await } } diff --git a/rust/crates/truapi/src/runtime/pairing_host/sso_channel.rs b/rust/crates/truapi/src/runtime/pairing_host/sso_channel.rs deleted file mode 100644 index ff35ec9d97..0000000000 --- a/rust/crates/truapi/src/runtime/pairing_host/sso_channel.rs +++ /dev/null @@ -1,894 +0,0 @@ -//! SSO statement-store channel to the paired remote signing host. - -use std::sync::{ - Arc, - atomic::{AtomicBool, Ordering}, -}; - -use super::{ - super::{ - authority::{ - AuthorityCancelError, AuthorityError, BulletinAllowanceKey, - CreateTransactionAuthorityRequest, SignPayloadAuthorityRequest, - SignRawAuthorityRequest, StatementStoreAllowanceKey, - }, - sso_remote::{ - RemoteResponseWait, SSO_LOCAL_DISCONNECT_REASON, SSO_PEER_DISCONNECT_REASON, - SsoRemoteResponseError, SsoSessionKey, fresh_statement_expiry, reply_matcher, - sso_message_id, statement_subscription_stream, subscribe_statement_topic, - wait_for_sso_remote_response, - }, - statement_store_rpc::{self, StatementStoreRpc}, - }, - PairingHost, -}; -use crate::{ - host_internal::{ - sso_messages::{ - CreateTransactionLegacyPayload, CreateTransactionPayload, CreateTransactionRequest, - CreateTransactionWithLegacyAccountRequest, OnExistingAllowancePolicy, ProductRequest, - ProductSubtreeRequest, RemoteMessage, RemoteMessageData, ResourceAllocationRequest, - RingVrfError, SignRawWithLegacyAccountRequest, SignRequest, SsoAllocatedResource, - SsoAllocationOutcome, SsoProductTxPayload, SsoSessionStatement, Withdrawal, - build_outgoing_request_statement, decode_sso_session_statement, v1, - }, - sso_wire::SsoRequest, - }, - host_logic::{ - session::{SessionInfo, SessionState, SsoSessionInfo}, - statement_store::parse_new_statements_result, - }, -}; - -use futures::{ - FutureExt, - future::{AbortHandle, Abortable}, -}; -use tracing::{debug, instrument, warn}; -use truapi::{CallContext, CancellationReason, latest}; - -/// Active peer-disconnect watcher for one SSO session; aborts on drop. -pub struct SsoDisconnectMonitor { - key: SsoSessionKey, - abort: AbortHandle, -} - -impl Drop for SsoDisconnectMonitor { - fn drop(&mut self) { - self.abort.abort(); - } -} - -impl PairingHost { - fn stop_disconnect_monitor(&self) { - self.disconnect_monitor - .lock() - .expect("SSO disconnect monitor mutex poisoned") - .take(); - } - - /// Watch the session's topics for a peer disconnect statement, replacing - /// any monitor for a different session. No-op when one is already running - /// for this session. - pub fn start_disconnect_monitor(&self, session: &SessionInfo) { - let Some(sso) = session.sso.clone() else { - self.stop_disconnect_monitor(); - return; - }; - let key = SsoSessionKey::from_session(&sso); - - let (registration, spawner) = { - let mut current = - self.disconnect_monitor.lock().expect("SSO disconnect monitor mutex poisoned"); - if current.as_ref().is_some_and(|active| active.key == key) { - return; - } - let (abort, registration) = AbortHandle::new_pair(); - *current = Some(SsoDisconnectMonitor { key, abort }); - (registration, self.spawner.clone()) - }; - - let statement_store = self.statement_store.clone(); - let pairing_host = self.weak_self.clone(); - let future = async move { - let result = wait_for_sso_peer_disconnect(statement_store, sso).await; - let Some(pairing_host) = pairing_host.upgrade() else { - return; - }; - { - let mut active = pairing_host - .disconnect_monitor - .lock() - .expect("SSO disconnect monitor mutex poisoned"); - if active.as_ref().is_some_and(|active| active.key == key) { - *active = None; - } - } - match result { - Ok(()) => { - pairing_host.handle_signing_host_disconnected(key).await; - }, - Err(reason) => { - warn!(%reason, "SSO peer disconnect monitor stopped"); - }, - } - }; - spawner(Box::pin(Abortable::new(future, registration).map(|_| ()))); - } - - /// Stop channel work for a cleared session: wake its in-flight waiters - /// with a local disconnect, then drop the peer-disconnect monitor. - pub fn stop_session_channel(&self, session: Option<&SessionInfo>) { - if let Some(sso) = session.and_then(|session| session.sso.as_ref()) { - self.session_disconnects.notify(sso, SSO_LOCAL_DISCONNECT_REASON); - } - *self.newest_request.lock().expect("newest request mutex poisoned") = None; - self.clear_statement_store_allowance_keys(session); - self.clear_bulletin_allowance_keys(session); - self.stop_disconnect_monitor(); - self.clear_product_subtrees(session); - } - - /// Best-effort `Disconnected` notification to the SSO peer. - #[instrument(skip_all, fields(runtime.method = "sso.disconnect.submit"))] - pub async fn submit_disconnected_message(&self, session: &SessionInfo) -> Result<(), String> { - let sso = session.sso.as_ref().ok_or_else(|| "No SSO session state".to_string())?; - let message_id = sso_message_id(); - let message = RemoteMessage { - message_id: message_id.clone(), - data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), - }; - let statement = self.build_request_channel_statement(sso, message_id, message, None)?; - self.statement_store - .submit_fire_and_forget(statement, "SSO statement-store") - .await - .map_err(|err| format!("SSO statement submit failed: {err}"))?; - Ok(()) - } - - /// Build a statement on the session's request channel, recording - /// `newest` as the request the channel now carries. - /// - /// The store keeps one statement per channel and the later build wins, so - /// the record and the build share one lock. - fn build_request_channel_statement( - &self, - sso: &SsoSessionInfo, - statement_request_id: String, - message: RemoteMessage, - newest: Option, - ) -> Result, String> { - let mut newest_request = self.newest_request.lock().expect("newest request mutex poisoned"); - let statement = build_outgoing_request_statement( - sso, - statement_request_id, - vec![message], - fresh_statement_expiry(), - )?; - *newest_request = newest; - Ok(statement) - } - - /// Withdraw the request sent as `message_id` from the paired host. - /// - /// A `Cancel` replaces the newest statement on the request channel, so it - /// is sent only while that is the request it names; otherwise it would - /// replace another request instead. Sent in the background, so it neither - /// holds up the withdrawn call's answer nor dies with its unwind grace. - fn withdraw_request(&self, sso: &SsoSessionInfo, message_id: &str) { - let withdrawal = { - let mut newest_request = - self.newest_request.lock().expect("newest request mutex poisoned"); - if newest_request.as_deref() != Some(message_id) { - return; - } - let cancel_id = sso_message_id(); - let message = RemoteMessage { - message_id: cancel_id.clone(), - data: RemoteMessageData::V1(v1::RemoteMessage::Cancel(Withdrawal { - message_id: message_id.to_string(), - })), - }; - *newest_request = None; - build_outgoing_request_statement( - sso, - cancel_id, - vec![message], - fresh_statement_expiry(), - ) - }; - let statement_store = self.statement_store.clone(); - let message_id = message_id.to_string(); - (self.spawner)(Box::pin(async move { - let submitted = match withdrawal { - Ok(statement) => statement_store - .submit_fire_and_forget(statement, "SSO statement-store") - .await - .map_err(|err| err.to_string()), - Err(reason) => Err(reason), - }; - if let Err(reason) = submitted { - warn!(%message_id, %reason, "could not withdraw the SSO request"); - } - })); - } - - /// Send `request` to the paired signing host and await its typed answer. - /// - /// The outer error is the transport's; the inner result is the peer's - /// payload for this request type. - #[instrument(skip_all, fields(runtime.method = "sso.remote_message.submit", action = R::NAME))] - async fn call( - &self, - cx: &CallContext, - session: &SessionInfo, - request: R, - ) -> Result { - let sso = session - .sso - .as_ref() - .ok_or_else(|| SsoRemoteResponseError::Failure("No SSO session state".to_string()))?; - let key = SsoSessionKey::from_session(sso); - let (_disconnect_guard, disconnect) = self.session_disconnects.subscribe(sso); - if !session_matches_key(&self.session_state, key) { - return Err(SsoRemoteResponseError::LocalDisconnected); - } - let message_id = sso_message_id(); - let statement = self - .build_request_channel_statement( - sso, - message_id.clone(), - RemoteMessage::request(message_id.clone(), request), - Some(message_id.clone()), - ) - .map_err(SsoRemoteResponseError::Failure)?; - let rpc_client = self - .statement_store - .client("SSO statement-store") - .await - .map_err(|err| SsoRemoteResponseError::Failure(err.to_string()))?; - let own_subscription = subscribe_statement_topic(&rpc_client, sso.session_id_own) - .await - .map_err(|err| { - SsoRemoteResponseError::Failure(format!( - "SSO own statement-store subscribe failed: {err}" - )) - })?; - let peer_subscription = subscribe_statement_topic(&rpc_client, sso.session_id_peer) - .await - .map_err(|err| { - SsoRemoteResponseError::Failure(format!( - "SSO peer statement-store subscribe failed: {err}" - )) - })?; - let submit_client = rpc_client.clone(); - let session_state = self.session_state.clone(); - let submitting = Arc::new(AtomicBool::new(false)); - let submit_started = submitting.clone(); - let submit = async move { - if !session_matches_key(&session_state, key) { - return Err(SsoRemoteResponseError::LocalDisconnected); - } - submit_started.store(true, Ordering::Release); - statement_store_rpc::submit_sso(&submit_client, statement, "pairing-host request") - .await - .map_err(|err| { - SsoRemoteResponseError::Failure(format!("SSO statement submit failed: {err}")) - }) - } - .boxed(); - let action = R::NAME; - debug!(action, %message_id, "submitted SSO remote message, awaiting response"); - let result = wait_for_sso_remote_response( - RemoteResponseWait { - own_statements: statement_subscription_stream(own_subscription, "own"), - peer_statements: statement_subscription_stream(peer_subscription, "peer"), - submit, - session: sso, - statement_request_id: &message_id, - remote_message_id: &message_id, - cancel: cx.cancel(), - disconnect: Some(disconnect), - }, - reply_matcher::(&message_id), - ) - .await; - let result = result.map_err(|reason| match reason { - SsoRemoteResponseError::Cancelled(err) if !cx.request_id().is_empty() => { - SsoRemoteResponseError::Cancelled(err.with_remote_message_id(cx.request_id())) - }, - reason => reason, - }); - match &result { - Ok(_) => debug!(action, %message_id, "SSO remote response received"), - Err(reason) => warn!(action, %message_id, %reason, "SSO remote message failed"), - } - // A request whose submit never started is not on the channel, and a - // `Cancel` for it would replace whatever older request is. - if let Err(SsoRemoteResponseError::Cancelled(err)) = &result && - err.reason() == CancellationReason::Cancelled && - submitting.load(Ordering::Acquire) && - session_matches_key(&self.session_state, key) - { - self.withdraw_request(sso, &message_id); - } - if matches!(&result, Err(SsoRemoteResponseError::PeerDisconnected)) { - self.handle_signing_host_disconnected(key).await; - } - result.map(|response| response.payload) - } - - /// Resolve a product's hard-subtree public key, asking the Account Holder - /// only when neither the memory cache nor storage already holds it. - pub async fn remote_product_subtree_public_key( - &self, - cx: &CallContext, - session: &SessionInfo, - product_id: String, - ) -> Result<[u8; 32], AuthorityError> { - let sso = session.sso.as_ref().ok_or(AuthorityError::Disconnected)?; - let lifecycle_epoch = self.current_session_lifecycle_epoch(); - let cache_key = (SsoSessionKey::from_session(sso), product_id.clone()); - if let Some(public_key) = self.known_product_subtree(session, cache_key.clone()).await { - return Ok(public_key); - } - let public_key = self - .call(cx, session, ProductSubtreeRequest { product_id }) - .await - .map_err(remote_authority_error)? - .map_err(remote_authority_error)?; - if !self - .persist_product_subtree_if_current(session, lifecycle_epoch, cache_key, public_key) - .await - { - return Err(AuthorityError::Disconnected); - } - Ok(public_key) - } - - /// Forward RFC-0023 VRF signing to the paired Account Holder. - pub async fn remote_sign_vrf( - &self, - cx: &CallContext, - session: &SessionInfo, - calling_product_id: String, - request: latest::HostAccountSignVrfRequest, - ) -> Result { - self.call(cx, session, ProductRequest { calling_product_id, payload: request }) - .await - .map_err(remote_authority_error)? - .map_err(|err| match err { - latest::HostAccountSignVrfError::NotConnected => AuthorityError::Disconnected, - latest::HostAccountSignVrfError::Rejected => AuthorityError::Rejected, - latest::HostAccountSignVrfError::Unknown { reason } => { - AuthorityError::Unknown { reason } - }, - }) - } - - /// Forward a payload-signing request to the paired signing host. - #[instrument(skip_all, fields(account_kind = match &request { - SignPayloadAuthorityRequest::Product(_) => "product", - SignPayloadAuthorityRequest::LegacyAccount { .. } => "legacy", - }))] - pub async fn remote_sign_payload( - &self, - cx: &CallContext, - session: &SessionInfo, - request: SignPayloadAuthorityRequest, - ) -> Result { - let request = match request { - SignPayloadAuthorityRequest::Product(request) => request, - SignPayloadAuthorityRequest::LegacyAccount { product_account, request } => { - latest::HostSignPayloadRequest { - account: product_account, - payload: request.payload, - } - }, - }; - self.call(cx, session, SignRequest::Payload(Box::new(request))) - .await - .map_err(remote_authority_error)? - .map_err(remote_authority_error) - } - - /// Forward a raw-signing request to the paired signing host. - #[instrument(skip_all, fields(account_kind = match &request { - SignRawAuthorityRequest::Product(_) => "product", - SignRawAuthorityRequest::LegacyAccount { .. } => "legacy", - }))] - pub async fn remote_sign_raw( - &self, - cx: &CallContext, - session: &SessionInfo, - request: SignRawAuthorityRequest, - watermarked: bool, - ) -> Result { - match request { - SignRawAuthorityRequest::Product(request) => self - .call( - cx, - session, - if watermarked { - SignRequest::Raw(request) - } else { - SignRequest::RawUnwatermarkedDeprecated(request) - }, - ) - .await - .map_err(remote_authority_error)? - .map_err(remote_authority_error), - SignRawAuthorityRequest::LegacyAccount { account, request } => { - let request = SignRawWithLegacyAccountRequest { account, data: request.payload }; - if !watermarked { - return self - .call( - cx, - session, - SignRequest::RawWithLegacyAccountUnwatermarkedDeprecated(request), - ) - .await - .map_err(remote_authority_error)? - .map_err(remote_authority_error); - } - let signature = self - .call(cx, session, request) - .await - .map_err(remote_authority_error)? - .map_err(remote_authority_error)?; - Ok(latest::HostSignPayloadResponse { signature, signed_transaction: None }) - }, - } - } - - /// Forward a transaction-creation request to the paired signing host. - #[instrument(skip_all, fields(account_kind = match &request { - CreateTransactionAuthorityRequest::Product(_) => "product", - CreateTransactionAuthorityRequest::LegacyAccount { .. } => "legacy", - CreateTransactionAuthorityRequest::IdentityAccount(_) => "identity", - }))] - pub async fn remote_create_transaction( - &self, - cx: &CallContext, - session: &SessionInfo, - request: CreateTransactionAuthorityRequest, - ) -> Result { - let signed = match request { - CreateTransactionAuthorityRequest::Product(payload) => { - self.call( - cx, - session, - CreateTransactionRequest { - payload: CreateTransactionPayload::V1(SsoProductTxPayload::from_resolved( - payload, - )), - }, - ) - .await - }, - CreateTransactionAuthorityRequest::LegacyAccount { product_account, request } => { - self.call( - cx, - session, - CreateTransactionRequest { - payload: CreateTransactionPayload::V1(SsoProductTxPayload { - signer: product_account, - genesis_hash: request.genesis_hash, - call_data: request.call_data, - extensions: request.extensions, - tx_ext_version: request.tx_ext_version, - }), - }, - ) - .await - }, - CreateTransactionAuthorityRequest::IdentityAccount(payload) => { - self.call( - cx, - session, - CreateTransactionWithLegacyAccountRequest { - payload: CreateTransactionLegacyPayload::V1(payload), - }, - ) - .await - }, - }; - signed - .map_err(remote_authority_error)? - .map(|transaction| latest::HostCreateTransactionResponse { transaction }) - .map_err(remote_authority_error) - } - - /// Forward a contextual-alias request to the paired signing host. - pub async fn remote_account_alias( - &self, - cx: &CallContext, - session: &SessionInfo, - request: ProductRequest, - ) -> Result { - self.call(cx, session, request).await.map_err(ring_vrf_transport_error)? - } - - /// Forward a ring-VRF proof request to the paired signing host. - pub async fn remote_create_proof( - &self, - cx: &CallContext, - session: &SessionInfo, - request: ProductRequest, - ) -> Result { - self.call(cx, session, request).await.map_err(ring_vrf_transport_error)? - } - - /// Forward a ring-VRF key registration request to the paired signing host. - pub async fn remote_register_ring_vrf_key( - &self, - cx: &CallContext, - session: &SessionInfo, - request: ProductRequest, - ) -> Result<[u8; 32], RingVrfError> { - self.call(cx, session, request).await.map_err(ring_vrf_transport_error)? - } - - /// Forward a ring-VRF key listing request to the paired signing host. - pub async fn remote_list_ring_vrf_keys( - &self, - cx: &CallContext, - session: &SessionInfo, - request: ProductRequest, - ) -> Result, RingVrfError> { - self.call(cx, session, request).await.map_err(ring_vrf_transport_error)? - } - - /// Forward a direct ring-VRF signing request to the paired signing host. - pub async fn remote_ring_vrf_sign( - &self, - cx: &CallContext, - session: &SessionInfo, - request: ProductRequest, - ) -> Result, RingVrfError> { - self.call(cx, session, request).await.map_err(ring_vrf_transport_error)? - } - - /// Ask the paired signing host to allocate product resources, caching any - /// returned allowance keys. - pub async fn remote_allocate_resources( - &self, - cx: &CallContext, - session: &SessionInfo, - product_id: String, - request: latest::HostRequestResourceAllocationRequest, - ) -> Result { - let lifecycle_epoch = self.current_session_lifecycle_epoch(); - let outcomes = self - .call( - cx, - session, - ResourceAllocationRequest { - calling_product_id: product_id.clone(), - resources: request.resources, - on_existing: OnExistingAllowancePolicy::Increase, - }, - ) - .await - .map_err(remote_authority_error)? - .map_err(remote_authority_error)?; - self.cache_allowance_outcomes(cx, session, lifecycle_epoch, &product_id, &outcomes) - .await?; - Ok(latest::HostRequestResourceAllocationResponse { - outcomes: outcomes.into_iter().map(Into::into).collect(), - }) - } - - /// Allocate exactly one allowance for the product and return its material. - async fn remote_allowance_slot( - &self, - cx: &CallContext, - session: &SessionInfo, - product_id: &str, - resource: latest::AllocatableResource, - on_existing: OnExistingAllowancePolicy, - ) -> Result { - let name = allowance_name(&resource); - let outcomes = self - .call( - cx, - session, - ResourceAllocationRequest { - calling_product_id: product_id.to_string(), - resources: vec![resource], - on_existing, - }, - ) - .await - .map_err(remote_authority_error)? - .map_err(remote_authority_error)?; - match outcomes.into_iter().next() { - Some(SsoAllocationOutcome::Allocated(resource)) => Ok(resource), - Some(SsoAllocationOutcome::Rejected) => Err(AuthorityError::Rejected), - Some(SsoAllocationOutcome::NotAvailable) => { - Err(AuthorityError::Unavailable { reason: format!("{name} is not available") }) - }, - None => Err(AuthorityError::Unknown { reason: format!("Empty {name} response") }), - } - } - - /// Statement-store allowance key for the product, served from the cache - /// or allocated by the paired signing host. - pub async fn remote_statement_store_allowance_key( - &self, - cx: &CallContext, - session: &SessionInfo, - product_id: String, - ) -> Result { - let lifecycle_epoch = self.current_session_lifecycle_epoch(); - if let Some(cached) = self - .cached_statement_store_allowance_key(session, lifecycle_epoch, &product_id) - .await? - { - return Ok(cached); - } - match self - .remote_allowance_slot( - cx, - session, - &product_id, - latest::AllocatableResource::StatementStoreAllowance, - OnExistingAllowancePolicy::Ignore, - ) - .await? - { - SsoAllocatedResource::StatementStoreAllowance { slot_account_key } => { - self.cache_statement_store_allowance_key( - session, - lifecycle_epoch, - &product_id, - slot_account_key, - ) - .await - }, - other => Err(unexpected_resource("statement-store allowance", &other)), - } - } - - /// Bulletin allowance key for the product, served from the cache or - /// allocated by the paired signing host. - pub async fn remote_bulletin_allowance_key( - &self, - cx: &CallContext, - session: &SessionInfo, - product_id: String, - ) -> Result { - let lifecycle_epoch = self.current_session_lifecycle_epoch(); - if let Some(cached) = self - .cached_bulletin_allowance_key(session, lifecycle_epoch, &product_id) - .await? - { - return Ok(cached); - } - self.allocate_bulletin_allowance_key( - cx, - session, - lifecycle_epoch, - product_id, - OnExistingAllowancePolicy::Ignore, - ) - .await - } - - /// Evict the cached Bulletin allowance key and allocate a fresh one with - /// an increased allowance, so a stale or exhausted slot is never reused. - pub async fn remote_refresh_bulletin_allowance_key( - &self, - cx: &CallContext, - session: &SessionInfo, - product_id: String, - ) -> Result { - let lifecycle_epoch = self.current_session_lifecycle_epoch(); - self.evict_bulletin_allowance_key(session, lifecycle_epoch, &product_id).await?; - self.allocate_bulletin_allowance_key( - cx, - session, - lifecycle_epoch, - product_id, - OnExistingAllowancePolicy::Increase, - ) - .await - } - - async fn allocate_bulletin_allowance_key( - &self, - cx: &CallContext, - session: &SessionInfo, - lifecycle_epoch: u64, - product_id: String, - on_existing: OnExistingAllowancePolicy, - ) -> Result { - match self - .remote_allowance_slot( - cx, - session, - &product_id, - latest::AllocatableResource::BulletinAllowance, - on_existing, - ) - .await? - { - SsoAllocatedResource::BulletinAllowance { slot_account_key } => { - self.cache_bulletin_allowance_key( - session, - lifecycle_epoch, - &product_id, - slot_account_key, - ) - .await - }, - other => Err(unexpected_resource("bulletin allowance", &other)), - } - } - - async fn cache_allowance_outcomes( - &self, - cx: &CallContext, - session: &SessionInfo, - lifecycle_epoch: u64, - product_id: &str, - outcomes: &[SsoAllocationOutcome], - ) -> Result<(), AuthorityError> { - for outcome in outcomes { - if let SsoAllocationOutcome::Allocated(resource) = outcome { - match resource { - SsoAllocatedResource::StatementStoreAllowance { slot_account_key } => { - self.cache_statement_store_allowance_key( - session, - lifecycle_epoch, - product_id, - slot_account_key.clone(), - ) - .await?; - }, - SsoAllocatedResource::BulletinAllowance { slot_account_key } => { - self.cache_bulletin_allowance_key( - session, - lifecycle_epoch, - product_id, - slot_account_key.clone(), - ) - .await?; - }, - SsoAllocatedResource::SmartContractAllowance => {}, - SsoAllocatedResource::AutoSigning { - product_root_private_key, - ring_vrf_domain_entropy, - } => { - let expected_product_subtree_public_key = self - .remote_product_subtree_public_key(cx, session, product_id.to_string()) - .await?; - self.remember_auto_signing_key( - session, - lifecycle_epoch, - product_id, - expected_product_subtree_public_key, - *product_root_private_key, - *ring_vrf_domain_entropy, - ) - .await?; - }, - } - } - } - Ok(()) - } -} - -/// True when the current session's SSO channel matches `key`. -pub fn session_matches_key(session_state: &SessionState, key: SsoSessionKey) -> bool { - session_state.current().as_ref().is_some_and(|current| { - current.sso.as_ref().is_some_and(|sso| SsoSessionKey::from_session(sso) == key) - }) -} - -fn ring_vrf_transport_error(reason: SsoRemoteResponseError) -> RingVrfError { - remote_authority_error(reason).into() -} - -fn allowance_name(resource: &latest::AllocatableResource) -> &'static str { - match resource { - latest::AllocatableResource::StatementStoreAllowance => "statement-store allowance", - latest::AllocatableResource::BulletinAllowance => "bulletin allowance", - _ => "resource", - } -} - -/// Reason for an allocation that came back as a different resource kind; names -/// only the kind so no key material reaches logs. -fn unexpected_resource(label: &str, resource: &SsoAllocatedResource) -> AuthorityError { - AuthorityError::Unknown { - reason: format!("Unexpected {label} response resource: {}", resource.kind()), - } -} - -fn remote_authority_error(reason: impl Into) -> AuthorityError { - match reason.into() { - SsoRemoteResponseError::Cancelled(err) => AuthorityError::Cancelled( - AuthorityCancelError::new(err.remote_message_id(), err.reason()), - ), - SsoRemoteResponseError::LocalDisconnected | SsoRemoteResponseError::PeerDisconnected => { - AuthorityError::Disconnected - }, - SsoRemoteResponseError::Failure(reason) => match reason.as_str() { - "Rejected" | "User rejected" => AuthorityError::Rejected, - SSO_LOCAL_DISCONNECT_REASON | SSO_PEER_DISCONNECT_REASON => { - AuthorityError::Disconnected - }, - _ => AuthorityError::Unknown { reason }, - }, - } -} - -#[instrument(skip_all, fields(runtime.method = "sso.peer_disconnect.monitor"))] -async fn wait_for_sso_peer_disconnect( - statement_store: StatementStoreRpc, - session: SsoSessionInfo, -) -> Result<(), String> { - let rpc_client = statement_store - .client("SSO disconnect monitor") - .await - .map_err(|err| err.to_string())?; - let mut subscription = - statement_store_rpc::subscribe_match_all(&rpc_client, &[session.session_id_peer]) - .await - .map_err(|err| format!("SSO disconnect monitor subscribe failed: {err}"))?; - while let Some(item) = subscription.next().await { - let value = item.map_err(|err| format!("SSO disconnect monitor item failed: {err}"))?; - let page = parse_new_statements_result("sso-peer-disconnect-monitor".to_string(), &value) - .map_err(|err| err.to_string())?; - for statement in page.statements { - let Some(SsoSessionStatement::RemoteMessages(messages)) = decode_sso_session_statement( - &session, - &statement, - "truapi:sso-peer-disconnect-monitor", - )? - else { - continue; - }; - for message in messages { - if message == Ok(v1::RemoteMessage::Disconnected) { - return Ok(()); - } - } - } - } - Err("SSO disconnect monitor response stream ended".to_string()) -} - -impl From for latest::AllocationOutcome { - fn from(outcome: SsoAllocationOutcome) -> Self { - match outcome { - SsoAllocationOutcome::Allocated(_) => Self::Allocated, - SsoAllocationOutcome::Rejected => Self::Rejected, - SsoAllocationOutcome::NotAvailable => Self::NotAvailable, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn unexpected_resource_reasons_include_only_safe_discriminants() { - let resource = SsoAllocatedResource::AutoSigning { - product_root_private_key: [0xA5; 64], - ring_vrf_domain_entropy: [0x5A; 32], - }; - - let AuthorityError::Unknown { reason } = - unexpected_resource("statement-store allowance", &resource) - else { - panic!("expected an unknown authority error"); - }; - - assert_eq!(reason, "Unexpected statement-store allowance response resource: auto-signing"); - assert!(!reason.contains("165, 165")); - } -} diff --git a/rust/crates/truapi/src/runtime/product_accounts.rs b/rust/crates/truapi/src/runtime/product_accounts.rs index cf7480cf10..7909f0e54f 100644 --- a/rust/crates/truapi/src/runtime/product_accounts.rs +++ b/rust/crates/truapi/src/runtime/product_accounts.rs @@ -1,49 +1,53 @@ //! The account operations one product connection may call, bound to its product. use std::sync::Arc; +#[cfg(not(target_arch = "wasm32"))] +use std::time::Instant; -use truapi::{ - CallContext, - latest::{ - HostAccountCreateProofRequest, HostAccountCreateProofResponse, HostAccountGetAliasRequest, - HostAccountGetAliasResponse, HostAccountListRingVrfKeysRequest, - HostAccountListRingVrfKeysResponse, HostAccountRegisterRingVrfKeyRequest, - HostAccountRegisterRingVrfKeyResponse, HostAccountRingVrfSignRequest, - HostAccountRingVrfSignResponse, HostAccountSignVrfRequest, HostCreateTransactionResponse, - HostRequestResourceAllocationRequest, HostRequestResourceAllocationResponse, - HostSignPayloadResponse, ProductAccountId, VrfSignature, - }, -}; +use truapi::{CallContext, CallError, latest as api}; +#[cfg(target_arch = "wasm32")] +use web_time::Instant; #[cfg(test)] use crate::host_logic::session::SessionInfo; use crate::{ - host_internal::sso_messages::{ProductRequest, RingVrfError}, + host_internal::{permissions::TemporaryPermissions, sso_messages::RingVrfError}, platform::ProductContext, }; -use super::authority::{ - AuthorityError, AuthoritySession, AutoSigningGrant, BulletinAllowanceKey, - CreateTransactionAuthorityRequest, ProductAuthority, SignPayloadAuthorityRequest, - SignRawAuthorityRequest, StatementStoreAllowanceKey, +use super::{ + HostAccounts, + authority::{ + AccountHolder, AuthorityError, AuthoritySession, CreateTransactionAuthorityRequest, + SignPayloadAuthorityRequest, SignRawAuthorityRequest, + }, + bulletin_rpc::BulletinSubmitError, + contacts::ContactHandles, + statement_store::StatementProofFailure, }; -/// The host's account authority as one product connection sees it: every call -/// acts for that product, and host-level operations stay out of reach. -pub struct ProductAccounts { - authority: Arc, +/// The host's accounts as one product connection sees them: every call acts for +/// that product, and host-level operations stay out of reach. +pub struct ProductAccounts { + accounts: Arc>, product: ProductContext, } -impl ProductAccounts { - /// Bind the host's account authority to the product of one connection. - pub fn new(authority: Arc, product: ProductContext) -> Self { - Self { authority, product } +impl ProductAccounts { + /// Bind the host's accounts to the product of one connection. + pub fn new(accounts: Arc>, product: ProductContext) -> Self { + Self { accounts, product } + } + + /// The shared accounts behind this view, for tests that rebuild a connection. + #[cfg(test)] + pub fn host_accounts_for_tests(&self) -> &Arc> { + &self.accounts } /// Current account-authority session, if connected. pub fn current_session(&self) -> Option { - self.authority.current_session() + self.accounts.current_session() } /// Seed a product subtree for authority tests. @@ -54,38 +58,39 @@ impl ProductAccounts { product_id: &str, public_key: [u8; 32], ) { - self.authority.cache_product_subtree_for_test(session, product_id, public_key); + self.accounts.cache_product_subtree_for_test(session, product_id, public_key); } - /// Resolve the public key of `product_id`'s hard subtree. - pub async fn product_subtree_public_key( + /// The owner this product may act for when it names `dot_ns_identifier`, + /// or `None` when it may not. + pub async fn authorized_product_account( &self, - session: &AuthoritySession, + dot_ns_identifier: &str, cx: &CallContext, - product_id: String, - ) -> Result<[u8; 32], AuthorityError> { - self.authority.product_subtree_public_key(cx, session, product_id).await + ) -> Option { + self.accounts + .authorized_product_account(&self.product, dot_ns_identifier, cx) + .await } - /// Whether resolving `product_id`'s subtree needs SSO and host consent. - pub async fn subtree_resolution_reaches_account_holder( + /// Public key of `account`, after any account-access check it needs. + pub async fn get_account( &self, - session: &AuthoritySession, - product_id: &str, - ) -> bool { - self.authority - .subtree_resolution_reaches_account_holder(session, product_id) - .await + cx: &CallContext, + account: api::ProductAccountId, + ) -> Result<[u8; 32], CallError> { + self.accounts.get_account(cx, &self.product, account).await } - /// Whether this product's account can sign without confirmation. - pub async fn auto_signing_status( + /// Public key of a product account this product may already use. + pub async fn product_account_public_key( &self, + cx: &CallContext, session: &AuthoritySession, - account: &ProductAccountId, - ) -> Result { - self.authority - .auto_signing_status(session, &self.product.product_id, account) + account: &api::ProductAccountId, + ) -> Result<[u8; 32], AuthorityError> { + self.accounts + .product_account_public_key(cx, session, &self.product, account) .await } @@ -94,11 +99,9 @@ impl ProductAccounts { &self, session: &AuthoritySession, cx: &CallContext, - request: HostAccountSignVrfRequest, - ) -> Result { - self.authority - .sign_vrf(cx, session, self.product.product_id.clone(), request) - .await + request: api::HostAccountSignVrfRequest, + ) -> Result { + self.accounts.sign_vrf(session, cx, &self.product, request).await } /// Sign a SCALE transaction payload for a product account. @@ -106,11 +109,10 @@ impl ProductAccounts { &self, session: &AuthoritySession, cx: &CallContext, + approvals: &TemporaryPermissions, request: SignPayloadAuthorityRequest, - ) -> Result { - self.authority - .sign_payload(cx, session, Some(&self.product.product_id), request) - .await + ) -> Result { + self.accounts.sign_payload(session, cx, &self.product, approvals, request).await } /// Sign arbitrary bytes for a product account. @@ -118,11 +120,12 @@ impl ProductAccounts { &self, session: &AuthoritySession, cx: &CallContext, + approvals: &TemporaryPermissions, request: SignRawAuthorityRequest, watermarked: bool, - ) -> Result { - self.authority - .sign_raw(cx, session, Some(&self.product.product_id), request, watermarked) + ) -> Result { + self.accounts + .sign_raw(session, cx, &self.product, approvals, request, watermarked) .await } @@ -131,10 +134,11 @@ impl ProductAccounts { &self, session: &AuthoritySession, cx: &CallContext, + approvals: &TemporaryPermissions, request: CreateTransactionAuthorityRequest, - ) -> Result { - self.authority - .create_transaction(cx, session, Some(&self.product.product_id), request) + ) -> Result { + self.accounts + .create_transaction(session, cx, &self.product, approvals, request) .await } @@ -143,9 +147,9 @@ impl ProductAccounts { &self, session: &AuthoritySession, cx: &CallContext, - request: HostAccountGetAliasRequest, - ) -> Result { - self.authority.account_alias(cx, session, self.request(request)).await + request: api::HostAccountGetAliasRequest, + ) -> Result { + self.accounts.account_alias(session, cx, &self.product, request).await } /// Create a ring-VRF proof bound to a context and message. @@ -153,9 +157,9 @@ impl ProductAccounts { &self, session: &AuthoritySession, cx: &CallContext, - request: HostAccountCreateProofRequest, - ) -> Result { - self.authority.create_proof(cx, session, self.request(request)).await + request: api::HostAccountCreateProofRequest, + ) -> Result { + self.accounts.create_proof(session, cx, &self.product, request).await } /// Register a ring-VRF key owned by this product. @@ -163,9 +167,9 @@ impl ProductAccounts { &self, session: &AuthoritySession, cx: &CallContext, - request: HostAccountRegisterRingVrfKeyRequest, - ) -> Result { - self.authority.register_ring_vrf_key(cx, session, self.request(request)).await + request: api::HostAccountRegisterRingVrfKeyRequest, + ) -> Result<[u8; 32], RingVrfError> { + self.accounts.register_ring_vrf_key(session, cx, &self.product, request).await } /// List registered ring-VRF keys. @@ -173,9 +177,9 @@ impl ProductAccounts { &self, session: &AuthoritySession, cx: &CallContext, - request: HostAccountListRingVrfKeysRequest, - ) -> Result { - self.authority.list_ring_vrf_keys(cx, session, self.request(request)).await + request: api::HostAccountListRingVrfKeysRequest, + ) -> Result, RingVrfError> { + self.accounts.list_ring_vrf_keys(session, cx, &self.product, request).await } /// Sign bytes directly with a registered ring-VRF key. @@ -183,9 +187,9 @@ impl ProductAccounts { &self, session: &AuthoritySession, cx: &CallContext, - request: HostAccountRingVrfSignRequest, - ) -> Result { - self.authority.ring_vrf_sign(cx, session, self.request(request)).await + request: api::HostAccountRingVrfSignRequest, + ) -> Result, RingVrfError> { + self.accounts.ring_vrf_sign(session, cx, &self.product, request).await } /// Allocate resources for this product. @@ -193,55 +197,36 @@ impl ProductAccounts { &self, cx: &CallContext, session: &AuthoritySession, - request: HostRequestResourceAllocationRequest, - ) -> Result { - self.authority - .allocate_resources(cx, session, self.product.product_id.clone(), request) - .await + request: api::HostRequestResourceAllocationRequest, + ) -> Result { + self.accounts.allocate_resources(cx, session, &self.product, request).await } - /// Statement-store allowance key material for this product. - pub async fn statement_store_allowance_key( + /// Renew this product's retained statement sponsorship for `signer`. + pub async fn renew_statement_sponsorship( &self, cx: &CallContext, - session: &AuthoritySession, - ) -> Result { - self.authority - .statement_store_allowance_key(cx, session, self.product.product_id.clone()) - .await - } - - /// Forget this product's kept Statement Store key if it is still `public_key`. - pub fn forget_statement_store_allowance_key(&self, public_key: [u8; 32]) { - self.authority - .forget_statement_store_allowance_key(&self.product.product_id, public_key); + signer: [u8; 32], + ) -> Result<(), AuthorityError> { + self.accounts.renew_statement_sponsorship(cx, &self.product, signer).await } /// Whether preimage submissions stay in the core instead of reaching Bulletin. #[cfg(feature = "test-host")] pub fn submits_preimages_locally(&self) -> bool { - self.authority.submits_preimages_locally() + self.accounts.submits_preimages_locally() } - /// Bulletin allowance key material for this product. - pub async fn bulletin_allowance_key( + /// Store `value` on Bulletin under this product's allowance before `deadline`. + pub async fn submit_preimage( &self, cx: &CallContext, + deadline: Instant, session: &AuthoritySession, - ) -> Result { - self.authority - .bulletin_allowance_key(cx, session, self.product.product_id.clone()) - .await - } - - /// Replace this product's Bulletin key after a submission was refused. - pub async fn refresh_bulletin_allowance_key( - &self, - cx: &CallContext, - session: &AuthoritySession, - ) -> Result { - self.authority - .refresh_bulletin_allowance_key(cx, session, self.product.product_id.clone()) + value: &[u8], + ) -> Result, BulletinSubmitError> { + self.accounts + .submit_preimage(cx, deadline, session, self.product.product_id.clone(), value) .await } @@ -250,38 +235,53 @@ impl ProductAccounts { &self, session: &AuthoritySession, cx: &CallContext, - account: ProductAccountId, + approvals: &TemporaryPermissions, + account: api::ProductAccountId, payload: Vec, ) -> Result<[u8; 64], AuthorityError> { - self.authority + self.accounts .sign_statement_store_product_payload( - cx, session, - Some(&self.product.product_id), + cx, + &self.product, + approvals, account, payload, ) .await } + /// Prove `statement` with this product's Statement Store allowance. + pub async fn create_authorized_statement_proof( + &self, + cx: &CallContext, + session: &AuthoritySession, + statement: api::Statement, + ) -> Result { + self.accounts + .create_authorized_statement_proof( + cx, + session, + self.product.product_id.clone(), + statement, + ) + .await + } + /// Derive entropy scoped to this product. pub fn derive_entropy( &self, session: &AuthoritySession, context: &[u8], ) -> Result<[u8; 32], AuthorityError> { - self.authority.derive_entropy(session, &self.product.product_id, context) + self.accounts.derive_entropy(session, &self.product.product_id, context) } - /// Root-derived key for contact handles that stay stable across products. - pub fn contacts_handle_key( + /// Contact handles that stay stable across products. + pub fn contact_handles( &self, session: &AuthoritySession, - ) -> Result<[u8; 32], AuthorityError> { - self.authority.contacts_handle_key(session) - } - - fn request

(&self, payload: P) -> ProductRequest

{ - ProductRequest { calling_product_id: self.product.product_id.clone(), payload } + ) -> Result { + self.accounts.contact_handles(session) } } diff --git a/rust/crates/truapi/src/runtime/product_consent.rs b/rust/crates/truapi/src/runtime/product_consent.rs new file mode 100644 index 0000000000..2f69df253d --- /dev/null +++ b/rust/crates/truapi/src/runtime/product_consent.rs @@ -0,0 +1,287 @@ +//! Account-access decisions and operation-review rules shared by local and paired callers. + +use std::{ + collections::HashSet, + sync::{Arc, Mutex}, +}; + +use truapi::{CallContext, latest::GenericError}; + +use super::authority::{AccountCaller, AuthorityError}; +use crate::{ + host_internal::{ + permissions::{ + TemporaryPermissions, account_access_status, cross_product_signature_key, + set_account_access_status, + }, + product_manifest::bare_product_label, + }, + platform::{ + AccountAccessReview, CreateTransactionReview, CrossProductSignatureKind, + PermissionAuthorizationStatus, PermissionDecision, Platform, ProductContext, + SignPayloadReview, SignRawReview, UserConfirmationReview, has_trusted_remote_permissions, + normalize_product_identifier, normalizes_to_trusted_remote_permissions, + }, +}; + +/// One runtime's consent policy: one prompt channel, one store, one set of rules. +pub struct ProductConsent { + platform: Arc, + permission_prompt: futures::lock::Mutex<()>, + allowed_once: Mutex>, +} + +impl ProductConsent { + /// Ask through `platform`, the runtime-wide channel, and store decisions there. + pub fn new(platform: Arc) -> Self { + Self { + platform, + permission_prompt: futures::lock::Mutex::new(()), + allowed_once: Mutex::default(), + } + } + + /// Whether `requester` may use `target`'s accounts, prompting when nothing is decided. + /// + /// Allow once lasts until this runtime forgets it; only one prompt runs at a time, + /// and a request that waited for it reuses the answer. + pub async fn account_access( + &self, + requester: &str, + target: &str, + ) -> Result { + if requester == target || normalizes_to_trusted_remote_permissions(requester) { + return Ok(PermissionAuthorizationStatus::Authorized); + } + // Filed per product, the granularity a manifest grant uses, so a refused + // product cannot keep access by respelling itself under a subname. + let pair = + (bare_product_label(requester).to_string(), bare_product_label(target).to_string()); + if let Some(status) = self.decided(&pair).await? { + return Ok(status); + } + let _prompt = self.permission_prompt.lock().await; + if let Some(status) = self.decided(&pair).await? { + return Ok(status); + } + let decision = self + .platform + .confirm_permission(UserConfirmationReview::AccountAccess(AccountAccessReview { + requesting_product_id: requester.to_string(), + target_product_id: target.to_string(), + })) + .await + .map_err(AccountAccessError::Confirmation)?; + let status = match decision { + PermissionDecision::AllowOnce => { + self.allowed_once.lock().expect("account access mutex poisoned").insert(pair); + return Ok(PermissionAuthorizationStatus::Authorized); + }, + PermissionDecision::AllowAlways => PermissionAuthorizationStatus::Authorized, + PermissionDecision::Deny => PermissionAuthorizationStatus::Denied, + }; + set_account_access_status(self.platform.as_ref(), &pair.0, &pair.1, status) + .await + .map_err(AccountAccessError::PermissionStorage)?; + Ok(status) + } + + /// Confirm an operation with the user unless `caller` may skip its review. + pub async fn review( + &self, + cx: &CallContext, + caller: AccountCaller<'_>, + review: UserConfirmationReview, + ) -> Result<(), AuthorityError> { + if skips_review(caller, &review) { + return Ok(()); + } + let approved = super::until_cancelled(cx, self.platform.confirm_user_action(review)) + .await? + .map_err(AuthorityError::ConfirmationFailed)?; + if approved { Ok(()) } else { Err(AuthorityError::Rejected) } + } + + /// Confirm `product`'s signature with an account `owner` holds. + /// + /// Another product's account is confirmed once per kind of signature and owning + /// product, for as long as `approvals`' execution lasts. Signature prompts open + /// one at a time, and a signature already approved never waits for one. + pub async fn review_signature( + &self, + cx: &CallContext, + product: &ProductContext, + approvals: &TemporaryPermissions, + kind: CrossProductSignatureKind, + owner: &str, + review: UserConfirmationReview, + ) -> Result<(), AuthorityError> { + let caller = AccountCaller::Local { product }; + if owner == product.product_id { + return self.review(cx, caller, review).await; + } + let approval = cross_product_signature_key(&product.product_id, owner, kind); + if approvals.authorize(&approval, false) { + return Ok(()); + } + let _prompt = super::until_cancelled(cx, approvals.signature_prompt()).await?; + if approvals.authorize(&approval, false) { + return Ok(()); + } + self.review(cx, caller, review).await?; + approvals.grant(approval); + Ok(()) + } + + /// Forget every Allow once answer, as on lock or a wallet change. + pub fn forget_allowed_once(&self) { + self.allowed_once.lock().expect("account access mutex poisoned").clear(); + } + + /// Forget the Allow once answers `product_id` received, as on its reset. + pub fn forget_allowed_once_for(&self, product_id: &str) { + let requester = bare_product_label(product_id); + self.allowed_once + .lock() + .expect("account access mutex poisoned") + .retain(|(granted, _)| granted != requester); + } + + /// A stored decision wins over Allow once, so a change made in settings applies at once. + async fn decided( + &self, + (requester, target): &(String, String), + ) -> Result, AccountAccessError> { + let stored = account_access_status(self.platform.as_ref(), requester, target) + .await + .map_err(AccountAccessError::PermissionStorage)?; + if stored != PermissionAuthorizationStatus::NotDetermined { + return Ok(Some(stored)); + } + let allowed_once = self + .allowed_once + .lock() + .expect("account access mutex poisoned") + .contains(&(requester.clone(), target.clone())); + Ok(allowed_once.then_some(PermissionAuthorizationStatus::Authorized)) + } +} + +/// Why an account-access decision could not be made. +#[derive(Debug, thiserror::Error)] +pub enum AccountAccessError { + /// The decision could not be read or written. + #[error("permission storage failed: {0:?}")] + PermissionStorage(GenericError), + /// The prompt could not be shown or answered. + #[error("account access confirmation failed: {0:?}")] + Confirmation(GenericError), +} + +/// Reviews a product bound by this host may skip; a paired caller never skips one. +fn skips_review(caller: AccountCaller<'_>, review: &UserConfirmationReview) -> bool { + let AccountCaller::Local { product } = caller else { + return false; + }; + let first_party = has_trusted_remote_permissions(&product.product_id); + match review { + UserConfirmationReview::SignPayload(SignPayloadReview::Product { .. }) | + UserConfirmationReview::SignRaw(SignRawReview::Product { .. }) | + UserConfirmationReview::CreateTransaction(CreateTransactionReview::Product { .. }) | + UserConfirmationReview::ResourceAllocation(_) | + UserConfirmationReview::ProductSubtree(_) => first_party, + UserConfirmationReview::StatementStoreProductSign(review) => { + first_party || review.account.dot_ns_identifier == product.product_id + }, + UserConfirmationReview::SignVrf(review) => { + first_party && + normalize_product_identifier(&review.request.account.dot_ns_identifier) + .is_ok_and(|owner| owner == product.product_id) + }, + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::StubPlatform; + use PermissionAuthorizationStatus::{Authorized, Denied}; + + fn consent_answering( + decisions: impl IntoIterator, + ) -> (Arc, ProductConsent) { + let platform = Arc::new(StubPlatform { + permission_confirmation_decisions: Mutex::new(decisions.into_iter().collect()), + ..StubPlatform::default() + }); + (platform.clone(), ProductConsent::new(platform)) + } + + fn access(consent: &ProductConsent, requester: &str) -> PermissionAuthorizationStatus { + futures::executor::block_on(consent.account_access(requester, "peopl.dot")) + .expect("the stub answers every prompt") + } + + fn prompts(platform: &StubPlatform) -> usize { + platform.account_access_reviews.lock().unwrap().len() + } + + /// Resetting a product drops what it was allowed once, not what others were. + #[test] + fn a_product_reset_forgets_only_that_products_allow_once() { + let (platform, consent) = consent_answering([ + PermissionDecision::AllowOnce, + PermissionDecision::AllowOnce, + PermissionDecision::Deny, + ]); + access(&consent, "myapp.dot"); + access(&consent, "other.dot"); + consent.forget_allowed_once_for("myapp.dot"); + assert_eq!( + (access(&consent, "other.dot"), access(&consent, "myapp.dot"), prompts(&platform)), + (Authorized, Denied, 3) + ); + } + + /// A decision changed in settings applies at once, even over Allow once. + #[test] + fn a_stored_decision_overrides_allow_once() { + let (platform, consent) = consent_answering([PermissionDecision::AllowOnce]); + access(&consent, "myapp.dot"); + futures::executor::block_on(set_account_access_status( + platform.as_ref(), + "myapp", + "peopl", + Denied, + )) + .unwrap(); + assert_eq!((access(&consent, "myapp.dot"), prompts(&platform)), (Denied, 1)); + } + + /// Two calls racing for the same decision raise one prompt, and the + /// second reuses its answer instead of asking again. + #[test] + fn a_request_waiting_for_the_prompt_reuses_its_answer() { + let (release, gate) = futures::channel::oneshot::channel(); + let platform = Arc::new(StubPlatform { + permission_confirmation_decisions: Mutex::new( + [PermissionDecision::AllowOnce, PermissionDecision::Deny].into(), + ), + account_access_confirmation_gate: Mutex::new(Some(gate)), + ..StubPlatform::default() + }); + let consent = ProductConsent::new(platform.clone()); + let (first, second, ()) = futures::executor::block_on(async { + futures::join!( + consent.account_access("myapp.dot", "peopl.dot"), + consent.account_access("myapp.dot", "peopl.dot"), + async { release.send(()).unwrap() }, + ) + }); + assert_eq!( + (first.unwrap(), second.unwrap(), prompts(&platform)), + (Authorized, Authorized, 1) + ); + } +} diff --git a/rust/crates/truapi/src/runtime/product_manifest.rs b/rust/crates/truapi/src/runtime/product_manifest.rs index daaccf4f6b..6b264cfa06 100644 --- a/rust/crates/truapi/src/runtime/product_manifest.rs +++ b/rust/crates/truapi/src/runtime/product_manifest.rs @@ -496,23 +496,9 @@ pub async fn ring_vrf_key_access_granted( calling_product_id: &str, handle: &v01::ProductAccountId, ) -> Result { - // A caller id that does not normalize names no product, so it holds no key - // and no manifest can grant it. It takes the same refusal as a product that - // granted nothing rather than an error carrying the string back: on the wire - // path this field is peer-supplied, and one refusal for every reason is the - // whole design of this seam. let Ok(caller) = normalize_product_identifier(calling_product_id) else { return Err(RingVrfError::NotAllowlisted); }; - // The handle is normalized here, not only at the frontend. The frontend - // does it before delegating, but `sso_responder` hands a wire request - // straight to the authority unnormalized, so without this the two doors - // disagree: an owner naming its own key `PEOPL.DOT` over the wire is - // refused where the same request from a local product runtime succeeds. - // - // A handle that does not normalize names no product, so it owns no key and - // no manifest can grant it: it takes the same refusal as a product that - // granted nothing, rather than a distinguishable error. let Ok(owner) = normalize_product_identifier(&handle.dot_ns_identifier) else { return Err(RingVrfError::NotAllowlisted); }; diff --git a/rust/crates/truapi/src/runtime/remote_account_holder.rs b/rust/crates/truapi/src/runtime/remote_account_holder.rs new file mode 100644 index 0000000000..8edb1d6dba --- /dev/null +++ b/rust/crates/truapi/src/runtime/remote_account_holder.rs @@ -0,0 +1,589 @@ +//! Canonical account operations translated to the existing paired SSO protocol. + +use super::{ + HostSession, + allowances::AllowanceResource, + authority::{ + AccountGrant, AccountGrantOutcome, AccountHolder, AccountInvocation, AuthorityCancelError, + AuthorityError, AuthoritySession, AutoSigningKey, BulletinAllowanceKey, + CreateTransactionAuthorityRequest, GrantKeeping, SignPayloadAuthorityRequest, + SignRawAuthorityRequest, StatementStoreAllowanceKey, SubtreeResolution, + require_current_session, + }, + ring_vrf_registry::{RingVrfRegistryStore, apply_ring_vrf_disclosure, validate_owner_listing}, + sso_remote::{SSO_LOCAL_DISCONNECT_REASON, SSO_PEER_DISCONNECT_REASON, SsoRemoteResponseError}, + sso_request_service::SsoRequestService, +}; +use crate::{ + host_internal::{ + sso_messages::{ + CreateTransactionLegacyPayload, CreateTransactionPayload, CreateTransactionRequest, + CreateTransactionWithLegacyAccountRequest, OnExistingAllowancePolicy, ProductRequest, + ProductSubtreeRequest, ResourceAllocationRequest, RingVrfError, + SignRawWithLegacyAccountRequest, SignRequest, SsoAllocatedResource, + SsoAllocationOutcome, SsoProductTxPayload, + }, + sso_wire::SsoRequest, + }, + host_logic::entropy::derive_product_entropy_from_source, + platform::normalize_product_identifier, + subscription::Spawner, +}; +use futures::{ + StreamExt, + stream::{self, BoxStream}, +}; +use std::sync::Arc; +use truapi::{CallContext, latest}; + +/// Account-holder requests over one runtime's selected SSO channel. +pub struct RemoteAccountHolder { + service: Arc, + registry: Arc, + spawner: Spawner, +} + +impl RemoteAccountHolder { + /// Use the runtime's existing session and outbound transport, and keep + /// `registry` as this host's copy of the phone's ring-VRF registrations. + pub fn new( + service: Arc, + registry: Arc, + spawner: Spawner, + ) -> Self { + Self { service, registry, spawner } + } + + async fn remember_registration( + &self, + invocation: &AccountInvocation<'_>, + request: latest::HostAccountRegisterRingVrfKeyRequest, + public_key: [u8; 32], + ) -> Result<(), RingVrfError> { + let owner = invocation.caller.product_id().ok_or(RingVrfError::NotAllowlisted)?; + let handle = latest::ProductAccountId { + dot_ns_identifier: normalize_product_identifier(owner) + .map_err(|error| RingVrfError::Unknown { reason: error.to_string() })?, + derivation_index: request.index, + }; + let mut update = self.registry.prepare_update(invocation.session.public_key).await?; + self.require_current_session(invocation.session)?; + update.register(handle, request.ring, public_key)?; + update.persist().await + } + + /// Register on the phone in the background; the host already answered from its kept key. + fn forward_registration( + &self, + invocation: &AccountInvocation<'_>, + request: latest::HostAccountRegisterRingVrfKeyRequest, + ) -> Result<(), RingVrfError> { + let calling_product_id = + invocation.caller.product_id().ok_or(RingVrfError::NotAllowlisted)?.to_string(); + let service = Arc::downgrade(&self.service); + let session = invocation.session.clone(); + (self.spawner)(Box::pin(async move { + let Some(service) = service.upgrade() else { + return; + }; + let cx = CallContext::with_request_id(format!( + "ring-vrf-registration-mirror:{}", + super::sso_remote::sso_message_id() + )); + let request = ProductRequest { calling_product_id, payload: request }; + let result = forward(&service, &cx, &session, request) + .await + .map_err(RingVrfError::from) + .and_then(|answer| answer); + if let Err(error) = result { + tracing::warn!(?error, "ring-VRF registration mirror failed"); + } + })); + Ok(()) + } + + async fn call_product( + &self, + invocation: &AccountInvocation<'_>, + payload: R, + ) -> Result + where + ProductRequest: SsoRequest>, + { + self.call( + invocation, + ProductRequest { + calling_product_id: invocation + .caller + .product_id() + .ok_or(RingVrfError::NotAllowlisted)? + .to_string(), + payload, + }, + ) + .await? + } + + async fn call( + &self, + invocation: &AccountInvocation<'_>, + request: R, + ) -> Result { + forward(&self.service, invocation.call, invocation.session, request).await + } +} + +/// Send `request` over `service`'s channel while `session` stays selected. +async fn forward( + service: &SsoRequestService, + cx: &CallContext, + session: &AuthoritySession, + request: R, +) -> Result { + let session = require_current_session(&service.session_state(), session)?; + let cx = super::remote_authority_context(cx); + super::remote_authority_call(&cx, async { + service.call(&cx, &session, request).await.map_err(remote_authority_error) + }) + .await +} + +#[async_trait::async_trait] +impl AccountHolder for RemoteAccountHolder { + const GRANT_KEEPING: GrantKeeping = GrantKeeping::Paired; + + fn current_session(&self) -> Option { + self.service.current_session() + } + + fn require_current_session(&self, session: &AuthoritySession) -> Result<(), AuthorityError> { + require_current_session(&self.service.session_state(), session).map(|_| ()) + } + + async fn product_subtree_public_key<'a>( + &'a self, + invocation: AccountInvocation<'a>, + product_id: String, + ) -> Result, AuthorityError> { + let session = require_current_session(&self.service.session_state(), invocation.session)?; + Ok(SubtreeResolution::Ask(Box::pin(async move { + self.service + .call(invocation.call, &session, ProductSubtreeRequest { product_id }) + .await + .map_err(remote_authority_error)? + .map_err(remote_authority_error) + }))) + } + + async fn sign_vrf( + &self, + invocation: AccountInvocation<'_>, + request: latest::HostAccountSignVrfRequest, + ) -> Result { + self.call( + &invocation, + ProductRequest { + calling_product_id: invocation + .caller + .product_id() + .ok_or(AuthorityError::Rejected)? + .to_string(), + payload: request, + }, + ) + .await? + .map_err(|error| match error { + latest::HostAccountSignVrfError::NotConnected => AuthorityError::Disconnected, + latest::HostAccountSignVrfError::Rejected => AuthorityError::Rejected, + latest::HostAccountSignVrfError::Unknown { reason } => { + AuthorityError::Unknown { reason } + }, + }) + } + + async fn sign_payload( + &self, + invocation: AccountInvocation<'_>, + request: SignPayloadAuthorityRequest, + ) -> Result { + let request = match request { + SignPayloadAuthorityRequest::Product(request) => request, + SignPayloadAuthorityRequest::LegacyAccount { product_account, request } => { + latest::HostSignPayloadRequest { + account: product_account, + payload: request.payload, + } + }, + }; + self.call(&invocation, SignRequest::Payload(Box::new(request))) + .await? + .map_err(remote_authority_error) + } + + async fn sign_raw( + &self, + invocation: AccountInvocation<'_>, + request: SignRawAuthorityRequest, + watermarked: bool, + ) -> Result { + let request = match request { + SignRawAuthorityRequest::Product(request) => request, + SignRawAuthorityRequest::LegacyAccount { product_account, request } => { + latest::HostSignRawRequest { account: product_account, payload: request.payload } + }, + SignRawAuthorityRequest::IdentityAccount { account, request } => { + let request = SignRawWithLegacyAccountRequest { account, data: request.payload }; + if !watermarked { + return self + .call( + &invocation, + SignRequest::RawWithLegacyAccountUnwatermarkedDeprecated(request), + ) + .await? + .map_err(remote_authority_error); + } + let signature = + self.call(&invocation, request).await?.map_err(remote_authority_error)?; + return Ok(latest::HostSignPayloadResponse { signature, signed_transaction: None }); + }, + }; + self.call( + &invocation, + if watermarked { + SignRequest::Raw(request) + } else { + SignRequest::RawUnwatermarkedDeprecated(request) + }, + ) + .await? + .map_err(remote_authority_error) + } + + async fn create_transaction( + &self, + invocation: AccountInvocation<'_>, + request: CreateTransactionAuthorityRequest, + ) -> Result { + let signed = match request { + CreateTransactionAuthorityRequest::Product(payload) => { + self.call( + &invocation, + CreateTransactionRequest { + payload: CreateTransactionPayload::V1(SsoProductTxPayload::from_resolved( + payload, + )), + }, + ) + .await + }, + CreateTransactionAuthorityRequest::LegacyAccount { product_account, request } => { + self.call( + &invocation, + CreateTransactionRequest { + payload: CreateTransactionPayload::V1(SsoProductTxPayload { + signer: product_account, + genesis_hash: request.genesis_hash, + call_data: request.call_data, + extensions: request.extensions, + tx_ext_version: request.tx_ext_version, + }), + }, + ) + .await + }, + CreateTransactionAuthorityRequest::IdentityAccount(payload) => { + self.call( + &invocation, + CreateTransactionWithLegacyAccountRequest { + payload: CreateTransactionLegacyPayload::V1(payload), + }, + ) + .await + }, + }; + signed? + .map(|transaction| latest::HostCreateTransactionResponse { transaction }) + .map_err(remote_authority_error) + } + + async fn account_alias( + &self, + invocation: AccountInvocation<'_>, + request: latest::HostAccountGetAliasRequest, + ) -> Result { + self.call_product(&invocation, request).await + } + + async fn create_proof( + &self, + invocation: AccountInvocation<'_>, + request: latest::HostAccountCreateProofRequest, + ) -> Result { + self.call_product(&invocation, request).await + } + + async fn register_ring_vrf_key( + &self, + invocation: AccountInvocation<'_>, + request: latest::HostAccountRegisterRingVrfKeyRequest, + ) -> Result { + let public_key = self.call_product(&invocation, request.clone()).await?; + self.remember_registration(&invocation, request, public_key).await?; + Ok(public_key) + } + + async fn record_ring_vrf_key( + &self, + invocation: AccountInvocation<'_>, + request: latest::HostAccountRegisterRingVrfKeyRequest, + public_key: [u8; 32], + ) -> Result<(), RingVrfError> { + self.remember_registration(&invocation, request.clone(), public_key).await?; + self.forward_registration(&invocation, request) + } + + async fn list_ring_vrf_keys( + &self, + invocation: AccountInvocation<'_>, + mut request: latest::HostAccountListRingVrfKeysRequest, + ) -> Result { + let owner = normalize_product_identifier(&request.owner) + .map_err(|error| RingVrfError::Unknown { reason: error.to_string() })?; + let disclosure = request.disclosure; + if invocation.caller.product_id() == Some(owner.as_str()) { + request.disclosure = latest::RingVrfKeyDisclosure::PublicKey; + } + let mut entries: Vec = + self.call_product(&invocation, request).await?; + validate_owner_listing(&owner, &entries)?; + if entries.iter().all(|entry| entry.public_key.is_some()) { + let mut update = self.registry.prepare_update(invocation.session.public_key).await?; + self.require_current_session(invocation.session)?; + entries = update.reconcile_owner(&owner, entries)?; + update.persist().await?; + } + apply_ring_vrf_disclosure(&mut entries, disclosure); + Ok(entries) + } + + async fn ring_vrf_sign( + &self, + invocation: AccountInvocation<'_>, + request: latest::HostAccountRingVrfSignRequest, + ) -> Result { + self.call_product(&invocation, request).await + } + + async fn sign_statement_store_product_payload( + &self, + invocation: AccountInvocation<'_>, + _account: latest::ProductAccountId, + _payload: Vec, + ) -> Result<[u8; 64], AuthorityError> { + self.require_current_session(invocation.session)?; + Err(AuthorityError::Unavailable { reason: "pairing host: exact statement proof signing needs an AutoSigning capability; the current SSO raw-signing protocol cannot carry it".to_string() }) + } + + fn derive_entropy( + &self, + session: &AuthoritySession, + product_id: &str, + context: &[u8], + ) -> Result<[u8; 32], AuthorityError> { + let session = require_current_session(&self.service.session_state(), session)?; + if session.sso.is_none() { + return Err(AuthorityError::Disconnected); + } + let source = session.root_entropy_source.ok_or_else(|| AuthorityError::Unavailable { + reason: "Session secret missing".to_string(), + })?; + derive_product_entropy_from_source(&source, product_id, context) + .map_err(|error| AuthorityError::Unknown { reason: error.to_string() }) + } + + fn contact_handles( + &self, + session: &AuthoritySession, + ) -> Result { + let session = require_current_session(&self.service.session_state(), session)?; + let source = session.root_entropy_source.ok_or_else(|| AuthorityError::Unavailable { + reason: "Session secret missing".to_string(), + })?; + Ok(crate::runtime::contacts::ContactHandles::from_root_entropy_source(&source)) + } + + async fn allocate_grants<'a>( + &'a self, + invocation: AccountInvocation<'a>, + request: latest::HostRequestResourceAllocationRequest, + policy: OnExistingAllowancePolicy, + ) -> Result>, AuthorityError> { + let session = require_current_session(&self.service.session_state(), invocation.session)?; + let request = ResourceAllocationRequest { + calling_product_id: invocation + .caller + .product_id() + .ok_or(AuthorityError::Rejected)? + .to_string(), + resources: request.resources, + on_existing: policy, + }; + Ok(stream::once(async move { + self.service + .call(invocation.call, &session, request) + .await + .map_err(remote_authority_error)? + .map_err(remote_authority_error) + }) + .flat_map(|result| { + stream::iter(match result { + Ok(outcomes) => outcomes + .into_iter() + .map(|outcome| match outcome { + SsoAllocationOutcome::Allocated(resource) => { + decode_grant(resource).map(AccountGrantOutcome::Allocated) + }, + SsoAllocationOutcome::Rejected => Ok(AccountGrantOutcome::Rejected), + SsoAllocationOutcome::NotAvailable => { + Ok(AccountGrantOutcome::NotAvailable { reason: None }) + }, + }) + .collect::>(), + Err(error) => vec![Err(error)], + }) + }) + .boxed()) + } + + /// The phone keeps renewing the statement allowances it issued, so a paired host sends nothing. + async fn renew_statement_sponsorship( + &self, + _invocation: AccountInvocation<'_>, + _account_id: [u8; 32], + ) -> Result<(), AuthorityError> { + Ok(()) + } + + async fn ensure_allowance( + &self, + invocation: AccountInvocation<'_>, + resource: AllowanceResource, + policy: OnExistingAllowancePolicy, + ) -> Result { + let expected = resource; + let resource = match resource { + AllowanceResource::StatementStore => { + latest::AllocatableResource::StatementStoreAllowance + }, + AllowanceResource::Bulletin => latest::AllocatableResource::BulletinAllowance, + }; + let name = match resource { + latest::AllocatableResource::StatementStoreAllowance => "statement-store allowance", + _ => "bulletin allowance", + }; + let outcomes = self + .call( + &invocation, + ResourceAllocationRequest { + calling_product_id: invocation + .caller + .product_id() + .ok_or(AuthorityError::Rejected)? + .to_string(), + resources: vec![resource], + on_existing: policy, + }, + ) + .await? + .map_err(remote_authority_error)?; + match outcomes.into_iter().next() { + Some(SsoAllocationOutcome::Allocated(resource)) => { + if !matches!( + (expected, &resource), + ( + AllowanceResource::StatementStore, + SsoAllocatedResource::StatementStoreAllowance { .. } + ) | ( + AllowanceResource::Bulletin, + SsoAllocatedResource::BulletinAllowance { .. } + ) + ) { + return Err(unexpected_resource(name, &resource)); + } + decode_grant(resource) + }, + Some(SsoAllocationOutcome::Rejected) => Err(AuthorityError::Rejected), + Some(SsoAllocationOutcome::NotAvailable) => { + Err(AuthorityError::Unavailable { reason: format!("{name} is not available") }) + }, + None => Err(AuthorityError::Unknown { reason: format!("Empty {name} response") }), + } + } +} + +fn decode_grant(resource: SsoAllocatedResource) -> Result { + Ok(match resource { + SsoAllocatedResource::StatementStoreAllowance { slot_account_key } => { + AccountGrant::StatementStore { + key: StatementStoreAllowanceKey::from_secret_bytes(slot_account_key)?, + period: None, + } + }, + SsoAllocatedResource::BulletinAllowance { slot_account_key } => { + AccountGrant::Bulletin(BulletinAllowanceKey::from_secret_bytes(slot_account_key)?) + }, + SsoAllocatedResource::SmartContractAllowance => AccountGrant::SmartContract, + SsoAllocatedResource::AutoSigning { product_root_private_key, ring_vrf_domain_entropy } => { + AccountGrant::AutoSigning(AutoSigningKey::from_parts( + product_root_private_key, + ring_vrf_domain_entropy, + )) + }, + }) +} + +fn remote_authority_error(reason: impl Into) -> AuthorityError { + match reason.into() { + SsoRemoteResponseError::Cancelled(err) => AuthorityError::Cancelled( + AuthorityCancelError::new(err.remote_message_id(), err.reason()), + ), + SsoRemoteResponseError::LocalDisconnected | SsoRemoteResponseError::PeerDisconnected => { + AuthorityError::Disconnected + }, + SsoRemoteResponseError::Failure(reason) => match reason.as_str() { + "Rejected" | "User rejected" => AuthorityError::Rejected, + SSO_LOCAL_DISCONNECT_REASON | SSO_PEER_DISCONNECT_REASON => { + AuthorityError::Disconnected + }, + _ => AuthorityError::Unknown { reason }, + }, + } +} + +fn unexpected_resource(label: &str, resource: &SsoAllocatedResource) -> AuthorityError { + AuthorityError::Unknown { + reason: format!("Unexpected {label} response resource: {}", resource.kind()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unexpected_resource_reasons_include_only_safe_discriminants() { + let resource = SsoAllocatedResource::AutoSigning { + product_root_private_key: [0xA5; 64], + ring_vrf_domain_entropy: [0x5A; 32], + }; + + let AuthorityError::Unknown { reason } = + unexpected_resource("statement-store allowance", &resource) + else { + panic!("expected an unknown authority error"); + }; + + assert_eq!(reason, "Unexpected statement-store allowance response resource: auto-signing"); + assert!(!reason.contains("165, 165")); + } +} diff --git a/rust/crates/truapi/src/runtime/ring_vrf_registry.rs b/rust/crates/truapi/src/runtime/ring_vrf_registry.rs index 2413bf67d0..6be46814c9 100644 --- a/rust/crates/truapi/src/runtime/ring_vrf_registry.rs +++ b/rust/crates/truapi/src/runtime/ring_vrf_registry.rs @@ -204,6 +204,7 @@ impl RingVrfRegistryStore { } /// Persist a registration without replacing an existing key. + #[cfg(test)] pub async fn register( &self, root_public_key: [u8; 32], @@ -232,7 +233,8 @@ impl RingVrfRegistryStore { /// invalidate an entry already accepted by this host. This also prevents a /// list response created before a fire-and-forget registration mirror from /// removing that local registration. - pub async fn reconcile_owner( + #[cfg(test)] + async fn reconcile_owner( &self, root_public_key: [u8; 32], owner: &str, @@ -274,7 +276,8 @@ impl RingVrfRegistryStore { } /// Persist a user-selected provider after validating its registration. - pub async fn select_provider( + #[cfg(test)] + async fn select_provider( &self, root_public_key: [u8; 32], ring: RingLocation, diff --git a/rust/crates/truapi/src/runtime/services.rs b/rust/crates/truapi/src/runtime/services.rs index 242342d5ea..9d3aece624 100644 --- a/rust/crates/truapi/src/runtime/services.rs +++ b/rust/crates/truapi/src/runtime/services.rs @@ -23,7 +23,7 @@ use crate::{ TopUpPlatform, }, runtime::{ - bulletin_rpc::BulletinRpc, signing_host::DevicePairingObserver, + bulletin_rpc::BulletinRpc, sso_responder_service::DevicePairingObserver, statement_store_rpc::StatementStoreRpc, }, subscription::Spawner, diff --git a/rust/crates/truapi/src/runtime/signing_host.rs b/rust/crates/truapi/src/runtime/signing_host.rs index 22edd2f26a..c51e0570ed 100644 --- a/rust/crates/truapi/src/runtime/signing_host.rs +++ b/rust/crates/truapi/src/runtime/signing_host.rs @@ -1,73 +1,29 @@ -//! Wallet-local signing authority and incoming SSO handling. +//! Native wallet lifecycle, shared host accounts and incoming SSO. +//! +//! Wallet replacement and retained grants use the same lifecycle guard. -mod allowance_renewal; mod local_activation; -pub mod ring_vrf; -mod sso_replay; -mod sso_responder; -mod sso_service; - -use std::{ - collections::{HashMap, HashSet}, - sync::{Arc, Mutex}, -}; -use truapi::latest::{ - ChainIdentifier, DerivationIndex, HostAccountCreateProofRequest, HostAccountGetAliasRequest, - HostAccountListRingVrfKeysRequest, HostAccountRegisterRingVrfKeyRequest, - HostAccountRingVrfSignRequest, ProductAccountId, RingLocation, RingLocationJunction, -}; +mod ring_vrf; +pub use ring_vrf::{ChainRingResolver, MemberCandidate, RingResolver, context_bytes, create_proof}; +mod wallet_account_holder; + +use std::sync::Arc; -pub use allowance_renewal::StatementRenewalTarget; -#[cfg(not(target_arch = "wasm32"))] -pub use allowance_renewal::TrackedStatementRenewalTarget; pub use local_activation::LocalActivation; -pub use sso_responder::{ - AnnouncedPairing, DevicePairingObserver, MAX_PAIRING_METADATA_CHARS, PairedSsoPeer, - PairingProposal, PairingProposalMetadata, ResponderExit, disconnect_paired_host, - establish_pairing, notify_pairing_allowance_allocation, notify_pairing_failed, - respond_to_pairing, resume_pairing, -}; -pub use sso_service::SigningHostSsoService; +#[cfg(not(target_arch = "wasm32"))] +pub use wallet_account_holder::TrackedStatementRenewalTarget; +pub use wallet_account_holder::{StatementRenewalTarget, WalletAccountHolder}; +#[cfg(not(target_arch = "wasm32"))] +use super::allowances::current_unix_secs; use super::{ - HostSession, RuntimeServices, - authority::{ - AuthorityError, AuthoritySession, AutoSigningGrant, BulletinAllowanceKey, - CreateTransactionAuthorityRequest, ProductAuthority, SignPayloadAuthorityRequest, - SignRawAuthorityRequest, StatementStoreAllowanceKey, authority_session_validation_id, - }, - connected_session_ui_info, - ring_vrf_registry::{RingVrfRegistryStore, apply_ring_vrf_disclosure}, - validate_vrf_transcript, + AccountHolder, HostAccounts, HostSession, RuntimeServices, authority::AuthorityError, + connected_session_ui_info, product_consent::ProductConsent, + ring_vrf_registry::RingVrfRegistryStore, sso_responder_service::SsoResponderService, }; -use crate::{ - host_internal::{ - extrinsic::build_local_transaction, - sso_messages::{OnExistingAllowancePolicy, ProductRequest, RingVrfError}, - transaction::sign_extrinsic_payload, - }, - host_logic::{ - entropy::derive_product_entropy, - features::genesis_for, - product_account::{ - ProductAccountError, SR25519_SIGNING_CONTEXT, derivation_index_bytes, - derive_full_person_ring_vrf_entropy, derive_identity_keypair, - derive_lite_person_ring_vrf_entropy, derive_product_keypair, - derive_product_subtree_keypair, derive_ring_vrf_entropy, - derive_root_keypair_from_entropy, personhood_product_id, - }, - raw_signing::raw_payload_bytes, - session::{SessionInfo, SessionState}, - }, - runtime::{ - auth_state::AuthStateMachine, - sso_service::SsoWithdrawals, - statement_allowance::{self, CollectionCandidate, collection::PersonhoodCollection}, - vrf::{self, Vrf}, - }, -}; -use ring_vrf::{ChainRingResolver, MemberCandidate, RingResolver, context_bytes, create_proof}; -use sso_replay::SsoReplayLocks; +#[cfg(not(target_arch = "wasm32"))] +use crate::runtime::statement_allowance; +use crate::{host_logic::session::SessionState, runtime::auth_state::AuthStateMachine}; /// The network suffix the unit tests configure their signing host for. `dot` /// keeps the `peopl.dot` handles the RFC examples use meaningful; the @@ -75,192 +31,90 @@ use sso_replay::SsoReplayLocks; #[cfg(test)] const TEST_NETWORK_SUFFIX: &str = "dot"; -use crate::platform::{ - PermissionAuthorizationStatus, Platform, ProductContext, SignVrfReview, UserConfirmationReview, - normalize_product_identifier, +#[cfg(test)] +use crate::platform::Platform; +use crate::platform::ProductContext; +use truapi::{ + CallError, + latest::{HostRequestLoginError, HostRequestLoginResponse}, }; -use truapi::{CallContext, CallError, latest, v01}; -use zeroize::Zeroizing; - -#[derive(Default)] -struct LocalGrantState { - activation_generation: u64, - auto_signing_grants: HashSet<([u8; 32], String)>, - /// Per product, the period its statement-store allowance was last seen - /// registered in, and its key. - // TODO(#1159): persist in core.sqlite3 allowance_records. - statement_allowance_keys: HashMap, -} - -impl LocalGrantState { - fn advance_activation(&mut self) { - self.activation_generation = self - .activation_generation - .checked_add(1) - .expect("local activation generation exhausted"); - self.auto_signing_grants.clear(); - self.statement_allowance_keys.clear(); - } - fn revoke_product(&mut self, product_id: &str) { - self.activation_generation = self - .activation_generation - .checked_add(1) - .expect("local activation generation exhausted"); - self.auto_signing_grants - .retain(|(_, granted_product_id)| granted_product_id != product_id); - self.statement_allowance_keys.remove(product_id); - } - - fn statement_allowance_key( - &self, - activation_generation: u64, - product_id: &str, - period: u32, - ) -> Result, AuthorityError> { - if self.activation_generation != activation_generation { - return Err(AuthorityError::Disconnected); - } - Ok(self - .statement_allowance_keys - .get(product_id) - .filter(|(cached_period, _)| *cached_period == period) - .map(|(_, key)| key)) - } - - fn forget_statement_allowance_key(&mut self, product_id: &str, public_key: [u8; 32]) { - if self - .statement_allowance_keys - .get(product_id) - .is_some_and(|(_, key)| key.public_key == public_key) - { - self.statement_allowance_keys.remove(product_id); - } - } - - fn remember_statement_allowance_key( - &mut self, - activation_generation: u64, - product_id: String, - period: u32, - key: StatementStoreAllowanceKey, - ) -> Result<(), AuthorityError> { - if self.activation_generation != activation_generation { - return Err(AuthorityError::Disconnected); - } - self.statement_allowance_keys.insert(product_id, (period, key)); - Ok(()) - } -} - -/// Wallet-local account authority for a signing host. +/// Native host lifecycle and retained grants over a shared wallet. pub struct SigningHost { + #[cfg(any(not(target_arch = "wasm32"), test))] services: Arc, - platform: Arc, - /// The dotNS TLD of the network this wallet serves, from - /// [`crate::platform::SigningHostConfig::network_suffix`]. Every reserved - /// RFC-0022 derivation (`uid.`, `peopl.`) ends in it. - network_suffix: String, - session_state: Arc, + wallet: Arc, auth_state: AuthStateMachine, - ring_resolver: Arc, - /// Unchecked grants and withheld resources for test-host suites. Compiled - /// only into a build carrying `test-host`, which is off by default and which - /// neither the production browser bundle nor a released native host - /// enables, so a shipping host has no way to set it. - #[cfg(feature = "test-host")] - resource_controls: Arc, - /// Keep preimage submissions in the core instead of the Bulletin chain, - /// also implied by unchecked allowance grants. - #[cfg(feature = "test-host")] - submit_preimages_locally: core::sync::atomic::AtomicBool, - /// Root BIP-39 entropy held only while a session is active. - root_entropy: Mutex>>>, - /// In-memory grants and the activation generation that owns them. The - /// lifecycle mutex also makes session replacement and snapshot creation - /// atomic with respect to generation changes. - local_grants: Mutex, - /// Durable RFC-0024 registry, scoped by the active wallet root. - ring_vrf_registry: Arc, - /// Serializes replay-ledger updates within each wallet and peer scope. - sso_replay_locks: SsoReplayLocks, - /// Paired-host requests the pairing host can still withdraw. - sso_withdrawals: SsoWithdrawals, - renewal: allowance_renewal::RenewalState, + accounts: Arc>, + sso_responder: Arc, + #[cfg(not(target_arch = "wasm32"))] + renewal_loop_started: std::sync::atomic::AtomicBool, } impl SigningHost { + /// Product account policy and grants shared by native connections. + pub fn accounts(&self) -> &Arc> { + &self.accounts + } + /// Build a locked signing host for the network's dotNS suffix. pub fn new(services: Arc, network_suffix: String) -> Arc { let platform = services.platform.clone(); - let ring_resolver = ChainRingResolver::new(services.chain.clone()); - Arc::new(Self { - services, - platform: platform.clone(), + let registry = RingVrfRegistryStore::new(platform.clone()); + let consent = Arc::new(ProductConsent::new(platform.clone())); + let wallet = Arc::new(WalletAccountHolder::new( + services.clone(), network_suffix, - #[cfg(feature = "test-host")] - resource_controls: Arc::default(), - #[cfg(feature = "test-host")] - submit_preimages_locally: core::sync::atomic::AtomicBool::new(false), - session_state: SessionState::new(), - auth_state: AuthStateMachine::new(platform.clone()), - ring_resolver, - root_entropy: Mutex::new(None), - local_grants: Mutex::new(LocalGrantState::default()), - ring_vrf_registry: RingVrfRegistryStore::new(platform), - sso_replay_locks: SsoReplayLocks::default(), - sso_withdrawals: Default::default(), - renewal: allowance_renewal::RenewalState::default(), - }) - } - - /// Whether allocation is answered as granted without performing it. - #[cfg(feature = "test-host")] - pub fn grants_allowances_unchecked(&self) -> bool { - self.resource_controls.grants_allowances_unchecked() + consent.clone(), + registry.clone(), + )); + Self::with_wallet(services, wallet, registry, consent) } - /// Keep preimage submissions in the core instead of the Bulletin chain. - /// - /// For test hosts only, with the `test-host` feature enabled. - #[cfg(feature = "test-host")] - pub fn set_submit_preimages_locally(&self, local: bool) { - self.submit_preimages_locally - .store(local, core::sync::atomic::Ordering::Relaxed); + fn with_wallet( + services: Arc, + wallet: Arc, + registry: Arc, + consent: Arc, + ) -> Arc { + let platform = services.platform.clone(); + let accounts = Arc::new(HostAccounts::new( + services.clone(), + wallet.clone(), + wallet.session_state(), + registry, + consent.clone(), + #[cfg(feature = "test-host")] + wallet.resource_controls().clone(), + )); + Arc::new(Self { + #[cfg(any(not(target_arch = "wasm32"), test))] + services: services.clone(), + sso_responder: SsoResponderService::new(services, wallet.clone(), consent), + wallet, + auth_state: AuthStateMachine::new(platform), + accounts, + #[cfg(not(target_arch = "wasm32"))] + renewal_loop_started: std::sync::atomic::AtomicBool::new(false), + }) } /// Answer resource allocation as granted without performing it in test hosts. #[cfg(feature = "test-host")] pub fn set_grant_allowances_unchecked(&self, granted: bool) { - self.resource_controls.set_grant_allowances_unchecked(granted); + self.wallet.resource_controls().set_grant_allowances_unchecked(granted); } - /// Replace refused resource tags; SmartContractAllowance covers every index. + /// Replace resource tags refused by this test host. #[cfg(feature = "test-host")] - pub(crate) fn set_withheld_resources(&self, tags: Vec) { - self.resource_controls.set_withheld_resources(tags); - } - - /// The shared services this role was built over, for tests that also need - /// to build a product runtime against the same platform and cache. - #[cfg(test)] - fn services(&self) -> Arc { - self.services.clone() + pub fn set_withheld_resources(&self, tags: Vec) { + self.wallet.resource_controls().set_withheld_resources(tags); } #[cfg(test)] fn new_with_ring_resolver( platform: Arc, ring_resolver: Arc, - ) -> Arc { - Self::new_with_ring_resolver_on(platform, ring_resolver, TEST_NETWORK_SUFFIX) - } - - #[cfg(test)] - fn new_with_ring_resolver_on( - platform: Arc, - ring_resolver: Arc, - network_suffix: &str, ) -> Arc { let services = RuntimeServices::new( platform.clone(), @@ -275,582 +129,143 @@ impl SigningHost { [0xcc; 32], crate::test_support::test_spawner(), ); - Arc::new(Self { - services, - platform: platform.clone(), - network_suffix: network_suffix.to_string(), - #[cfg(feature = "test-host")] - resource_controls: Arc::default(), - #[cfg(feature = "test-host")] - submit_preimages_locally: core::sync::atomic::AtomicBool::new(false), - session_state: SessionState::new(), - auth_state: AuthStateMachine::new(platform.clone()), + let registry = RingVrfRegistryStore::new(platform.clone()); + let consent = Arc::new(ProductConsent::new(platform.clone())); + let wallet = Arc::new(WalletAccountHolder::new_with_ring_resolver( + services.clone(), + TEST_NETWORK_SUFFIX.to_string(), + consent.clone(), ring_resolver, - root_entropy: Mutex::new(None), - local_grants: Mutex::new(LocalGrantState::default()), - ring_vrf_registry: RingVrfRegistryStore::new(platform), - sso_replay_locks: SsoReplayLocks::default(), - sso_withdrawals: Default::default(), - renewal: allowance_renewal::RenewalState::default(), - }) - } - - /// Shared session holder for connection-status subscriptions. - pub fn session_state(&self) -> Arc { - self.session_state.clone() - } - - /// The dotNS TLD of the network this wallet serves: the suffix of every - /// reserved identity it derives. - pub fn network_suffix(&self) -> &str { - &self.network_suffix - } - - /// Current root entropy, or [`AuthorityError::Disconnected`] when no local - /// session is active. - fn root_entropy(&self) -> Result>, AuthorityError> { - self.root_entropy - .lock() - .expect("signing host entropy mutex poisoned") - .clone() - .ok_or(AuthorityError::Disconnected) - } - - fn product_subtree_secret(&self, product_id: &str) -> Result<[u8; 64], AuthorityError> { - let entropy = self.root_entropy()?; - let root = derive_root_keypair_from_entropy(&entropy).map_err(product_authority_error)?; - let product_id = normalize_product_identifier(product_id) - .map_err(|err| AuthorityError::Unavailable { reason: err.to_string() })?; - derive_product_subtree_keypair(&root, &product_id) - .map(|keypair| keypair.secret.to_bytes()) - .map_err(product_authority_error) - } - - fn sso_replay_locks(&self) -> &SsoReplayLocks { - &self.sso_replay_locks - } - - fn sso_withdrawals(&self) -> &SsoWithdrawals { - &self.sso_withdrawals + registry.clone(), + )); + Self::with_wallet(services, wallet, registry, consent) } - fn grant_auto_signing( - &self, - session: &AuthoritySession, - product_id: &str, - ) -> Result<(), AuthorityError> { - let (_, activation_generation) = self.require_current_session(session)?; - let entropy = self.root_entropy()?; - let root = derive_root_keypair_from_entropy(&entropy).map_err(product_authority_error)?; - let owner = root.public.to_bytes(); - if owner != session.public_key { - return Err(AuthorityError::Disconnected); - } - let product_id = normalize_product_identifier(product_id) - .map_err(|err| AuthorityError::Unavailable { reason: err.to_string() })?; - derive_product_subtree_keypair(&root, &product_id).map_err(product_authority_error)?; - - let mut state = self.local_grants.lock().expect("local AutoSigning grant mutex poisoned"); - if state.activation_generation != activation_generation { - return Err(AuthorityError::Disconnected); - } - state.auto_signing_grants.insert((owner, product_id)); - Ok(()) + /// Incoming SSO for this host's wallet, answered by its runtime. + pub fn sso_responder(&self) -> Arc { + self.sso_responder.clone() } - async fn allocate_statement_store_allowance_key( - &self, - session: &AuthoritySession, - product_id: &str, - policy: OnExistingAllowancePolicy, - ) -> Result { - let (_, activation_generation) = self.require_current_session(session)?; - let allocation = sso_responder::allocate_statement_store_allowance( - &self.services, - self, - session, - product_id, - policy, - ) - .await?; - let key = StatementStoreAllowanceKey::from_secret_bytes(allocation.secret)?; - self.local_grants - .lock() - .expect("local AutoSigning grant mutex poisoned") - .remember_statement_allowance_key( - activation_generation, - product_id.to_string(), - allocation.period, - key.clone(), - )?; - Ok(key) + /// Revoke one product's grants while preserving the active wallet. + pub async fn clear_product_state(&self, product_id: &str) -> Result<(), String> { + self.accounts.clear_product_state(product_id).await } - fn has_auto_signing_grant( - &self, - activation_generation: u64, - owner: [u8; 32], - calling_product_id: &str, - account_product_id: &str, - ) -> bool { - let (Ok(calling_product_id), Ok(account_product_id)) = ( - normalize_product_identifier(calling_product_id), - normalize_product_identifier(account_product_id), - ) else { - return false; - }; - if calling_product_id != account_product_id { - return false; + /// Delete persisted grants when their wallet is removed, including while locked. + pub async fn clear_wallet_state(&self, owner: [u8; 32]) -> Result<(), String> { + if self.accounts.revoke_wallet(owner, || self.wallet.clear()) { + self.auth_state.store_disconnected(); } - - let state = self.local_grants.lock().expect("local AutoSigning grant mutex poisoned"); - state.activation_generation == activation_generation && - state.auto_signing_grants.contains(&(owner, calling_product_id)) + self.accounts.finish_grant_cleanup().await } - /// Fence in-flight grant work and revoke this product's grants from the - /// current local activation while preserving unrelated products. - pub fn clear_product_state(&self, product_id: &str) -> Result<(), AuthorityError> { - let product_id = normalize_product_identifier(product_id) - .map_err(|error| AuthorityError::Unavailable { reason: error.to_string() })?; - self.local_grants - .lock() - .expect("local AutoSigning grant mutex poisoned") - .revoke_product(&product_id); - Ok(()) - } - - /// Derive the product's hard-subtree public key from the active session root. - /// Returns `None` when no session is active. - pub fn derive_subtree_public_key( + /// The product's hard-subtree public key under the active wallet, or + /// `None` while the wallet is locked. + pub fn product_subtree_public_key( &self, product_id: &str, ) -> Result, AuthorityError> { - let product_id = normalize_product_identifier(product_id) - .map_err(|err| AuthorityError::Unavailable { reason: err.to_string() })?; - let Ok(entropy) = self.root_entropy() else { - return Ok(None); - }; - let root = derive_root_keypair_from_entropy(&entropy).map_err(product_authority_error)?; - let subtree = - derive_product_subtree_keypair(&root, &product_id).map_err(product_authority_error)?; - Ok(Some(subtree.public.to_bytes())) - } - - /// Derive the product-account keypair for `account` from the root entropy. - /// - /// The root keypair is recomputed per call (PBKDF2, 2048 rounds, via - /// `substrate-bip39`) rather than cached: the signing host holds only the - /// raw, zeroizable entropy, never an expanded secret key. - fn product_keypair_with_owner( - &self, - account: &v01::ProductAccountId, - ) -> Result<([u8; 32], schnorrkel::Keypair), AuthorityError> { - let entropy = self.root_entropy()?; - let root = derive_root_keypair_from_entropy(&entropy).map_err(product_authority_error)?; - let owner = root.public.to_bytes(); - let product_id = normalize_product_identifier(&account.dot_ns_identifier) - .map_err(|err| AuthorityError::Unavailable { reason: err.to_string() })?; - derive_product_keypair( - &root, - &product_id, - derivation_index_bytes(&account.derivation_index), - ) - .map(|keypair| (owner, keypair)) - .map_err(product_authority_error) - } - - fn product_keypair( - &self, - account: &v01::ProductAccountId, - ) -> Result { - self.product_keypair_with_owner(account).map(|(_, keypair)| keypair) - } - - fn identity_keypair(&self) -> Result { - let entropy = self.root_entropy()?; - derive_identity_keypair(&entropy, &self.network_suffix).map_err(product_authority_error) + self.wallet.derive_subtree_public_key(product_id) } - fn install_local_session(&self, secret: Zeroizing>, session: SessionInfo) { - let mut state = self.local_grants.lock().expect("local AutoSigning grant mutex poisoned"); - state.advance_activation(); - *self.root_entropy.lock().expect("signing host entropy mutex poisoned") = Some(secret); - self.session_state.set_session(session); + /// Wallet held by this host, for tests that drive it without a product. + #[cfg(test)] + pub fn wallet_for_tests(&self) -> &Arc { + &self.wallet } fn clear_local_session(&self) { - let mut state = self.local_grants.lock().expect("local AutoSigning grant mutex poisoned"); - state.advance_activation(); - self.root_entropy.lock().expect("signing host entropy mutex poisoned").take(); - self.session_state.clear_session(); - } - - fn current_local_session(&self) -> Option { - let state = self.local_grants.lock().expect("local AutoSigning grant mutex poisoned"); - let session = self.session_state.current()?; - Some(AuthoritySession::from_session_info( - &session, - local_session_validation_id(&session, state.activation_generation), - )) - } - - fn require_current_session( - &self, - session: &AuthoritySession, - ) -> Result<(SessionInfo, u64), AuthorityError> { - let state = self.local_grants.lock().expect("local AutoSigning grant mutex poisoned"); - let current = self.session_state.current().ok_or(AuthorityError::Disconnected)?; - if local_session_validation_id(¤t, state.activation_generation) != - session.validation_id - { - return Err(AuthorityError::Disconnected); - } - Ok((current, state.activation_generation)) - } - - fn ring_vrf_entropy( - &self, - session: &AuthoritySession, - handle: &v01::ProductAccountId, - ) -> Result, RingVrfError> { - self.require_current_session(session)?; - let root = self.root_entropy()?; - derive_ring_vrf_entropy(&root, &handle.dot_ns_identifier, &handle.derivation_index) - .map(Zeroizing::new) - .map_err(|err| RingVrfError::Unknown { reason: err.to_string() }) - } - - /// Every personhood collection this wallet can derive allowance aliases for, - /// widest slot budget first. - /// - /// Wallet-internal allowance proofs use the reserved `peopl.` keys - /// the mobile hosts derive on the same network. Product-facing RFC-0024 - /// operations resolve registered handles, including the built-in keys - /// registered when the personhood owner is listed. - /// - /// Both entropies are always returned; which collections the person is - /// actually a member of is settled on chain by looking for a ring that - /// includes each member key, not by local state. That keeps the two hosts - /// from disagreeing about personhood. - fn reserved_person_collection_candidates( - &self, - session: &AuthoritySession, - ) -> Result, AuthorityError> { - self.require_current_session(session)?; - let root = self.root_entropy()?; - Ok(vec![ - CollectionCandidate { - collection: PersonhoodCollection::People, - entropy: derive_full_person_ring_vrf_entropy(&root, &self.network_suffix), - }, - CollectionCandidate { - collection: PersonhoodCollection::LitePeople, - entropy: derive_lite_person_ring_vrf_entropy(&root, &self.network_suffix), - }, - ]) - } - - async fn register_builtin_personhood_keys_if_needed( - &self, - session: &AuthoritySession, - owner: &str, - ) -> Result<(), RingVrfError> { - if owner != personhood_product_id(&self.network_suffix) { - return Ok(()); - } - let chains = self - .platform - .supported_chains() - .await - .map_err(|error| RingVrfError::Unknown { reason: error.reason })?; - let chain_id = - genesis_for(&chains, ChainIdentifier::People).ok_or(RingVrfError::RingNotFound)?; - let entries = self.ring_vrf_registry.owner_entries(session.public_key, owner).await?; - let missing = [(PersonhoodCollection::People, 0), (PersonhoodCollection::LitePeople, 1)] - .into_iter() - .filter(|(collection, index)| { - !entries.iter().any(|entry| { - entry.handle.derivation_index == DerivationIndex::Index(*index) && - entry.rings.iter().any(|ring| { - ring.chain_id == chain_id && - matches!( - ring.junctions.as_slice(), - [RingLocationJunction::PalletInstance(_), RingLocationJunction::CollectionId(identifier)] - if identifier.as_slice() == collection.identifier() - ) - }) - }) - }) - .collect::>(); - if missing.is_empty() { - return Ok(()); - } - let pallet_index = self.ring_resolver.members_pallet_index(&chain_id).await?; - let vrf = vrf::load().await?; - for (collection, index) in missing { - let handle = ProductAccountId { - dot_ns_identifier: owner.to_string(), - derivation_index: DerivationIndex::Index(index), - }; - let entropy = self.ring_vrf_entropy(session, &handle)?; - let public_key = vrf.member(&entropy)?; - let ring = RingLocation { - chain_id, - junctions: vec![ - RingLocationJunction::PalletInstance(pallet_index), - RingLocationJunction::CollectionId(collection.identifier().to_vec()), - ], - }; - self.ring_vrf_registry - .register(session.public_key, handle, ring, public_key) - .await?; - } - Ok(()) - } - - async fn registered_ring_vrf_entry( - &self, - session: &AuthoritySession, - handle: &v01::ProductAccountId, - ) -> Result, RingVrfError> { - self.require_current_session(session)?; - self.ring_vrf_registry.entry(session.public_key, handle).await - } - - async fn resolve_ring_vrf_key_for_ring( - &self, - vrf: &Vrf, - session: &AuthoritySession, - handle: &v01::ProductAccountId, - ring: &v01::RingLocation, - ) -> Result, RingVrfError> { - let entry = self - .registered_ring_vrf_entry(session, handle) - .await? - .ok_or(RingVrfError::KeyNotRegistered)?; - if !entry.rings.contains(ring) { - return Err(RingVrfError::KeyNotInRing); - } - let entropy = self.ring_vrf_entropy(session, handle)?; - Self::require_matching_registered_public_key(vrf, &entry, &entropy)?; - Ok(entropy) + self.accounts.change_activation(|| self.wallet.clear()); } +} - async fn resolve_registered_ring_vrf_key( - &self, - vrf: &Vrf, - session: &AuthoritySession, - handle: &v01::ProductAccountId, - ) -> Result, RingVrfError> { - let entry = self - .registered_ring_vrf_entry(session, handle) - .await? - .ok_or(RingVrfError::KeyNotRegistered)?; - let entropy = self.ring_vrf_entropy(session, handle)?; - Self::require_matching_registered_public_key(vrf, &entry, &entropy)?; - Ok(entropy) - } - - fn require_matching_registered_public_key( - vrf: &Vrf, - entry: &v01::RegisteredRingVrfKey, - entropy: &[u8; 32], - ) -> Result<(), RingVrfError> { - if entry.public_key != Some(vrf.member(entropy)?) { - return Err(RingVrfError::Unknown { - reason: "registered ring-VRF public key does not match the active wallet" - .to_string(), - }); +#[cfg(not(target_arch = "wasm32"))] +impl SigningHost { + /// Start the periodic statement-store renewal loop. Idempotent. + pub fn start_statement_allowance_renewal(self: &Arc) { + const CLOCK_FAILURE_TICK_DELAY: core::time::Duration = + core::time::Duration::from_secs(3_600); + if self.renewal_loop_started.swap(true, std::sync::atomic::Ordering::SeqCst) { + return; } - Ok(()) - } - - fn ring_vrf_member_candidate( - &self, - vrf: &Vrf, - entropy: &[u8; 32], - ) -> Result { - Ok(MemberCandidate { member: vrf.member(entropy)? }) - } - - /// Whether `calling_product_id` may act on `handle`'s ring-VRF key. - /// - /// Delegates to [`crate::runtime::ring_vrf_key_access_granted`], which - /// resolves the owner's manifest here rather than trusting the request: on - /// this role the request can have arrived over the pairing wire. - async fn require_ring_vrf_key_access( - &self, - calling_product_id: &str, - handle: &v01::ProductAccountId, - ) -> Result< - (v01::ProductAccountId, crate::runtime::product_manifest::AuthorizedAccess), - RingVrfError, - > { - let access = crate::runtime::product_manifest::ring_vrf_key_access_granted( - &self.services, - self.platform.as_ref(), - calling_product_id, - handle, - ) - .await?; - Ok(( - v01::ProductAccountId { - dot_ns_identifier: access.owner.clone(), - derivation_index: handle.derivation_index.clone(), - }, - access, - )) - } - - pub async fn ring_vrf_providers( - &self, - ring: &v01::RingLocation, - ) -> Result, RingVrfError> { - let session = self - .current_local_session() - .ok_or(RingVrfError::Unknown { reason: "no active session".to_string() })?; - self.ring_vrf_registry.providers(session.public_key, ring).await - } - - pub async fn selected_ring_vrf_provider( - &self, - ring: &v01::RingLocation, - ) -> Result, RingVrfError> { - let session = self - .current_local_session() - .ok_or(RingVrfError::Unknown { reason: "no active session".to_string() })?; - self.ring_vrf_registry.selected_provider(session.public_key, ring).await - } - - pub async fn select_ring_vrf_provider( - &self, - ring: v01::RingLocation, - handle: v01::ProductAccountId, - ) -> Result<(), RingVrfError> { - let session = self - .current_local_session() - .ok_or(RingVrfError::Unknown { reason: "no active session".to_string() })?; - self.ring_vrf_registry.select_provider(session.public_key, ring, handle).await - } - - async fn sign_vrf_request( - &self, - cx: &CallContext, - session: &AuthoritySession, - calling_product_id: String, - request: v01::HostAccountSignVrfRequest, - authenticated_caller: bool, - ) -> Result { - self.require_current_session(session)?; - validate_vrf_transcript(&request).map_err(|reason| AuthorityError::Unknown { reason })?; - let keypair = self.product_keypair(&request.account)?; - let (current, activation_generation) = self.require_current_session(session)?; - let granted = authenticated_caller && - super::authority::is_blessed_owner( - &calling_product_id, - &request.account.dot_ns_identifier, - ) || self.has_auto_signing_grant( - activation_generation, - current.public_key, - &calling_product_id, - &request.account.dot_ns_identifier, - ); - if !granted { - let confirmed = - super::until_cancelled( - cx, - self.platform.confirm_user_action(UserConfirmationReview::SignVrf( - SignVrfReview { calling_product_id, request: request.clone() }, - )), - ) - .await? - .map_err(|err| AuthorityError::Unknown { - reason: format!("VRF signing confirmation failed: {err:?}"), - })?; - if !confirmed { - return Err(AuthorityError::Rejected); + let weak_host = Arc::downgrade(self); + (self.services.spawner)(Box::pin(async move { + loop { + { + let Some(host) = weak_host.upgrade() else { + return; + }; + host.wallet.renewal_tick().await; + } + let delay = match current_unix_secs() { + Ok(now) => statement_allowance::renewal::next_tick_delay(now), + Err(_) => CLOCK_FAILURE_TICK_DELAY, + }; + futures_timer::Delay::new(delay).await; } - } - let (pre_output, proof) = crate::dynamic_vrf::sign_dynamic_vrf( - &keypair, - &request.transcript_label, - request.items.iter().map(|item| (item.label.as_slice(), item.value.as_slice())), - ); - Ok(v01::VrfSignature { pre_output, proof }) + })); } -} -#[cfg(not(target_arch = "wasm32"))] -impl SigningHost { - /// Record statement-store accounts to keep renewed across periods. + /// Record statement accounts to keep renewed under the active wallet. pub async fn track_statement_renewal_targets( &self, targets: Vec, ) -> Result<(), String> { - allowance_renewal::track(self, targets).await + self.wallet.track_statement_renewal_targets(targets).await } - /// Every statement account the ledger currently tracks. + /// Every statement account the renewal ledger tracks. pub async fn statement_renewal_targets( &self, ) -> Result, String> { - allowance_renewal::list(self).await + self.wallet.statement_renewal_targets().await } - /// Root public key the active identity records its fixed entries under. + /// Root public key the active wallet records its ledger entries under. pub fn statement_renewal_owner_key(&self) -> Result<[u8; 32], String> { - allowance_renewal::active_owner_key(self) + self.wallet.statement_renewal_owner_key() } - /// Stop renewing one fixed statement account. + /// Stop renewing one statement account. pub async fn untrack_statement_renewal_account( &self, account_id: &[u8; 32], ) -> Result { - allowance_renewal::untrack_account_for_signing_host(self, account_id).await + self.wallet.untrack_statement_renewal_account(account_id).await } - /// Run one statement-store renewal pass over the tracked targets. + /// Run one renewal pass now. pub async fn renew_statement_allowances( &self, - ) -> Result { - allowance_renewal::renew_now(&self.services, self).await + ) -> Result { + self.wallet.renew_statement_allowances().await } - /// The most recent pass the in-process loop ran. - /// - /// A direct call to [`Self::renew_statement_allowances`] returns its own - /// report, so only the loop needs somewhere to leave one. + /// The most recent pass the renewal loop ran. pub fn last_statement_renewal_report( &self, - ) -> Option { - self.renewal.last_report() - } - - /// Start the periodic statement-store renewal loop. Idempotent. - pub fn start_statement_allowance_renewal(self: &Arc) { - allowance_renewal::start_renewal_loop(&self.services, self); + ) -> Option { + self.wallet.last_statement_renewal_report() } } -#[async_trait::async_trait] +#[crate::platform::async_trait] impl HostSession for SigningHost { /// Shared session holder for connection-status subscriptions. fn session_state(&self) -> Arc { - SigningHost::session_state(self) + self.wallet.session_state() } async fn request_login( &self, _product: &ProductContext, - ) -> Result> { - if let Some(session) = self.session_state.current() { + ) -> Result> { + if let Some(session) = self.wallet.session_state().current() { self.auth_state.connected(&connected_session_ui_info(&session)); - Ok(latest::HostRequestLoginResponse::AlreadyConnected) + Ok(HostRequestLoginResponse::AlreadyConnected) } else { // Wallet unlock and session activation are platform-owned. - Ok(latest::HostRequestLoginResponse::Rejected) + Ok(HostRequestLoginResponse::Rejected) } } @@ -860,626 +275,20 @@ impl HostSession for SigningHost { } async fn primary_username(&self) -> Option { - self.current_local_session()?.primary_username().map(str::to_string) - } -} - -#[async_trait::async_trait] -impl ProductAuthority for SigningHost { - fn current_session(&self) -> Option { - self.current_local_session() - } - - async fn product_subtree_public_key( - &self, - _cx: &CallContext, - session: &AuthoritySession, - product_id: String, - ) -> Result<[u8; 32], AuthorityError> { - self.require_current_session(session)?; - let product_id = normalize_product_identifier(&product_id) - .map_err(|err| AuthorityError::Unavailable { reason: err.to_string() })?; - let entropy = self.root_entropy()?; - let root = derive_root_keypair_from_entropy(&entropy).map_err(product_authority_error)?; - derive_product_subtree_keypair(&root, &product_id) - .map(|keypair| keypair.public.to_bytes()) - .map_err(product_authority_error) - } - - async fn subtree_resolution_reaches_account_holder( - &self, - _session: &AuthoritySession, - _product_id: &str, - ) -> bool { - // A signing host derives the subtree locally from root entropy, so - // resolution never reaches a remote Account Holder and never prompts. - false - } - - async fn auto_signing_status( - &self, - session: &AuthoritySession, - calling_product_id: &str, - account: &v01::ProductAccountId, - ) -> Result { - // A stale session is not a grant, and is answered here rather than - // raising a prompt against a session that no longer exists. - // `grant_auto_signing` refuses to record a grant whose owner is not - // the session's own key, so the session carries the owner a grant can - // be keyed on and no root derivation is needed to answer this. - let (current, activation_generation) = self.require_current_session(session)?; - if super::authority::is_blessed_owner(calling_product_id, &account.dot_ns_identifier) || - self.has_auto_signing_grant( - activation_generation, - current.public_key, - calling_product_id, - &account.dot_ns_identifier, - ) { - Ok(AutoSigningGrant::Active) - } else { - Ok(AutoSigningGrant::Absent) - } - } - - async fn sign_vrf( - &self, - cx: &CallContext, - session: &AuthoritySession, - calling_product_id: String, - request: v01::HostAccountSignVrfRequest, - ) -> Result { - self.sign_vrf_request(cx, session, calling_product_id, request, true).await + self.wallet.current_session()?.primary_username().map(str::to_string) } - - async fn sign_payload( - &self, - _cx: &CallContext, - session: &AuthoritySession, - _calling_product_id: Option<&str>, - request: SignPayloadAuthorityRequest, - ) -> Result { - self.require_current_session(session)?; - let (keypair, payload) = match request { - SignPayloadAuthorityRequest::Product(request) => { - (self.product_keypair(&request.account)?, request.payload) - }, - SignPayloadAuthorityRequest::LegacyAccount { product_account, request } => { - (self.product_keypair(&product_account)?, request.payload) - }, - }; - Ok(sign_extrinsic_payload(&keypair, payload)?) - } - - async fn sign_raw( - &self, - _cx: &CallContext, - session: &AuthoritySession, - _calling_product_id: Option<&str>, - request: SignRawAuthorityRequest, - watermarked: bool, - ) -> Result { - let (keypair, payload) = match request { - SignRawAuthorityRequest::Product(request) => { - (self.product_keypair(&request.account)?, request.payload) - }, - SignRawAuthorityRequest::LegacyAccount { account, request } => { - let keypair = self.identity_keypair()?; - if keypair.public.to_bytes() != account { - return Err(AuthorityError::Unavailable { - reason: "signing host: the requested legacy account is not available in \ - this CLI wallet" - .to_string(), - }); - } - (keypair, request.payload) - }, - }; - self.require_current_session(session)?; - let message = raw_payload_bytes(payload, watermarked)?; - let signature = keypair - .secret - .sign_simple(SR25519_SIGNING_CONTEXT, &message, &keypair.public) - .to_bytes(); - Ok(v01::HostSignPayloadResponse { signature: signature.to_vec(), signed_transaction: None }) - } - - async fn create_transaction( - &self, - _cx: &CallContext, - session: &AuthoritySession, - _calling_product_id: Option<&str>, - request: CreateTransactionAuthorityRequest, - ) -> Result { - self.require_current_session(session)?; - match request { - CreateTransactionAuthorityRequest::Product(payload) => { - // The product account is authoritative and caller-scoping is - // enforced upstream, so the derived key defines the signer. - let keypair = self.product_keypair(&payload.signer)?; - build_local_transaction( - &self.services.chain, - &keypair, - payload.genesis_hash, - &payload.call_data, - &payload.extensions, - payload.tx_ext_version, - ) - .await - .map_err(AuthorityError::from) - }, - CreateTransactionAuthorityRequest::LegacyAccount { product_account, request } => { - let keypair = self.product_keypair(&product_account)?; - // Defense-in-depth: the slot-zero key must match the legacy - // signer the caller asked for (also validated upstream). Never - // sign with a diverging key. - if keypair.public.to_bytes() != request.signer { - return Err(AuthorityError::Unknown { - reason: "signing host: legacy signer does not match the product \ - slot-zero account" - .to_string(), - }); - } - build_local_transaction( - &self.services.chain, - &keypair, - request.genesis_hash, - &request.call_data, - &request.extensions, - request.tx_ext_version, - ) - .await - .map_err(AuthorityError::from) - }, - CreateTransactionAuthorityRequest::IdentityAccount(request) => { - let keypair = self.identity_keypair()?; - if keypair.public.to_bytes() != request.signer { - return Err(AuthorityError::Unavailable { - reason: "signing host: the requested identity account is not available in \ - this CLI wallet" - .to_string(), - }); - } - build_local_transaction( - &self.services.chain, - &keypair, - request.genesis_hash, - &request.call_data, - &request.extensions, - request.tx_ext_version, - ) - .await - .map_err(AuthorityError::from) - }, - } - } - - async fn account_alias( - &self, - _cx: &CallContext, - session: &AuthoritySession, - request: ProductRequest, - ) -> Result { - self.require_current_session(session)?; - // Aliases expose the same identity as `create_proof`, so both must enforce - // the same RFC-0024 grant and context restrictions. - let granted = match self - .require_ring_vrf_key_access(&request.calling_product_id, &request.payload.key_handle) - .await - { - Ok(granted) => Some(granted), - Err(RingVrfError::NotAllowlisted) => None, - Err(err) => return Err(err), - }; - // The context is held to the same rule as on `create_proof`: the alias and - // the proof come from one VRF evaluation. - let key_handle = match granted { - Some((key_handle, access)) => { - crate::runtime::product_manifest::require_context_access( - &self.services, - &access, - &request.payload.context, - ) - .await?; - key_handle - }, - None => { - // Permission lookups and prompts must use the same canonical identifiers. - let requester = normalize_product_identifier(&request.calling_product_id) - .map_err(|_| RingVrfError::NotAllowlisted)?; - let owner = - normalize_product_identifier(&request.payload.key_handle.dot_ns_identifier) - .map_err(|_| RingVrfError::NotAllowlisted)?; - match super::account_access_authorization( - self.services.platform.as_ref(), - &requester, - &owner, - ) - .await - { - Ok(PermissionAuthorizationStatus::Authorized) => {}, - Ok( - PermissionAuthorizationStatus::Denied | - PermissionAuthorizationStatus::NotDetermined, - ) => return Err(RingVrfError::Rejected), - Err(err) => { - return Err(RingVrfError::Unknown { reason: err.to_string() }); - }, - } - v01::ProductAccountId { - dot_ns_identifier: owner, - derivation_index: request.payload.key_handle.derivation_index.clone(), - } - }, - }; - let vrf = vrf::load().await?; - let entropy = self - .resolve_ring_vrf_key_for_ring( - &vrf, - session, - &key_handle, - &request.payload.ring_location, - ) - .await?; - self.ring_resolver.validate(&request.payload.ring_location).await?; - let context = context_bytes(&request.payload.context); - let alias = vrf.alias(&entropy, &context)?; - Ok(v01::ContextualAlias { context, alias: alias.to_vec() }) - } - - async fn create_proof( - &self, - _cx: &CallContext, - session: &AuthoritySession, - request: ProductRequest, - ) -> Result { - self.require_current_session(session)?; - let (key_handle, access) = self - .require_ring_vrf_key_access(&request.calling_product_id, &request.payload.key_handle) - .await?; - // A grant cannot expose the owner's alias in a third product's context unless - // that product's manifest grants the caller `context`. - crate::runtime::product_manifest::require_context_access( - &self.services, - &access, - &request.payload.context, - ) - .await?; - let vrf = vrf::load().await?; - let entropy = self - .resolve_ring_vrf_key_for_ring( - &vrf, - session, - &key_handle, - &request.payload.ring_location, - ) - .await?; - let candidate = self.ring_vrf_member_candidate(&vrf, &entropy)?; - let resolved = - self.ring_resolver.resolve(&request.payload.ring_location, &[candidate]).await?; - // Reject a stale request if the local session disconnected or changed - // while its chain snapshot was being resolved. - self.require_current_session(session)?; - let context = context_bytes(&request.payload.context); - let (proof, alias) = - create_proof(&vrf, &entropy, &resolved, &context, &request.payload.message)?; - Ok(v01::HostAccountCreateProofResponse { - proof, - contextual_alias: v01::ContextualAlias { context, alias: alias.to_vec() }, - ring_index: resolved.ring_index, - ring_revision: resolved.ring_revision, - }) - } - - async fn register_ring_vrf_key( - &self, - _cx: &CallContext, - session: &AuthoritySession, - request: ProductRequest, - ) -> Result<[u8; 32], RingVrfError> { - self.require_current_session(session)?; - self.ring_resolver.validate(&request.payload.ring).await?; - - let handle = v01::ProductAccountId { - dot_ns_identifier: normalize_product_identifier(&request.calling_product_id) - .map_err(|err| RingVrfError::Unknown { reason: err.to_string() })?, - derivation_index: request.payload.index, - }; - let entropy = self.ring_vrf_entropy(session, &handle)?; - let public_key = vrf::load().await?.member(&entropy)?; - self.ring_vrf_registry - .register(session.public_key, handle, request.payload.ring, public_key) - .await?; - Ok(public_key) - } - - async fn list_ring_vrf_keys( - &self, - _cx: &CallContext, - session: &AuthoritySession, - request: ProductRequest, - ) -> Result, RingVrfError> { - self.require_current_session(session)?; - let owner = normalize_product_identifier(&request.payload.owner) - .map_err(|err| RingVrfError::Unknown { reason: err.to_string() })?; - // Ownership checks and permission decisions must use canonical identifiers. - let caller = normalize_product_identifier(&request.calling_product_id) - .map_err(|_| RingVrfError::NotAllowlisted)?; - if caller != owner { - match super::account_access_authorization( - self.services.platform.as_ref(), - &caller, - &owner, - ) - .await - { - Ok(PermissionAuthorizationStatus::Authorized) => {}, - Ok( - PermissionAuthorizationStatus::Denied | - PermissionAuthorizationStatus::NotDetermined, - ) => return Err(RingVrfError::Rejected), - Err(err) => { - return Err(RingVrfError::Unknown { reason: err.to_string() }); - }, - } - } - - self.register_builtin_personhood_keys_if_needed(session, &owner).await?; - let mut entries = self.ring_vrf_registry.owner_entries(session.public_key, &owner).await?; - self.require_current_session(session)?; - apply_ring_vrf_disclosure(&mut entries, request.payload.disclosure); - Ok(entries) - } - - async fn ring_vrf_sign( - &self, - _cx: &CallContext, - session: &AuthoritySession, - request: ProductRequest, - ) -> Result, RingVrfError> { - self.require_current_session(session)?; - let (key_handle, _access) = self - .require_ring_vrf_key_access(&request.calling_product_id, &request.payload.key_handle) - .await?; - let vrf = vrf::load().await?; - let entropy = self.resolve_registered_ring_vrf_key(&vrf, session, &key_handle).await?; - vrf.sign(&entropy, &request.payload.message) - } - - async fn allocate_resources( - &self, - cx: &CallContext, - session: &AuthoritySession, - product_id: String, - request: v01::HostRequestResourceAllocationRequest, - ) -> Result { - self.require_current_session(session)?; - #[cfg(feature = "test-host")] - if self.resource_controls.grants_allowances_unchecked() { - // Nothing is allocated and no proof is built: a suite in this mode - // learns that its product handles a grant, not that a host would - // have given one. A withheld tag is still refused here, so the one - // resource a suite wants to prove its product lives without stays - // refused while the rest are granted. - return Ok(v01::HostRequestResourceAllocationResponse { - outcomes: request - .resources - .iter() - .map(|resource| { - if self.resource_controls.withholds(resource) { - v01::AllocationOutcome::Rejected - } else { - v01::AllocationOutcome::Allocated - } - }) - .collect(), - }); - } - let mut outcomes = Vec::with_capacity(request.resources.len()); - for resource in request.resources { - if let Some(reason) = cx.cancel().reason() { - return Err(super::authority_cancellation_error(cx, reason)); - } - // Checked before the work, not after: withholding is the suite - // saying this resource is refused, so performing the allocation and - // then reporting a refusal would leave the two disagreeing. - #[cfg(feature = "test-host")] - if self.resource_controls.withholds(&resource) { - outcomes.push(v01::AllocationOutcome::Rejected); - continue; - } - let outcome = match resource { - v01::AllocatableResource::StatementStoreAllowance => self - .allocate_statement_store_allowance_key( - session, - &product_id, - OnExistingAllowancePolicy::Increase, - ) - .await - .map(|_| v01::AllocationOutcome::Allocated), - v01::AllocatableResource::BulletinAllowance => { - sso_responder::allocate_bulletin_allowance( - &self.services, - self, - session, - &product_id, - OnExistingAllowancePolicy::Increase, - ) - .await - .map(|_| v01::AllocationOutcome::Allocated) - }, - v01::AllocatableResource::SmartContractAllowance(index) => { - sso_responder::allocate_smart_contract_allowance( - &self.services, - self, - session, - &product_id, - index, - OnExistingAllowancePolicy::Increase, - ) - .await - .map(|()| v01::AllocationOutcome::Allocated) - }, - v01::AllocatableResource::AutoSigning => self - .grant_auto_signing(session, &product_id) - .map(|_| v01::AllocationOutcome::Allocated) - .map_err(sso_responder::AllowanceAllocationError::Authority), - }; - match outcome { - Ok(outcome) => outcomes.push(outcome), - Err(reason) => { - tracing::warn!(%product_id, %reason, "direct resource allocation item failed"); - outcomes.push(v01::AllocationOutcome::NotAvailable); - }, - } - } - Ok(v01::HostRequestResourceAllocationResponse { outcomes }) - } - - async fn statement_store_allowance_key( - &self, - _cx: &CallContext, - session: &AuthoritySession, - product_id: String, - ) -> Result { - let (_, activation_generation) = self.require_current_session(session)?; - #[cfg(feature = "test-host")] - self.resource_controls - .refuse_withheld(&v01::AllocatableResource::StatementStoreAllowance)?; - let period = statement_allowance::slot::current_period( - sso_responder::current_unix_secs() - .map_err(sso_responder::AllowanceAllocationError::into_authority_error)?, - ); - if let Some(key) = self - .local_grants - .lock() - .expect("local AutoSigning grant mutex poisoned") - .statement_allowance_key(activation_generation, &product_id, period)? - { - return Ok(key.clone()); - } - self.allocate_statement_store_allowance_key( - session, - &product_id, - OnExistingAllowancePolicy::Ignore, - ) - .await - .map_err(sso_responder::AllowanceAllocationError::into_authority_error) - } - - #[cfg(feature = "test-host")] - fn submits_preimages_locally(&self) -> bool { - self.resource_controls.grants_allowances_unchecked() || - self.submit_preimages_locally.load(core::sync::atomic::Ordering::Relaxed) - } - - fn forget_statement_store_allowance_key(&self, product_id: &str, public_key: [u8; 32]) { - self.local_grants - .lock() - .expect("local AutoSigning grant mutex poisoned") - .forget_statement_allowance_key(product_id, public_key); - } - - async fn bulletin_allowance_key( - &self, - _cx: &CallContext, - session: &AuthoritySession, - product_id: String, - ) -> Result { - self.require_current_session(session)?; - #[cfg(feature = "test-host")] - self.resource_controls - .refuse_withheld(&v01::AllocatableResource::BulletinAllowance)?; - let secret = sso_responder::allocate_bulletin_allowance( - &self.services, - self, - session, - &product_id, - OnExistingAllowancePolicy::Ignore, - ) - .await - .map_err(sso_responder::AllowanceAllocationError::into_authority_error)?; - BulletinAllowanceKey::from_secret_bytes(secret) - } - - async fn refresh_bulletin_allowance_key( - &self, - _cx: &CallContext, - session: &AuthoritySession, - product_id: String, - ) -> Result { - self.require_current_session(session)?; - #[cfg(feature = "test-host")] - self.resource_controls - .refuse_withheld(&v01::AllocatableResource::BulletinAllowance)?; - let secret = sso_responder::allocate_bulletin_allowance( - &self.services, - self, - session, - &product_id, - OnExistingAllowancePolicy::Increase, - ) - .await - .map_err(sso_responder::AllowanceAllocationError::into_authority_error)?; - BulletinAllowanceKey::from_secret_bytes(secret) - } - - async fn sign_statement_store_product_payload( - &self, - _cx: &CallContext, - session: &AuthoritySession, - _calling_product_id: Option<&str>, - account: v01::ProductAccountId, - payload: Vec, - ) -> Result<[u8; 64], AuthorityError> { - self.require_current_session(session)?; - let keypair = self.product_keypair(&account)?; - Ok(keypair - .secret - .sign_simple(SR25519_SIGNING_CONTEXT, &payload, &keypair.public) - .to_bytes()) - } - - fn derive_entropy( - &self, - session: &AuthoritySession, - product_id: &str, - context: &[u8], - ) -> Result<[u8; 32], AuthorityError> { - self.require_current_session(session)?; - let entropy = self.root_entropy()?; - derive_product_entropy(&entropy, product_id, context) - .map_err(|err| AuthorityError::Unknown { reason: err.to_string() }) - } - - fn contacts_handle_key(&self, session: &AuthoritySession) -> Result<[u8; 32], AuthorityError> { - self.require_current_session(session)?; - // The same 32 bytes a pairing host receives from the wallet, so one - // contact hashes alike whichever role the user is running. - let root_entropy_source = - crate::host_logic::entropy::root_entropy_source(&self.root_entropy()?); - Ok(crate::runtime::contacts::handle_key_from_root_source(&root_entropy_source)) - } -} - -fn local_session_validation_id(session: &SessionInfo, activation_generation: u64) -> Vec { - let mut id = authority_session_validation_id(session); - id.extend_from_slice(b":activation:"); - id.extend_from_slice(&activation_generation.to_le_bytes()); - id -} - -fn product_authority_error(err: ProductAccountError) -> AuthorityError { - AuthorityError::Unavailable { reason: err.to_string() } } #[cfg(test)] mod tests { + mod activation; mod allowance_keys; mod auto_signing; mod cross_product_account; #[cfg(feature = "test-host")] mod local_preimages; mod raw_signing; + mod remote_consent; #[cfg(feature = "test-host")] mod withheld_resources; @@ -1487,13 +296,14 @@ mod tests { use super::{ super::{ - HostSession, ProductAuthority, ProductRuntimeHost, RuntimeServices, SigningHostRole, + AccountHolder, HostSession, ProductRuntimeHost, RuntimeServices, SigningHostRole, authority::{ - AuthorityError, AuthoritySession, CreateTransactionAuthorityRequest, - SignPayloadAuthorityRequest, SignRawAuthorityRequest, StatementStoreAllowanceKey, + AccountCaller, AccountInvocation, AuthorityError, AuthoritySession, + CreateTransactionAuthorityRequest, SignPayloadAuthorityRequest, + SignRawAuthorityRequest, StatementStoreAllowanceKey, }, }, - LocalActivation, TEST_NETWORK_SUFFIX, + LocalActivation, TEST_NETWORK_SUFFIX, WalletAccountHolder, ring_vrf::{MemberCandidate, ResolvedRing, RingResolver}, }; use crate::{ @@ -1507,7 +317,6 @@ mod tests { derive_ring_vrf_entropy, derive_root_keypair_from_entropy, index_bytes, }, platform::{HostInfo, Platform, PlatformInfo, ProductContext, SigningHostConfig}, - runtime::statement_allowance::collection::PersonhoodCollection, test_support::{StubPlatform, test_spawner}, }; use truapi::{ @@ -1519,7 +328,7 @@ mod tests { }, v01, versioned::{ - account::{HostAccountGetError, HostAccountGetRequest}, + account::{HostAccountGetError, HostAccountGetRequest, HostAccountGetResponse}, entropy::HostDeriveEntropyRequest, resource_allocation::{ HostRequestResourceAllocationRequest, HostRequestResourceAllocationResponse, @@ -1532,6 +341,7 @@ mod tests { #[derive(Clone)] struct StubRingResolver { + validation_gate: Arc>>>, collection: [u8; 32], ring: ResolvedRing, } @@ -1543,6 +353,10 @@ mod tests { } async fn validate(&self, _location: &v01::RingLocation) -> Result<[u8; 32], RingVrfError> { + let gate = self.validation_gate.lock().unwrap().take(); + if let Some(gate) = gate { + gate.await.unwrap(); + } Ok(self.collection) } @@ -1600,6 +414,18 @@ mod tests { assert!(!granted, "the closed default: no manifest reachable means no grant"); } + fn local<'a>( + call: &'a CallContext, + session: &'a AuthoritySession, + product: &'a ProductContext, + ) -> AccountInvocation<'a> { + AccountInvocation { call, session, caller: AccountCaller::Local { product } } + } + + fn active_session(authority: &SigningHostRole) -> AuthoritySession { + authority.wallet_for_tests().current_session().expect("active session") + } + fn signing_runtime() -> (Arc, Arc) { // Auto-confirm raw signing so the role-neutral confirmation gate does // not reject before reaching the signing authority. @@ -1643,11 +469,11 @@ mod tests { fn product_runtime( services: Arc, authority: Arc, - ) -> ProductRuntimeHost { + ) -> ProductRuntimeHost { ProductRuntimeHost::from_services( services.clone(), crate::host_core::ConnectionAdapters::from_services(&services), - authority.clone(), + authority.accounts().clone(), authority, ProductContext::new("myapp.dot".to_string()).expect("valid product id"), ) @@ -1657,16 +483,26 @@ mod tests { services: Arc, authority: Arc, product_id: &str, - ) -> ProductRuntimeHost { + ) -> ProductRuntimeHost { ProductRuntimeHost::from_services( services.clone(), crate::host_core::ConnectionAdapters::from_services(&services), - authority.clone(), + authority.accounts().clone(), authority, ProductContext::new(product_id.to_string()).expect("valid product id"), ) } + /// Whether this host keeps an AutoSigning key for `product_id` in this activation. + fn keeps_auto_signing(authority: &SigningHostRole, product_id: &str) -> bool { + let session = authority.session_state().current().expect("an active wallet"); + futures::executor::block_on( + authority.accounts().grants_for_tests().auto_signing_key(&session, product_id), + ) + .expect("kept keys are readable") + .is_some() + } + fn vrf_request(product_id: &str) -> v01::HostAccountSignVrfRequest { v01::HostAccountSignVrfRequest { account: v01::ProductAccountId { @@ -1694,6 +530,7 @@ mod tests { .member(&full_entropy) .expect("full-person member"); Arc::new(StubRingResolver { + validation_gate: Default::default(), collection: *b"pop:polkadot.network/people ", ring: ResolvedRing { selected: MemberCandidate { member: full_member }, @@ -1757,7 +594,7 @@ mod tests { signing_runtime_with_ring_resolver(platform.clone(), full_person_ring_resolver()); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); - let session = authority.current_session().expect("active session"); + let session = active_session(&authority); let ring_location = full_person_ring_location(); register_full_person_key(&authority, &session, &ring_location); @@ -1779,7 +616,7 @@ mod tests { signing_runtime_with_ring_resolver(platform.clone(), full_person_ring_resolver()); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); - let session = authority.current_session().expect("active session"); + let session = active_session(&authority); let ring_location = full_person_ring_location(); register_full_person_key(&authority, &session, &ring_location); @@ -1861,7 +698,7 @@ mod tests { signing_runtime_with_ring_resolver(platform.clone(), full_person_ring_resolver()); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); - let session = authority.current_session().expect("active session"); + let session = active_session(&authority); let ring = full_person_ring_location(); register_full_person_key(&authority, &session, &ring); let context = v01::ProductProofContext { @@ -1869,16 +706,16 @@ mod tests { suffix: v01::DerivationIndex::Index(0), }; let alias_for = |caller: &str| { - futures::executor::block_on(authority.account_alias( - &CallContext::default(), - &session, - ProductRequest { - calling_product_id: caller.to_string(), - payload: v01::HostAccountGetAliasRequest { - key_handle: full_person_key_handle(), - context: context.clone(), - ring_location: ring.clone(), - }, + futures::executor::block_on(authority.wallet_for_tests().account_alias( + local( + &CallContext::default(), + &session, + &ProductContext::new(caller.to_string()).unwrap(), + ), + v01::HostAccountGetAliasRequest { + key_handle: full_person_key_handle(), + context: context.clone(), + ring_location: ring.clone(), }, )) }; @@ -1926,25 +763,25 @@ mod tests { signing_runtime_with_ring_resolver(platform.clone(), full_person_ring_resolver()); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); - let session = authority.current_session().expect("active session"); + let session = active_session(&authority); let ring = full_person_ring_location(); register_full_person_key(&authority, &session, &ring); let mint = |caller: &str, context: &str| { - futures::executor::block_on(authority.create_proof( - &CallContext::default(), - &session, - ProductRequest { - calling_product_id: caller.to_string(), - payload: v01::HostAccountCreateProofRequest { - key_handle: full_person_key_handle(), - context: v01::ProductProofContext { - product_id: context.to_string(), - suffix: v01::DerivationIndex::Index(0), - }, - ring_location: ring.clone(), - message: b"m".to_vec(), + futures::executor::block_on(authority.wallet_for_tests().create_proof( + local( + &CallContext::default(), + &session, + &ProductContext::new(caller.to_string()).unwrap(), + ), + v01::HostAccountCreateProofRequest { + key_handle: full_person_key_handle(), + context: v01::ProductProofContext { + product_id: context.to_string(), + suffix: v01::DerivationIndex::Index(0), }, + ring_location: ring.clone(), + message: b"m".to_vec(), }, )) }; @@ -2020,33 +857,27 @@ mod tests { signing_runtime_with_ring_resolver(platform.clone(), full_person_ring_resolver()); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); - let session = authority.current_session().expect("active session"); + let session = active_session(&authority); let ring = full_person_ring_location(); register_full_person_key(&authority, &session, &ring); + let cx = CallContext::default(); + let caller = ProductContext::new("dim2next.dot".to_string()).unwrap(); - let proof = futures::executor::block_on(authority.create_proof( - &CallContext::default(), - &session, - ProductRequest { - calling_product_id: "dim2next.dot".to_string(), - payload: v01::HostAccountCreateProofRequest { - key_handle: full_person_key_handle(), - context: context.clone(), - ring_location: ring.clone(), - message: b"m".to_vec(), - }, + let proof = futures::executor::block_on(authority.wallet_for_tests().create_proof( + local(&cx, &session, &caller), + v01::HostAccountCreateProofRequest { + key_handle: full_person_key_handle(), + context: context.clone(), + ring_location: ring.clone(), + message: b"m".to_vec(), }, )); - let alias = futures::executor::block_on(authority.account_alias( - &CallContext::default(), - &session, - ProductRequest { - calling_product_id: "dim2next.dot".to_string(), - payload: v01::HostAccountGetAliasRequest { - key_handle: full_person_key_handle(), - context, - ring_location: ring, - }, + let alias = futures::executor::block_on(authority.wallet_for_tests().account_alias( + local(&cx, &session, &caller), + v01::HostAccountGetAliasRequest { + key_handle: full_person_key_handle(), + context, + ring_location: ring, }, )); (proof, alias) @@ -2191,19 +1022,19 @@ mod tests { signing_runtime_with_ring_resolver(platform.clone(), full_person_ring_resolver()); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); - let session = authority.current_session().expect("active session"); + let session = active_session(&authority); let ring = full_person_ring_location(); register_full_person_key(&authority, &session, &ring); - let listed = futures::executor::block_on(authority.list_ring_vrf_keys( - &CallContext::default(), - &session, - ProductRequest { - calling_product_id: "PEOPL.DOT".to_string(), - payload: v01::HostAccountListRingVrfKeysRequest { - owner: "peopl.dot".to_string(), - disclosure: v01::RingVrfKeyDisclosure::PublicKey, - }, + let listed = futures::executor::block_on(authority.wallet_for_tests().list_ring_vrf_keys( + local( + &CallContext::default(), + &session, + &ProductContext::new("PEOPL.DOT".to_string()).unwrap(), + ), + v01::HostAccountListRingVrfKeysRequest { + owner: "peopl.dot".to_string(), + disclosure: v01::RingVrfKeyDisclosure::PublicKey, }, )); assert!( @@ -2237,26 +1068,26 @@ mod tests { signing_runtime_with_ring_resolver(platform.clone(), full_person_ring_resolver()); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); - let session = authority.current_session().expect("active session"); + let session = active_session(&authority); let ring = full_person_ring_location(); register_full_person_key(&authority, &session, &ring); // `peopl.paseo` shares a label with the key's owner `peopl.dot` but is a // different product, so the grant admits it and the context still binds. - let minted = futures::executor::block_on(authority.create_proof( - &CallContext::default(), - &session, - ProductRequest { - calling_product_id: "peopl.paseo".to_string(), - payload: v01::HostAccountCreateProofRequest { - key_handle: full_person_key_handle(), - context: v01::ProductProofContext { - product_id: "bank.dot".to_string(), - suffix: v01::DerivationIndex::Index(0), - }, - ring_location: ring.clone(), - message: b"m".to_vec(), + let minted = futures::executor::block_on(authority.wallet_for_tests().create_proof( + local( + &CallContext::default(), + &session, + &ProductContext::new("peopl.paseo".to_string()).unwrap(), + ), + v01::HostAccountCreateProofRequest { + key_handle: full_person_key_handle(), + context: v01::ProductProofContext { + product_id: "bank.dot".to_string(), + suffix: v01::DerivationIndex::Index(0), }, + ring_location: ring.clone(), + message: b"m".to_vec(), }, )); assert_eq!( @@ -2279,24 +1110,24 @@ mod tests { signing_runtime_with_ring_resolver(platform.clone(), full_person_ring_resolver()); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); - let session = authority.current_session().expect("active session"); + let session = active_session(&authority); let ring = full_person_ring_location(); register_full_person_key(&authority, &session, &ring); let mint = |context: &str| { - futures::executor::block_on(authority.create_proof( - &CallContext::default(), - &session, - ProductRequest { - calling_product_id: "dim2.dot".to_string(), - payload: v01::HostAccountCreateProofRequest { - key_handle: full_person_key_handle(), - context: v01::ProductProofContext { - product_id: context.to_string(), - suffix: v01::DerivationIndex::Index(0), - }, - ring_location: ring.clone(), - message: b"m".to_vec(), + futures::executor::block_on(authority.wallet_for_tests().create_proof( + local( + &CallContext::default(), + &session, + &ProductContext::new("dim2.dot".to_string()).unwrap(), + ), + v01::HostAccountCreateProofRequest { + key_handle: full_person_key_handle(), + context: v01::ProductProofContext { + product_id: context.to_string(), + suffix: v01::DerivationIndex::Index(0), }, + ring_location: ring.clone(), + message: b"m".to_vec(), }, )) }; @@ -2368,16 +1199,15 @@ mod tests { let platform = Arc::new(StubPlatform { // The user declines, so the refusal is written by the production // path rather than by a test helper: this has to pin where - // `account_access_authorization` files it, not where a fixture does. + // `ProductConsent::account_access` files it, not where a fixture does. account_access_confirmed: false, ..StubPlatform::default() }); cache_grant(&platform, "peopl.dot", r#"{"ordinary":["context"]}"#); - futures::executor::block_on(crate::runtime::account_access_authorization( - platform.as_ref(), - "ordinary.dot", - "peopl.dot", - )) + futures::executor::block_on( + crate::runtime::product_consent::ProductConsent::new(platform.clone()) + .account_access("ordinary.dot", "peopl.dot"), + ) .expect("the stub records the declined decision"); let (services, _authority) = signing_runtime_with_ring_resolver(platform.clone(), full_person_ring_resolver()); @@ -2411,23 +1241,23 @@ mod tests { signing_runtime_with_ring_resolver(platform.clone(), full_person_ring_resolver()); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); - let session = authority.current_session().expect("active session"); + let session = active_session(&authority); let ring = full_person_ring_location(); register_full_person_key(&authority, &session, &ring); let alias = |caller: &str, context: &str| { - futures::executor::block_on(authority.account_alias( - &CallContext::default(), - &session, - ProductRequest { - calling_product_id: caller.to_string(), - payload: v01::HostAccountGetAliasRequest { - key_handle: full_person_key_handle(), - context: v01::ProductProofContext { - product_id: context.to_string(), - suffix: v01::DerivationIndex::Index(0), - }, - ring_location: ring.clone(), + futures::executor::block_on(authority.wallet_for_tests().account_alias( + local( + &CallContext::default(), + &session, + &ProductContext::new(caller.to_string()).unwrap(), + ), + v01::HostAccountGetAliasRequest { + key_handle: full_person_key_handle(), + context: v01::ProductProofContext { + product_id: context.to_string(), + suffix: v01::DerivationIndex::Index(0), }, + ring_location: ring.clone(), }, )) }; @@ -2476,24 +1306,24 @@ mod tests { signing_runtime_with_ring_resolver(platform.clone(), full_person_ring_resolver()); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); - let session = authority.current_session().expect("active session"); + let session = active_session(&authority); let ring = full_person_ring_location(); register_full_person_key(&authority, &session, &ring); let prove = |caller: &str| { - futures::executor::block_on(authority.create_proof( - &CallContext::default(), - &session, - ProductRequest { - calling_product_id: caller.to_string(), - payload: v01::HostAccountCreateProofRequest { - key_handle: full_person_key_handle(), - context: v01::ProductProofContext { - product_id: "peopl.dot".to_string(), - suffix: v01::DerivationIndex::Index(0), - }, - ring_location: ring.clone(), - message: b"m".to_vec(), + futures::executor::block_on(authority.wallet_for_tests().create_proof( + AccountInvocation { + call: &CallContext::default(), + session: &session, + caller: AccountCaller::Remote { product_id: Some(caller) }, + }, + v01::HostAccountCreateProofRequest { + key_handle: full_person_key_handle(), + context: v01::ProductProofContext { + product_id: "peopl.dot".to_string(), + suffix: v01::DerivationIndex::Index(0), }, + ring_location: ring.clone(), + message: b"m".to_vec(), }, )) }; @@ -2566,7 +1396,7 @@ mod tests { ring_resolver: Arc, ) -> (Arc, Arc) { let authority = SigningHostRole::new_with_ring_resolver(platform, ring_resolver); - (authority.services(), authority) + (authority.services.clone(), authority) } /// The grant admits `ring_vrf_sign`, not only `create_proof`. @@ -2746,21 +1576,21 @@ mod tests { signing_runtime_with_ring_resolver(platform, full_person_ring_resolver()); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); - let session = authority.current_session().expect("active session"); + let session = active_session(&authority); register_full_person_key(&authority, &session, &full_person_ring_location()); - futures::executor::block_on(authority.ring_vrf_sign( - &CallContext::default(), - &session, - ProductRequest { - calling_product_id: caller.to_string(), - payload: v01::HostAccountRingVrfSignRequest { - key_handle: v01::ProductAccountId { - dot_ns_identifier: handle_owner.to_string(), - derivation_index: v01::DerivationIndex::Index(0), - }, - message: b"sign me".to_vec(), + futures::executor::block_on(authority.wallet_for_tests().ring_vrf_sign( + AccountInvocation { + call: &CallContext::default(), + session: &session, + caller: AccountCaller::Remote { product_id: Some(caller) }, + }, + v01::HostAccountRingVrfSignRequest { + key_handle: v01::ProductAccountId { + dot_ns_identifier: handle_owner.to_string(), + derivation_index: v01::DerivationIndex::Index(0), }, + message: b"sign me".to_vec(), }, )) } @@ -2786,18 +1616,18 @@ mod tests { signing_runtime_with_ring_resolver(platform, full_person_ring_resolver()); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); - let session = authority.current_session().expect("active session"); + let session = active_session(&authority); register_full_person_key(&authority, &session, &full_person_ring_location()); - futures::executor::block_on(authority.ring_vrf_sign( - &CallContext::default(), - &session, - ProductRequest { - calling_product_id: caller.to_string(), - payload: v01::HostAccountRingVrfSignRequest { - key_handle: full_person_key_handle(), - message: b"sign me".to_vec(), - }, + futures::executor::block_on(authority.wallet_for_tests().ring_vrf_sign( + AccountInvocation { + call: &CallContext::default(), + session: &session, + caller: AccountCaller::Remote { product_id: Some(caller) }, + }, + v01::HostAccountRingVrfSignRequest { + key_handle: full_person_key_handle(), + message: b"sign me".to_vec(), }, )) } @@ -2823,24 +1653,24 @@ mod tests { signing_runtime_with_ring_resolver(platform.clone(), full_person_ring_resolver()); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); - let session = authority.current_session().expect("active session"); + let session = active_session(&authority); let ring_location = full_person_ring_location(); register_full_person_key(&authority, &session, &ring_location); - futures::executor::block_on(authority.create_proof( - &CallContext::default(), - &session, - ProductRequest { - calling_product_id: "dim2.dot".to_string(), - payload: v01::HostAccountCreateProofRequest { - key_handle: full_person_key_handle(), - context: v01::ProductProofContext { - product_id: "dim2.dot".to_string(), - suffix: v01::DerivationIndex::Index(0), - }, - ring_location, - message: b"prove me".to_vec(), + futures::executor::block_on(authority.wallet_for_tests().create_proof( + local( + &CallContext::default(), + &session, + &ProductContext::new("dim2.dot".to_string()).unwrap(), + ), + v01::HostAccountCreateProofRequest { + key_handle: full_person_key_handle(), + context: v01::ProductProofContext { + product_id: "dim2.dot".to_string(), + suffix: v01::DerivationIndex::Index(0), }, + ring_location, + message: b"prove me".to_vec(), }, )) } @@ -2878,93 +1708,22 @@ mod tests { session: &AuthoritySession, ring: &v01::RingLocation, ) { - futures::executor::block_on(authority.register_ring_vrf_key( - &CallContext::default(), - session, - ProductRequest { - calling_product_id: "peopl.dot".to_string(), - payload: HostAccountRegisterRingVrfKeyRequest { - index: v01::DerivationIndex::Index(0), - ring: ring.clone(), + futures::executor::block_on(authority.wallet_for_tests().register_ring_vrf_key( + AccountInvocation { + call: &CallContext::default(), + session, + caller: AccountCaller::Local { + product: &ProductContext::new("peopl.dot".to_string()).unwrap(), }, }, + HostAccountRegisterRingVrfKeyRequest { + index: v01::DerivationIndex::Index(0), + ring: ring.clone(), + }, )) .expect("full person key registration succeeds"); } - #[test] - fn internal_allowances_offer_both_reserved_person_handles_widest_first() { - let (_, authority) = signing_runtime(); - futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) - .expect("activation succeeds"); - let session = authority.current_session().expect("active session"); - let candidates = authority - .reserved_person_collection_candidates(&session) - .expect("reserved keys derive"); - - // Index 0 is the full-person handle and index 1 the light-person one, and - // People leads so a full person spends its wider slot budget first. - let expected = - [(PersonhoodCollection::People, 0u32), (PersonhoodCollection::LitePeople, 1)]; - assert_eq!(candidates.len(), expected.len()); - for (candidate, (collection, index)) in candidates.iter().zip(expected) { - assert_eq!(candidate.collection, collection); - assert_eq!( - candidate.entropy, - derive_ring_vrf_entropy(&ENTROPY, "peopl.dot", &v01::DerivationIndex::Index(index)) - .expect("reserved RFC-0024 handle derives"), - "{collection} candidate does not use peopl.dot/{index}", - ); - } - assert_ne!(candidates[0].entropy, candidates[1].entropy); - } - - #[test] - fn reserved_identities_follow_the_configured_network_suffix() { - // A wallet on paseo-next-v2 is the `peopl.paseo` person and the - // `uid.paseo` account: the ones a `peopl.paseo` product registers and the - // ones the identity backend records a lite username for. The `.dot` - // derivations of the same seed are a different person. - let platform: Arc = Arc::new(StubPlatform::default()); - let authority = SigningHostRole::new_with_ring_resolver_on( - platform, - full_person_ring_resolver(), - "paseo", - ); - futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) - .expect("activation succeeds"); - let session = authority.current_session().expect("active session"); - - let candidates = authority - .reserved_person_collection_candidates(&session) - .expect("reserved keys derive"); - for (candidate, index) in candidates.iter().zip([0u32, 1]) { - assert_eq!( - candidate.entropy, - derive_ring_vrf_entropy( - &ENTROPY, - "peopl.paseo", - &v01::DerivationIndex::Index(index) - ) - .expect("reserved RFC-0024 handle derives"), - "{} candidate does not use peopl.paseo/{index}", - candidate.collection - ); - assert_ne!( - candidate.entropy, - derive_ring_vrf_entropy(&ENTROPY, "peopl.dot", &v01::DerivationIndex::Index(index)) - .expect("reserved RFC-0024 handle derives"), - ); - } - - let identity = derive_identity_keypair(&ENTROPY, "paseo") - .expect("uid.paseo identity derivation") - .public - .to_bytes(); - assert_eq!(session.identity_account_id, Some(identity)); - assert_eq!(authority.identity_keypair().expect("identity").public.to_bytes(), identity); - } - #[test] fn ring_alias_and_proof_share_the_explicit_registered_key() { let resolver = full_person_ring_resolver(); @@ -2972,7 +1731,7 @@ mod tests { let authority = SigningHostRole::new_with_ring_resolver(platform, resolver); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); - let session = authority.current_session().expect("active session"); + let session = active_session(&authority); let cx = CallContext::default(); let context = v01::ProductProofContext { product_id: "myapp.dot".to_string(), @@ -2981,30 +1740,22 @@ mod tests { let ring_location = full_person_ring_location(); register_full_person_key(&authority, &session, &ring_location); - let alias = futures::executor::block_on(authority.account_alias( - &cx, - &session, - ProductRequest { - calling_product_id: "peopl.dot".to_string(), - payload: HostAccountGetAliasRequest { - key_handle: full_person_key_handle(), - context: context.clone(), - ring_location: ring_location.clone(), - }, + let alias = futures::executor::block_on(authority.wallet_for_tests().account_alias( + local(&cx, &session, &ProductContext::new("peopl.dot".to_string()).unwrap()), + HostAccountGetAliasRequest { + key_handle: full_person_key_handle(), + context: context.clone(), + ring_location: ring_location.clone(), }, )) .expect("alias succeeds"); - let proof = futures::executor::block_on(authority.create_proof( - &cx, - &session, - ProductRequest { - calling_product_id: "peopl.dot".to_string(), - payload: HostAccountCreateProofRequest { - key_handle: full_person_key_handle(), - context, - ring_location, - message: b"prove me".to_vec(), - }, + let proof = futures::executor::block_on(authority.wallet_for_tests().create_proof( + local(&cx, &session, &ProductContext::new("peopl.dot".to_string()).unwrap()), + HostAccountCreateProofRequest { + key_handle: full_person_key_handle(), + context, + ring_location, + message: b"prove me".to_vec(), }, )) .expect("proof succeeds"); @@ -3019,28 +1770,28 @@ mod tests { fn alias_checks_the_exact_registry_ring_before_resolving_it() { let platform: Arc = Arc::new(StubPlatform::default()); let authority = - SigningHostRole::new_with_ring_resolver(platform, full_person_ring_resolver()); + SigningHostRole::new_with_ring_resolver(platform.clone(), full_person_ring_resolver()); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); - let session = authority.current_session().expect("active session"); + let session = active_session(&authority); let registered_ring = full_person_ring_location(); register_full_person_key(&authority, &session, ®istered_ring); - let error = futures::executor::block_on(authority.account_alias( - &CallContext::default(), - &session, - ProductRequest { - calling_product_id: "peopl.dot".to_string(), - payload: HostAccountGetAliasRequest { - key_handle: full_person_key_handle(), - context: v01::ProductProofContext { - product_id: "myapp.dot".to_string(), - suffix: v01::DerivationIndex::Index(0), - }, - ring_location: v01::RingLocation { - chain_id: registered_ring.chain_id, - junctions: vec![], - }, + let error = futures::executor::block_on(authority.wallet_for_tests().account_alias( + local( + &CallContext::default(), + &session, + &ProductContext::new("peopl.dot".to_string()).unwrap(), + ), + HostAccountGetAliasRequest { + key_handle: full_person_key_handle(), + context: v01::ProductProofContext { + product_id: "myapp.dot".to_string(), + suffix: v01::DerivationIndex::Index(0), + }, + ring_location: v01::RingLocation { + chain_id: registered_ring.chain_id, + junctions: vec![], }, }, )) @@ -3053,31 +1804,34 @@ mod tests { fn direct_signing_rejects_registry_public_key_mismatched_with_wallet() { let platform: Arc = Arc::new(StubPlatform::default()); let authority = - SigningHostRole::new_with_ring_resolver(platform, full_person_ring_resolver()); + SigningHostRole::new_with_ring_resolver(platform.clone(), full_person_ring_resolver()); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); - let session = authority.current_session().expect("active session"); + let session = active_session(&authority); let handle = v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), derivation_index: v01::DerivationIndex::Index(8), }; - futures::executor::block_on(authority.ring_vrf_registry.register( - session.public_key, - handle.clone(), - full_person_ring_location(), - [0xFF; 32], - )) + futures::executor::block_on( + crate::runtime::ring_vrf_registry::RingVrfRegistryStore::new(platform.clone()) + .register( + session.public_key, + handle.clone(), + full_person_ring_location(), + [0xFF; 32], + ), + ) .expect("synthetic registry entry persists"); - let error = futures::executor::block_on(authority.ring_vrf_sign( - &CallContext::default(), - &session, - ProductRequest { - calling_product_id: "myapp.dot".to_string(), - payload: HostAccountRingVrfSignRequest { - key_handle: handle, - message: b"reject mismatched registry state".to_vec(), - }, + let error = futures::executor::block_on(authority.wallet_for_tests().ring_vrf_sign( + local( + &CallContext::default(), + &session, + &ProductContext::new("myapp.dot".to_string()).unwrap(), + ), + HostAccountRingVrfSignRequest { + key_handle: handle, + message: b"reject mismatched registry state".to_vec(), }, )) .unwrap_err(); @@ -3095,7 +1849,7 @@ mod tests { SigningHostRole::new_with_ring_resolver(platform.clone(), full_person_ring_resolver()); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); - let session = authority.current_session().expect("active session"); + let session = active_session(&authority); let cx = CallContext::default(); let context = v01::ProductProofContext { product_id: "other.dot".to_string(), @@ -3104,31 +1858,23 @@ mod tests { let ring_location = full_person_ring_location(); register_full_person_key(&authority, &session, &ring_location); - let alias = futures::executor::block_on(authority.account_alias( - &cx, - &session, - ProductRequest { - calling_product_id: "myapp.dot".to_string(), - payload: HostAccountGetAliasRequest { - key_handle: full_person_key_handle(), - context: context.clone(), - ring_location: ring_location.clone(), - }, + let alias = futures::executor::block_on(authority.wallet_for_tests().account_alias( + local(&cx, &session, &ProductContext::new("myapp.dot".to_string()).unwrap()), + HostAccountGetAliasRequest { + key_handle: full_person_key_handle(), + context: context.clone(), + ring_location: ring_location.clone(), }, )); assert_eq!(alias, Err(RingVrfError::Rejected)); - let proof = futures::executor::block_on(authority.create_proof( - &cx, - &session, - ProductRequest { - calling_product_id: "myapp.dot".to_string(), - payload: HostAccountCreateProofRequest { - key_handle: full_person_key_handle(), - context, - ring_location, - message: b"prove me".to_vec(), - }, + let proof = futures::executor::block_on(authority.wallet_for_tests().create_proof( + local(&cx, &session, &ProductContext::new("myapp.dot".to_string()).unwrap()), + HostAccountCreateProofRequest { + key_handle: full_person_key_handle(), + context, + ring_location, + message: b"prove me".to_vec(), }, )); assert_eq!(proof, Err(RingVrfError::NotAllowlisted)); @@ -3150,7 +1896,7 @@ mod tests { SigningHostRole::new_with_ring_resolver(platform.clone(), full_person_ring_resolver()); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); - let session = authority.current_session().expect("active session"); + let session = active_session(&authority); let cx = CallContext::default(); let request = ProductRequest { calling_product_id: "myapp.dot".to_string(), @@ -3165,10 +1911,16 @@ mod tests { }; register_full_person_key(&authority, &session, &request.payload.ring_location); - futures::executor::block_on(authority.account_alias(&cx, &session, request.clone())) - .expect("first alias succeeds"); - futures::executor::block_on(authority.account_alias(&cx, &session, request)) - .expect("second alias succeeds from cached grant"); + futures::executor::block_on(authority.wallet_for_tests().account_alias( + local(&cx, &session, &ProductContext::new(request.calling_product_id.clone()).unwrap()), + request.payload.clone(), + )) + .expect("first alias succeeds"); + futures::executor::block_on(authority.wallet_for_tests().account_alias( + local(&cx, &session, &ProductContext::new(request.calling_product_id.clone()).unwrap()), + request.payload, + )) + .expect("second alias succeeds from cached grant"); assert_eq!( platform @@ -3186,7 +1938,7 @@ mod tests { futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); - let session = authority.current_session().expect("active session"); + let session = active_session(&authority); let identity = derive_identity_keypair(&ENTROPY, TEST_NETWORK_SUFFIX) .expect("uid.dot identity derivation") .public @@ -3231,7 +1983,7 @@ mod tests { let (_services, authority) = signing_runtime(); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); - let session = authority.current_session().expect("active session"); + let session = active_session(&authority); let request = v01::HostAccountSignVrfRequest { account: product_account(0), transcript_label: b"pop:airdrop".to_vec(), @@ -3244,10 +1996,12 @@ mod tests { ], }; - let signature = futures::executor::block_on(authority.sign_vrf( - &CallContext::default(), - &session, - "myapp.dot".to_string(), + let signature = futures::executor::block_on(authority.wallet_for_tests().sign_vrf( + local( + &CallContext::default(), + &session, + &ProductContext::new("myapp.dot".to_string()).unwrap(), + ), request, )) .expect("VRF signing succeeds"); @@ -3295,14 +2049,14 @@ mod tests { 1, ); - let session = authority.current_session().expect("active session"); - futures::executor::block_on(authority.sign_vrf( - &CallContext::default(), + let session = authority.accounts().current_session().expect("active session"); + futures::executor::block_on(authority.accounts().sign_vrf( &session, - "myapp.dot".to_string(), + &CallContext::default(), + &ProductContext::new("myapp.dot".to_string()).unwrap(), vrf_request("myapp.dot"), )) - .expect("granted product signs without another confirmation"); + .expect("granted product signs with its kept key"); assert!( platform .sign_vrf_reviews @@ -3312,10 +2066,10 @@ mod tests { "the allocation grant bypasses only the subsequent VRF prompt", ); - let error = futures::executor::block_on(authority.sign_vrf( - &CallContext::default(), + let error = futures::executor::block_on(authority.accounts().sign_vrf( &session, - "other.dot".to_string(), + &CallContext::default(), + &ProductContext::new("other.dot".to_string()).unwrap(), vrf_request("myapp.dot"), )) .expect_err("different calling product remains confirmation-bound"); @@ -3330,179 +2084,118 @@ mod tests { #[test] fn product_clear_revokes_only_current_activation_grant_and_fences_stale_work() { - let platform = Arc::new(StubPlatform::default()); - let (_services, authority) = signing_runtime_with_platform(platform); - futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) - .expect("activation succeeds"); - let stale_session = authority.current_session().expect("active session"); - authority - .grant_auto_signing(&stale_session, "myapp.dot") - .expect("first product grant succeeds"); - authority - .grant_auto_signing(&stale_session, "other.dot") - .expect("other product grant succeeds"); - - authority.clear_product_state("myapp.dot").expect("product clear succeeds"); - - let current_session = authority.current_session().expect("session remains active"); - let (_, current_generation) = authority - .require_current_session(¤t_session) - .expect("current session validates"); - assert!(!authority.has_auto_signing_grant( - current_generation, - current_session.public_key, - "myapp.dot", - "myapp.dot", - )); - assert!(authority.has_auto_signing_grant( - current_generation, - current_session.public_key, - "other.dot", - "other.dot", - )); - assert!(matches!( - authority.grant_auto_signing(&stale_session, "myapp.dot"), - Err(AuthorityError::Disconnected) - )); - } - - #[test] - fn auto_signing_grant_does_not_cross_root_identity_replacement() { let platform = Arc::new(StubPlatform { resource_allocation_confirmed: true, - sign_vrf_confirmed: false, ..StubPlatform::default() }); - let (services, authority) = signing_runtime_with_platform(platform.clone()); - futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) - .expect("first activation succeeds"); - let runtime = product_runtime(services, authority.clone()); - futures::executor::block_on(ResourceAllocation::request( - &runtime, - &CallContext::default(), - HostRequestResourceAllocationRequest::V1(v01::HostRequestResourceAllocationRequest { - resources: vec![v01::AllocatableResource::AutoSigning], - }), - )) - .expect("AutoSigning allocation succeeds"); - - futures::executor::block_on(authority.activate_local_session([0xCD; 16].to_vec())) - .expect("replacement activation succeeds"); - let replacement = authority.current_session().expect("replacement session"); - let error = futures::executor::block_on(authority.sign_vrf( - &CallContext::default(), - &replacement, - "myapp.dot".to_string(), - vrf_request("myapp.dot"), - )) - .expect_err("replacement root must receive its own confirmation"); - assert_eq!(error, AuthorityError::Rejected); + let (services, authority) = signing_runtime_with_platform(platform); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())).unwrap(); + let authority_session = authority.accounts().current_session().unwrap(); + let revision = authority.accounts().grants_for_tests().lifecycle().revision(); + for product in ["myapp.dot", "other.dot"] { + auto_signing::grant_auto_signing(&product_runtime_for( + services.clone(), + authority.clone(), + product, + )); + } + let wallet_session = authority.session_state().current().unwrap(); + let key = futures::executor::block_on( + authority + .accounts() + .grants_for_tests() + .auto_signing_key(&wallet_session, "myapp.dot"), + ) + .unwrap() + .unwrap(); + let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); + let subtree = + crate::host_logic::product_account::derive_product_subtree_keypair(&root, "myapp.dot") + .unwrap(); + futures::executor::block_on(authority.clear_product_state("myapp.dot")).unwrap(); + let current_session = authority.wallet_for_tests().current_session().unwrap(); + let own = keeps_auto_signing(&authority, "myapp.dot"); + let other = keeps_auto_signing(&authority, "other.dot"); + let stale = futures::executor::block_on( + authority.accounts().grants_for_tests().remember_auto_signing_key( + &authority.session_state(), + &wallet_session, + revision, + "myapp.dot", + subtree.public.to_bytes(), + key, + ), + ); assert_eq!( - platform - .sign_vrf_reviews - .lock() - .expect("VRF signing review list mutex poisoned") - .len(), - 1, + (current_session, own, other, stale), + (authority_session.clone(), false, true, Err(AuthorityError::Disconnected)), ); } #[test] - fn auto_signing_grant_does_not_cross_disconnect_and_same_wallet_reactivation() { - let platform = Arc::new(StubPlatform { - resource_allocation_confirmed: true, - sign_vrf_confirmed: false, - ..StubPlatform::default() - }); - let (services, authority) = signing_runtime_with_platform(platform.clone()); - futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) - .expect("first activation succeeds"); - let runtime = product_runtime(services, authority.clone()); - futures::executor::block_on(ResourceAllocation::request( - &runtime, - &CallContext::default(), - HostRequestResourceAllocationRequest::V1(v01::HostRequestResourceAllocationRequest { - resources: vec![v01::AllocatableResource::AutoSigning], - }), - )) - .expect("AutoSigning allocation succeeds"); - - futures::executor::block_on(authority.disconnect()); - futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) - .expect("same wallet reactivation succeeds"); - let reactivated = authority.current_session().expect("reactivated session"); - let error = futures::executor::block_on(authority.sign_vrf( - &CallContext::default(), - &reactivated, - "myapp.dot".to_string(), - vrf_request("myapp.dot"), - )) - .expect_err("reactivated wallet must receive its own confirmation"); - assert_eq!(error, AuthorityError::Rejected); - assert_eq!( - platform - .sign_vrf_reviews - .lock() - .expect("VRF signing review list mutex poisoned") - .len(), - 1, - ); + fn auto_signing_grant_does_not_survive_wallet_replacement() { + for disconnect in [false, true] { + let platform = Arc::new(StubPlatform { + resource_allocation_confirmed: true, + ..StubPlatform::default() + }); + let (services, authority) = signing_runtime_with_platform(platform); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .unwrap(); + auto_signing::grant_auto_signing(&product_runtime(services, authority.clone())); + assert!(keeps_auto_signing(&authority, "myapp.dot")); + + let entropy = if disconnect { + futures::executor::block_on(authority.disconnect()); + ENTROPY.to_vec() + } else { + vec![0xCD; 16] + }; + futures::executor::block_on(authority.activate_local_session(entropy)).unwrap(); + assert!(!keeps_auto_signing(&authority, "myapp.dot"), "disconnect: {disconnect}",); + } } #[test] fn stale_auto_signing_completion_cannot_grant_same_wallet_reactivation() { - let platform = - Arc::new(StubPlatform { sign_vrf_confirmed: false, ..StubPlatform::default() }); - let (_services, authority) = signing_runtime_with_platform(platform.clone()); + let (_services, authority) = + signing_runtime_with_platform(Arc::new(StubPlatform::default())); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("first activation succeeds"); - let stale = authority.current_session().expect("first session snapshot"); + let stale = authority.accounts().current_session().expect("first session snapshot"); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("same wallet replacement activation succeeds"); - let current = authority.current_session().expect("replacement session"); + let current = active_session(&authority); assert_ne!(stale.validation_id, current.validation_id); - let error = futures::executor::block_on(authority.allocate_resources( + let error = futures::executor::block_on(authority.accounts().allocate_resources( &CallContext::default(), &stale, - "myapp.dot".to_string(), + &ProductContext::new("myapp.dot".to_string()).unwrap(), v01::HostRequestResourceAllocationRequest { resources: vec![v01::AllocatableResource::AutoSigning], }, )) .expect_err("completion captured from the old activation is stale"); - assert_eq!(error, AuthorityError::Disconnected); - - let error = futures::executor::block_on(authority.sign_vrf( - &CallContext::default(), - ¤t, - "myapp.dot".to_string(), - vrf_request("myapp.dot"), - )) - .expect_err("stale allocation must not grant the replacement activation"); - assert_eq!(error, AuthorityError::Rejected); assert_eq!( - platform - .sign_vrf_reviews - .lock() - .expect("VRF signing review list mutex poisoned") - .len(), - 1, + (error, keeps_auto_signing(&authority, "myapp.dot"),), + (AuthorityError::Disconnected, false), ); } + /// A wallet's own host keeps AutoSigning keys in memory for one activation, so + /// another runtime over the same wallet and storage starts without them. #[test] fn auto_signing_grant_does_not_cross_runtime_instance() { let platform = Arc::new(StubPlatform { resource_allocation_confirmed: true, - sign_vrf_confirmed: false, ..StubPlatform::default() }); let (services, granting_authority) = signing_runtime_with_platform(platform.clone()); futures::executor::block_on(granting_authority.activate_local_session(ENTROPY.to_vec())) .expect("granting runtime activates"); - let granting_runtime = product_runtime(services, granting_authority); + let granting_runtime = product_runtime(services, granting_authority.clone()); futures::executor::block_on(ResourceAllocation::request( &granting_runtime, &CallContext::default(), @@ -3512,25 +2205,15 @@ mod tests { )) .expect("AutoSigning allocation succeeds"); - let (_replacement_services, replacement) = signing_runtime_with_platform(platform.clone()); + let (_replacement_services, replacement) = signing_runtime_with_platform(platform); futures::executor::block_on(replacement.activate_local_session(ENTROPY.to_vec())) .expect("replacement runtime activates with the same root"); - let session = replacement.current_session().expect("replacement session"); - let error = futures::executor::block_on(replacement.sign_vrf( - &CallContext::default(), - &session, - "myapp.dot".to_string(), - vrf_request("myapp.dot"), - )) - .expect_err("a separate runtime must receive its own confirmation"); - assert_eq!(error, AuthorityError::Rejected); assert_eq!( - platform - .sign_vrf_reviews - .lock() - .expect("VRF signing review list mutex poisoned") - .len(), - 1, + ( + keeps_auto_signing(&granting_authority, "myapp.dot"), + keeps_auto_signing(&replacement, "myapp.dot"), + ), + (true, false), ); } @@ -3539,7 +2222,7 @@ mod tests { let (_services, authority) = signing_runtime(); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); - let session = authority.current_session().expect("active session"); + let session = active_session(&authority); let cx = CallContext::default(); let mut payload = crate::test_support::sign_payload_data(); payload.signed_extensions = vec![ @@ -3553,16 +2236,15 @@ mod tests { payload.with_signed_transaction = parity_scale_codec::OptionBool(Some(true)); let preimage = extrinsic_payload_preimage(&payload).expect("preimage builds"); - let product_response = futures::executor::block_on(authority.sign_payload( - &cx, - &session, - None, - SignPayloadAuthorityRequest::Product(v01::HostSignPayloadRequest { - account: product_account(0), - payload: payload.clone(), - }), - )) - .expect("product payload signing succeeds"); + let product_response = + futures::executor::block_on(authority.wallet_for_tests().sign_payload( + local(&cx, &session, &ProductContext::new("myapp.dot".to_string()).unwrap()), + SignPayloadAuthorityRequest::Product(v01::HostSignPayloadRequest { + account: product_account(0), + payload: payload.clone(), + }), + )) + .expect("product payload signing succeeds"); let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); let keypair = derive_product_keypair(&root, "myapp.dot", index_bytes(0)).unwrap(); @@ -3591,19 +2273,18 @@ mod tests { .collect::>(); assert_eq!(tail, expected_tail); - let legacy_response = futures::executor::block_on(authority.sign_payload( - &cx, - &session, - None, - SignPayloadAuthorityRequest::LegacyAccount { - product_account: product_account(0), - request: v01::HostSignPayloadWithLegacyAccountRequest { - signer: format!("0x{}", hex::encode(keypair.public.to_bytes())), - payload, + let legacy_response = + futures::executor::block_on(authority.wallet_for_tests().sign_payload( + local(&cx, &session, &ProductContext::new("myapp.dot".to_string()).unwrap()), + SignPayloadAuthorityRequest::LegacyAccount { + product_account: product_account(0), + request: v01::HostSignPayloadWithLegacyAccountRequest { + signer: format!("0x{}", hex::encode(keypair.public.to_bytes())), + payload, + }, }, - }, - )) - .expect("legacy payload signing succeeds"); + )) + .expect("legacy payload signing succeeds"); assert_eq!(legacy_response.signature[0], 1); let signature = schnorrkel::Signature::from_bytes(&legacy_response.signature[1..]).unwrap(); assert!( @@ -3620,10 +2301,10 @@ mod tests { let (_services, authority) = signing_runtime(); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); - let session = authority.current_session().expect("active session"); + let session = active_session(&authority); let cx = CallContext::default(); let identity = derive_identity_keypair(&ENTROPY, TEST_NETWORK_SUFFIX).unwrap(); - let request = |account| SignRawAuthorityRequest::LegacyAccount { + let request = |account| SignRawAuthorityRequest::IdentityAccount { account, request: v01::HostSignRawWithLegacyAccountRequest { signer: String::new(), @@ -3631,10 +2312,8 @@ mod tests { }, }; - let response = futures::executor::block_on(authority.sign_raw( - &cx, - &session, - None, + let response = futures::executor::block_on(authority.wallet_for_tests().sign_raw( + local(&cx, &session, &ProductContext::new("myapp.dot".to_string()).unwrap()), request(identity.public.to_bytes()), true, )) @@ -3647,10 +2326,8 @@ mod tests { .is_ok() ); - let error = futures::executor::block_on(authority.sign_raw( - &cx, - &session, - None, + let error = futures::executor::block_on(authority.wallet_for_tests().sign_raw( + local(&cx, &session, &ProductContext::new("myapp.dot".to_string()).unwrap()), request([0xff; 32]), true, )) @@ -3706,13 +2383,11 @@ mod tests { let (_services, activation) = signing_runtime_with_platform(platform); futures::executor::block_on(activation.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); - let session = activation.current_session().expect("active session"); + let session = activation.wallet_for_tests().current_session().expect("active session"); let cx = CallContext::default(); - let err = futures::executor::block_on(activation.create_transaction( - &cx, - &session, - None, + let err = futures::executor::block_on(activation.wallet_for_tests().create_transaction( + local(&cx, &session, &ProductContext::new("myapp.dot".to_string()).unwrap()), CreateTransactionAuthorityRequest::Product(tx_payload(0)), )) .expect_err("fixture cannot resolve metadata"); @@ -3727,7 +2402,7 @@ mod tests { let (_services, activation) = signing_runtime(); futures::executor::block_on(activation.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); - let session = activation.current_session().expect("active session"); + let session = activation.wallet_for_tests().current_session().expect("active session"); let cx = CallContext::default(); let payload = tx_payload(0); @@ -3741,9 +2416,10 @@ mod tests { tx_ext_version: 0, }, }; - let err = futures::executor::block_on( - activation.create_transaction(&cx, &session, None, request), - ) + let err = futures::executor::block_on(activation.wallet_for_tests().create_transaction( + local(&cx, &session, &ProductContext::new("myapp.dot".to_string()).unwrap()), + request, + )) .expect_err("mismatched legacy signer"); assert!( matches!(err, AuthorityError::Unknown { reason } if reason.contains("does not match")) @@ -3757,14 +2433,12 @@ mod tests { // request against a role that has never been activated. let (_s2, other) = signing_runtime(); futures::executor::block_on(other.activate_local_session(ENTROPY.to_vec())).unwrap(); - let stale_session = other.current_session().expect("session"); + let stale_session = other.wallet_for_tests().current_session().expect("session"); futures::executor::block_on(other.disconnect()); let cx = CallContext::default(); - let err = futures::executor::block_on(activation.create_transaction( - &cx, - &stale_session, - None, + let err = futures::executor::block_on(activation.wallet_for_tests().create_transaction( + local(&cx, &stale_session, &ProductContext::new("myapp.dot".to_string()).unwrap()), CreateTransactionAuthorityRequest::Product(tx_payload(0)), )) .expect_err("no active session"); @@ -3809,6 +2483,31 @@ mod tests { )); } + /// The wallet derives a product's own subtree on the device, so the user is not asked. + #[test] + fn get_account_resolves_the_own_subtree_without_a_prompt() { + let platform = + Arc::new(StubPlatform { product_subtree_denied: true, ..StubPlatform::default() }); + let (services, authority) = signing_runtime_with_platform(platform.clone()); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + let runtime = product_runtime(services, authority); + let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { + product_account_id: product_account(0), + }); + + let HostAccountGetResponse::V1(inner) = + futures::executor::block_on(runtime.get_account(&CallContext::default(), request)) + .expect("the own account resolves"); + + let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); + let expected = derive_product_keypair(&root, "myapp.dot", index_bytes(0)).unwrap(); + assert_eq!( + (inner.account.public_key, platform.product_subtree_reviews.lock().unwrap().len()), + (expected.public.to_bytes().to_vec(), 0) + ); + } + #[test] fn sign_raw_leaves_already_wrapped_payload_untouched() { let (services, activation) = signing_runtime(); @@ -3850,13 +2549,13 @@ mod tests { let (_services, authority) = signing_runtime(); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("first activation"); - let stale = authority.current_session().expect("snapshot"); + let stale = active_session(&authority); // Re-activate with different entropy: a fresh public key, hence a // different validation id. futures::executor::block_on(authority.activate_local_session([0xCD; 16].to_vec())) .expect("second activation"); - assert_ne!(authority.current_session().expect("session").public_key, stale.public_key,); + assert_ne!(active_session(&authority).public_key, stale.public_key); let cx = CallContext::default(); let request = v01::HostSignRawRequest { @@ -3866,10 +2565,8 @@ mod tests { }, payload: v01::RawPayload::Bytes { bytes: vec![1, 2, 3] }, }; - let err = futures::executor::block_on(authority.sign_raw( - &cx, - &stale, - None, + let err = futures::executor::block_on(authority.wallet_for_tests().sign_raw( + local(&cx, &stale, &ProductContext::new("myapp.dot".to_string()).unwrap()), SignRawAuthorityRequest::Product(request), true, )) @@ -3882,10 +2579,10 @@ mod tests { let (_services, authority) = signing_runtime(); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("activation"); - let session = authority.current_session().expect("connected"); + let session = active_session(&authority); futures::executor::block_on(authority.disconnect()); - assert!(authority.current_session().is_none()); + assert!(authority.wallet_for_tests().current_session().is_none()); let cx = CallContext::default(); let request = v01::HostSignRawRequest { @@ -3895,10 +2592,8 @@ mod tests { }, payload: v01::RawPayload::Bytes { bytes: vec![1] }, }; - let err = futures::executor::block_on(authority.sign_raw( - &cx, - &session, - None, + let err = futures::executor::block_on(authority.wallet_for_tests().sign_raw( + local(&cx, &session, &ProductContext::new("myapp.dot".to_string()).unwrap()), SignRawAuthorityRequest::Product(request), true, )) @@ -3917,18 +2612,22 @@ mod tests { task::{Context, Poll}, }; - let platform = - Arc::new(StubPlatform { chain_connect_pending: true, ..StubPlatform::default() }); + let platform = Arc::new(StubPlatform { + chain_connect_pending: true, + resource_allocation_confirmed: true, + ..StubPlatform::default() + }); let (_services, authority) = signing_runtime_with_platform(platform); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("activation"); - let session = authority.current_session().expect("connected"); + let session = authority.accounts().current_session().expect("connected"); let cx = CallContext::default(); + let product = ProductContext::new("myapp.dot".to_string()).unwrap(); - let mut allocation = Box::pin(authority.allocate_resources( + let mut allocation = Box::pin(authority.accounts().allocate_resources( &cx, &session, - "myapp.dot".to_string(), + &product, v01::HostRequestResourceAllocationRequest { resources: vec![v01::AllocatableResource::SmartContractAllowance( v01::DerivationIndex::Index(0), @@ -3954,15 +2653,16 @@ mod tests { signing_runtime_with_platform(Arc::new(StubPlatform::default())); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("activation"); - let session = authority.current_session().expect("connected"); + let session = authority.accounts().current_session().expect("connected"); let cancel = truapi::CancellationToken::default(); cancel.cancel(); let cx = CallContext::with_parts("allocation-withdrawn".to_string(), cancel); + let product = ProductContext::new("myapp.dot".to_string()).unwrap(); - let result = futures::executor::block_on(authority.allocate_resources( + let result = futures::executor::block_on(authority.accounts().allocate_resources( &cx, &session, - "myapp.dot".to_string(), + &product, v01::HostRequestResourceAllocationRequest { resources: vec![v01::AllocatableResource::AutoSigning], }, @@ -3984,27 +2684,29 @@ mod tests { // resource failing does not poison the others. let platform = Arc::new(StubPlatform { chain_connect_error: Some("asset hub unavailable"), + resource_allocation_confirmed: true, ..StubPlatform::default() }); let (_services, authority) = signing_runtime_with_platform(platform); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("activation"); - let session = authority.current_session().expect("connected"); + let session = authority.accounts().current_session().expect("connected"); let cx = CallContext::default(); + let product = ProductContext::new("myapp.dot".to_string()).unwrap(); - let empty = futures::executor::block_on(authority.allocate_resources( + let empty = futures::executor::block_on(authority.accounts().allocate_resources( &cx, &session, - "myapp.dot".to_string(), + &product, v01::HostRequestResourceAllocationRequest { resources: vec![] }, )) .expect("empty allocation succeeds"); assert!(empty.outcomes.is_empty()); - let optional = futures::executor::block_on(authority.allocate_resources( + let optional = futures::executor::block_on(authority.accounts().allocate_resources( &cx, &session, - "myapp.dot".to_string(), + &product, v01::HostRequestResourceAllocationRequest { resources: vec![ v01::AllocatableResource::SmartContractAllowance(v01::DerivationIndex::Index( diff --git a/rust/crates/truapi/src/runtime/signing_host/allowance_renewal.rs b/rust/crates/truapi/src/runtime/signing_host/allowance_renewal.rs deleted file mode 100644 index 31da83eb13..0000000000 --- a/rust/crates/truapi/src/runtime/signing_host/allowance_renewal.rs +++ /dev/null @@ -1,199 +0,0 @@ -//! Ledger and driver for automatic statement-store allowance renewal. -//! -//! The ledger records which accounts this signing host promised to keep -//! allowed, as derivation recipes where possible so entries stay valid when -//! the host rotates to a new root entropy. The driver resolves them against -//! the active session and runs the chain-pure pass in -//! `statement_allowance::renewal`, either once (`renew_now`) or on a periodic -//! tick (`start_renewal_loop`). - -#[cfg(not(target_arch = "wasm32"))] -mod driver; - -#[cfg(not(target_arch = "wasm32"))] -pub use driver::{ - active_owner_key, list, renew_now, start_renewal_loop, untrack_account_for_signing_host, -}; - -use crate::{ - host_logic::product_account::derive_root_keypair_from_entropy, - platform::{CoreStorage, CoreStorageKey, normalize_product_identifier}, -}; -use futures::lock::Mutex; -use parity_scale_codec::{Decode, Encode}; -use tracing::warn; - -use super::SigningHost; - -/// A statement account or derivation recipe the wallet keeps renewed. -#[derive(Clone, Debug, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Enum))] -pub enum StatementRenewalTarget { - /// `//allowance//statement-store//{product_id}` from the active root entropy. - ProductStatementAllowance { - /// Product the allowance account belongs to. - product_id: String, - }, - /// `//wallet//sso` from the active root entropy. - WalletSso, - /// A fixed account, e.g. a pairing peer's device statement key. - Account { - /// Account to keep allowed. - account_id: [u8; 32], - /// Human-readable name used in logs and reports. - label: String, - }, -} - -/// Renewal recipes follow the active wallet; fixed accounts belong to their recorded owner. -#[derive(Clone, Debug, PartialEq, Eq, Encode, Decode)] -#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Record))] -pub struct TrackedStatementRenewalTarget { - /// The account, or the recipe for one, that the host promised to renew. - pub target: StatementRenewalTarget, - /// Root public key that promised a raw account id. A recipe carries none - /// and resolves under whichever identity is active. - pub owner: Option<[u8; 32]>, -} - -impl StatementRenewalTarget { - /// Match the product identifier used by account derivation. - fn normalized(self) -> Result { - match self { - Self::ProductStatementAllowance { product_id } => { - let normalized = normalize_product_identifier(&product_id).map_err(|_| { - format!("product_id {product_id} is not a valid product identifier") - })?; - Ok(Self::ProductStatementAllowance { product_id: normalized }) - }, - target @ (Self::WalletSso | Self::Account { .. }) => Ok(target), - } - } -} - -impl TrackedStatementRenewalTarget { - /// Record `target` under `owner`, which only raw account ids retain. - fn new(target: StatementRenewalTarget, owner: [u8; 32]) -> Self { - let owner = match &target { - StatementRenewalTarget::Account { .. } => Some(owner), - StatementRenewalTarget::ProductStatementAllowance { .. } | - StatementRenewalTarget::WalletSso => None, - }; - Self { target, owner } - } -} - -/// Root public key of the identity rooted at `entropy`, used to own raw ledger -/// entries. -fn owner_key(entropy: &[u8]) -> Result<[u8; 32], String> { - derive_root_keypair_from_entropy(entropy) - .map(|pair| pair.public.to_bytes()) - .map_err(|err| err.to_string()) -} - -/// Renewal coordination state owned by [`SigningHost`]. -#[derive(Default)] -pub struct RenewalState { - /// Serializes slot registrations between the renewal pass and on-demand - /// allocation so both cannot race for the same free slot. - registration_lock: Mutex<()>, - /// Serializes read-modify-write cycles on the ledger so a concurrent - /// allocation cannot drop another's entry. - ledger_lock: Mutex<()>, - #[cfg(not(target_arch = "wasm32"))] - driver: driver::DriverState, -} - -impl RenewalState { - /// Serialize registration scans with competing issuers. - pub fn registration_lock(&self) -> &Mutex<()> { - &self.registration_lock - } - - fn ledger_lock(&self) -> &Mutex<()> { - &self.ledger_lock - } -} - -/// Discard unreadable entries; later allocations rebuild the ledger. -async fn read_entries( - storage: &(impl CoreStorage + ?Sized), -) -> Result, String> { - let Some(blob) = storage - .read_core_storage(CoreStorageKey::StatementRenewalTargets) - .await - .map_err(|err| format!("renewal ledger read failed: {}", err.reason))? - else { - return Ok(Vec::new()); - }; - match decode_entries(&blob) { - Ok(entries) => Ok(entries), - Err(reason) => { - warn!(%reason, "discarding an undecodable renewal ledger"); - Ok(Vec::new()) - }, - } -} - -/// Append `new_targets` to the ledger, preserving order and skipping entries -/// already present. -async fn track_targets( - storage: &(impl CoreStorage + ?Sized), - ledger_lock: &Mutex<()>, - owner: [u8; 32], - new_targets: Vec, -) -> Result<(), String> { - let _guard = ledger_lock.lock().await; - let mut entries = read_entries(storage).await?; - let mut changed = false; - for target in new_targets { - let entry = TrackedStatementRenewalTarget::new(target, owner); - if !entries.contains(&entry) { - entries.push(entry); - changed = true; - } - } - if !changed { - return Ok(()); - } - write_entries(storage, &entries).await -} - -async fn write_entries( - storage: &(impl CoreStorage + ?Sized), - entries: &[TrackedStatementRenewalTarget], -) -> Result<(), String> { - storage - .write_core_storage(CoreStorageKey::StatementRenewalTargets, entries.encode()) - .await - .map_err(|err| format!("renewal ledger write failed: {}", err.reason)) -} - -fn decode_entries(blob: &[u8]) -> Result, String> { - let mut input = blob; - let entries = Vec::::decode(&mut input) - .map_err(|err| format!("invalid persisted renewal targets: {err}"))?; - if !input.is_empty() { - return Err("invalid persisted renewal targets: trailing bytes".to_string()); - } - Ok(entries) -} - -/// Record `targets` in the ledger under the active identity. -pub async fn track( - signing_host: &SigningHost, - targets: Vec, -) -> Result<(), String> { - let targets = targets - .into_iter() - .map(StatementRenewalTarget::normalized) - .collect::, _>>()?; - let entropy = signing_host.root_entropy().map_err(|err| err.to_string())?; - track_targets( - signing_host.platform.as_ref(), - signing_host.renewal.ledger_lock(), - owner_key(&entropy)?, - targets, - ) - .await -} diff --git a/rust/crates/truapi/src/runtime/signing_host/allowance_renewal/driver.rs b/rust/crates/truapi/src/runtime/signing_host/allowance_renewal/driver.rs deleted file mode 100644 index 607b3cf592..0000000000 --- a/rust/crates/truapi/src/runtime/signing_host/allowance_renewal/driver.rs +++ /dev/null @@ -1,1031 +0,0 @@ -//! Native statement-store allowance renewal management and scheduling. - -use std::{ - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, - time::Duration, -}; - -use futures::lock::Mutex; -use tracing::{debug, info, warn}; - -use super::{ - RenewalState, SigningHost, StatementRenewalTarget, TrackedStatementRenewalTarget, owner_key, - read_entries, write_entries, -}; -use crate::{ - host_logic::product_account::{derive_identity_keypair, derive_sr25519_hard_path}, - platform::CoreStorage, - runtime::{ - RuntimeServices, - authority::ProductAuthority, - signing_host::sso_responder::current_unix_secs, - statement_allowance::{ - self, fetch_chain_state, fetch_metadata, find_including_rings, - renewal::{ - RenewalChainContext, ResolvedRenewalTarget, StatementRenewalReport, - next_tick_delay, renew_targets, - }, - }, - }, -}; - -/// Fallback tick delay when the system clock is unusable. -const CLOCK_FAILURE_TICK_DELAY: Duration = Duration::from_secs(3_600); - -/// Native renewal loop coordination and its latest completed report. -#[derive(Default)] -pub struct DriverState { - loop_started: AtomicBool, - /// The most recent pass, so a host that drives the in-process loop can read - /// what it achieved. The loop computes a report on every tick and has no - /// caller to hand it to, and exhaustion is the outcome a host most needs to - /// act on. A blocking lock rather than an async one: every use is a clone or - /// a store with no await in between. - last_report: std::sync::Mutex>, -} - -impl TrackedStatementRenewalTarget { - /// Whether this entry belongs to the identity rooted at `owner`. - fn is_owned_by(&self, owner: [u8; 32]) -> bool { - self.owner.is_none_or(|recorded| recorded == owner) - } -} - -impl RenewalState { - /// Record the pass a host has not seen the return value of. - fn record_report(&self, report: &StatementRenewalReport) { - if let Ok(mut last) = self.driver.last_report.lock() { - *last = Some(report.clone()); - } - } - - /// The most recent pass the loop ran. `None` until one has run, which a host - /// should read as "not yet" rather than as healthy. - pub fn last_report(&self) -> Option { - self.driver.last_report.lock().ok().and_then(|last| last.clone()) - } -} - -/// Read a settled ledger while competing writes hold the same lock. -async fn list_entries( - storage: &(impl CoreStorage + ?Sized), - ledger_lock: &Mutex<()>, -) -> Result, String> { - let _guard = ledger_lock.lock().await; - read_entries(storage).await -} - -async fn untrack_account( - storage: &(impl CoreStorage + ?Sized), - ledger_lock: &Mutex<()>, - owner: [u8; 32], - account_id: &[u8; 32], -) -> Result { - let _guard = ledger_lock.lock().await; - let mut entries = read_entries(storage).await?; - let original_len = entries.len(); - entries.retain(|entry| { - entry.owner != Some(owner) || - !matches!( - &entry.target, - StatementRenewalTarget::Account { - account_id: existing, - .. - } if existing == account_id - ) - }); - if entries.len() == original_len { - return Ok(false); - } - write_entries(storage, &entries).await?; - Ok(true) -} - -/// Stable labels identify pruned entries even without an active wallet. -fn target_label(target: &StatementRenewalTarget) -> String { - match target { - StatementRenewalTarget::ProductStatementAllowance { product_id } => { - format!("product:{product_id}") - }, - StatementRenewalTarget::WalletSso => "wallet-sso".to_string(), - StatementRenewalTarget::Account { label, .. } => label.clone(), - } -} - -fn resolve_target( - entropy: &[u8], - network_suffix: &str, - target: &StatementRenewalTarget, -) -> Result { - let label = target_label(target); - match target { - StatementRenewalTarget::ProductStatementAllowance { product_id } => { - let pair = derive_sr25519_hard_path( - entropy, - &["allowance", "statement-store", product_id.as_str()], - ) - .map_err(|err| err.to_string())?; - Ok(ResolvedRenewalTarget { label, account_id: pair.public.to_bytes() }) - }, - StatementRenewalTarget::WalletSso => { - let pair = - derive_identity_keypair(entropy, network_suffix).map_err(|err| err.to_string())?; - Ok(ResolvedRenewalTarget { label, account_id: pair.public.to_bytes() }) - }, - StatementRenewalTarget::Account { account_id, .. } => { - Ok(ResolvedRenewalTarget { label, account_id: *account_id }) - }, - } -} - -/// List tracked entries without requiring wallet unlock. -pub async fn list( - signing_host: &SigningHost, -) -> Result, String> { - list_entries(signing_host.platform.as_ref(), signing_host.renewal.ledger_lock()).await -} - -/// Identify which fixed ledger entries belong to the active wallet. -pub fn active_owner_key(signing_host: &SigningHost) -> Result<[u8; 32], String> { - let entropy = signing_host.root_entropy().map_err(|err| err.to_string())?; - owner_key(&entropy) -} - -/// Stop renewing one fixed statement account for the active identity. -pub async fn untrack_account_for_signing_host( - signing_host: &SigningHost, - account_id: &[u8; 32], -) -> Result { - let entropy = signing_host.root_entropy().map_err(|err| err.to_string())?; - untrack_account( - signing_host.platform.as_ref(), - signing_host.renewal.ledger_lock(), - owner_key(&entropy)?, - account_id, - ) - .await -} - -/// Skip unusable entries so they cannot prevent renewal of other targets. -fn resolve_targets( - entropy: &[u8], - network_suffix: &str, - targets: &[StatementRenewalTarget], -) -> Vec { - targets - .iter() - .filter_map(|target| match resolve_target(entropy, network_suffix, target) { - Ok(resolved) => Some(resolved), - Err(reason) => { - warn!(?target, %reason, "skipping an unresolvable renewal target"); - None - }, - }) - .collect() -} - -/// The ledger targets `owner` promised, dropping any it did not. -/// -/// An entry promised by a different identity would consume this one's slots for -/// an account it never promised, so it is removed rather than skipped — the cost -/// is paid once per identity change instead of on every tick. The ledger is only -/// rewritten when something was actually dropped. -/// -/// The lock covers the read as well as the write, because this is a -/// read-modify-write of the whole ledger: reading outside it lets a -/// [`super::track_targets`] call land in the gap and be overwritten by a view that -/// predates it, leaving that account tracked nowhere and never renewed again. -/// -/// The labels are logged here rather than only returned. Every step between this -/// and the report can fail, and `run_tick` does not read the report at all, so -/// the log is the one place a prune is recorded unconditionally. -async fn owned_targets( - storage: &(impl CoreStorage + ?Sized), - ledger_lock: &Mutex<()>, - owner: [u8; 32], -) -> Result<(Vec, Vec), String> { - let _guard = ledger_lock.lock().await; - let (owned, foreign): (Vec<_>, Vec<_>) = read_entries(storage) - .await? - .into_iter() - .partition(|entry| entry.is_owned_by(owner)); - let pruned: Vec = foreign.iter().map(|entry| target_label(&entry.target)).collect(); - if !foreign.is_empty() { - warn!( - dropped = ?pruned, - "pruning renewal targets promised by a previous identity" - ); - write_entries(storage, &owned).await?; - } - Ok((owned.into_iter().map(|entry| entry.target).collect(), pruned)) -} - -/// One renewal pass: resolve the ledger against the active session and renew -/// every target for the current period. -pub async fn renew_now( - services: &Arc, - signing_host: &SigningHost, -) -> Result { - let entropy = signing_host.root_entropy().map_err(|err| err.to_string())?; - let period = statement_allowance::slot::current_period( - current_unix_secs().map_err(|err| err.to_string())?, - ); - let (targets, pruned) = owned_targets( - signing_host.platform.as_ref(), - signing_host.renewal.ledger_lock(), - owner_key(&entropy)?, - ) - .await?; - let resolved = resolve_targets(&entropy, signing_host.network_suffix(), &targets); - if resolved.is_empty() { - return Ok(StatementRenewalReport { - period, - outcomes: Vec::new(), - pruned, - slots_exhausted: false, - }); - } - - // The same accessor on-demand allocation uses, so a change to the reserved - // key reaches renewal too — and it revalidates the session first. - let session = signing_host - .current_session() - .ok_or_else(|| "no active session for statement-store renewal".to_string())?; - let candidates = signing_host - .reserved_person_collection_candidates(&session) - .map_err(|err| err.to_string())?; - let rpc = statement_allowance::rpc::RpcClient::new( - services - .statement_store - .client("statement-allowance renewal") - .await - .map_err(|err| err.to_string())?, - ); - let metadata = fetch_metadata(&rpc).await.map_err(|err| err.to_string())?; - let chain_state = fetch_chain_state(&rpc).await.map_err(|err| err.to_string())?; - let network_suffix = statement_allowance::slot::read_network_suffix(&rpc) - .await - .map_err(|err| err.to_string())?; - // Every ring back to index 0, because a membership that stopped being - // re-included still proves against the ring that holds it. - let signer = statement_allowance::FixedPersonhoodSigner::new(&candidates) - .await - .map_err(|err| err.to_string())?; - let collections: Vec<_> = candidates.iter().map(|candidate| candidate.collection).collect(); - let memberships = find_including_rings(&rpc, &metadata, &signer, &collections, u32::MAX) - .await - .map_err(|err| err.to_string())?; - if memberships.is_empty() { - return Err( - "signing account is not a member of any personhood ring; cannot renew statement-store allowances" - .to_string(), - ); - } - let context = RenewalChainContext { - rpc: &rpc, - metadata: &metadata, - chain_state: &chain_state, - network_suffix: &network_suffix, - signer: &signer, - collections: &collections, - memberships: &memberships, - }; - let mut report = - renew_targets(&context, period, &resolved, signing_host.renewal.registration_lock()).await; - report.pruned = pruned; - Ok(report) -} - -/// Spawn the periodic renewal loop; repeated calls are no-ops. The loop holds -/// only weak references, so it exits when the owning runtime is dropped. -pub fn start_renewal_loop(services: &Arc, signing_host: &Arc) { - if signing_host.renewal.driver.loop_started.swap(true, Ordering::SeqCst) { - return; - } - let weak_services = Arc::downgrade(services); - let weak_host = Arc::downgrade(signing_host); - let spawner = services.spawner.clone(); - spawner(Box::pin(async move { - loop { - { - let (Some(services), Some(signing_host)) = - (weak_services.upgrade(), weak_host.upgrade()) - else { - return; - }; - run_tick(&services, &signing_host).await; - } - let delay = match current_unix_secs() { - Ok(now) => next_tick_delay(now), - Err(_) => CLOCK_FAILURE_TICK_DELAY, - }; - futures_timer::Delay::new(delay).await; - } - })); -} - -async fn run_tick(services: &Arc, signing_host: &SigningHost) { - if signing_host.root_entropy().is_err() { - debug!("skipping statement-store renewal tick; no active session"); - return; - } - absorb_tick(&signing_host.renewal, renew_now(services, signing_host).await); -} - -/// Preserve the last completed pass when a later tick fails. -fn absorb_tick(state: &RenewalState, result: Result) { - match result { - Ok(report) => { - if report.slots_exhausted { - warn!(period = report.period, "statement-store renewal hit slot exhaustion"); - } else { - info!( - period = report.period, - targets = report.outcomes.len(), - "statement-store renewal pass complete" - ); - } - state.record_report(&report); - }, - // A tick that could not run leaves the previous pass readable rather than - // replacing it with nothing: "the last thing we know" beats "no idea". - Err(reason) => warn!(%reason, "statement-store renewal tick failed"), - } -} - -#[cfg(test)] -mod tests { - use super::{ - super::{CoreStorageKey, decode_entries, track_targets}, - *, - }; - use parity_scale_codec::Encode; - use std::{collections::HashMap, sync::Mutex}; - - use truapi::latest::GenericError; - - #[derive(Default)] - struct MemStorage { - inner: Mutex, Vec>>, - writes: Mutex, - } - - impl MemStorage { - fn writes(&self) -> usize { - *self.writes.lock().expect("write counter mutex poisoned") - } - } - - #[crate::platform::async_trait] - impl CoreStorage for MemStorage { - async fn read_core_storage( - &self, - key: CoreStorageKey, - ) -> Result>, GenericError> { - Ok(self.inner.lock().expect("storage mutex poisoned").get(&key.encode()).cloned()) - } - - async fn write_core_storage( - &self, - key: CoreStorageKey, - value: Vec, - ) -> Result<(), GenericError> { - *self.writes.lock().expect("write counter mutex poisoned") += 1; - self.inner.lock().expect("storage mutex poisoned").insert(key.encode(), value); - Ok(()) - } - - async fn clear_core_storage(&self, key: CoreStorageKey) -> Result<(), GenericError> { - self.inner.lock().expect("storage mutex poisoned").remove(&key.encode()); - Ok(()) - } - } - - /// Root public key standing in for the active identity. - const OWNER: [u8; 32] = [1; 32]; - /// A different identity's root public key. - const OTHER_OWNER: [u8; 32] = [2; 32]; - - /// Yield once, so a concurrently polled task can run. - async fn yield_once() { - let mut yielded = false; - futures::future::poll_fn(move |cx| { - if yielded { - core::task::Poll::Ready(()) - } else { - yielded = true; - cx.waker().wake_by_ref(); - core::task::Poll::Pending - } - }) - .await - } - - /// Storage that yields *after* serving a read, so a second reader observes - /// the same value before the first writes its update back. Without - /// something serializing the cycle, one update is lost. - #[derive(Default)] - struct YieldingStorage(MemStorage); - - #[crate::platform::async_trait] - impl CoreStorage for YieldingStorage { - async fn read_core_storage( - &self, - key: CoreStorageKey, - ) -> Result>, GenericError> { - let value = self.0.read_core_storage(key).await; - yield_once().await; - value - } - - async fn write_core_storage( - &self, - key: CoreStorageKey, - value: Vec, - ) -> Result<(), GenericError> { - self.0.write_core_storage(key, value).await - } - - async fn clear_core_storage(&self, key: CoreStorageKey) -> Result<(), GenericError> { - self.0.clear_core_storage(key).await - } - } - - #[test] - fn concurrent_tracks_do_not_drop_an_entry() { - let storage = YieldingStorage::default(); - let ledger_lock = lock(); - - futures::executor::block_on(async { - let (first, second) = futures::join!( - track_targets(&storage, &ledger_lock, OWNER, vec![product("a.dot")]), - track_targets(&storage, &ledger_lock, OWNER, vec![product("b.dot")]), - ); - first.unwrap(); - second.unwrap(); - - let mut targets = read_targets(&storage, OWNER).await.unwrap(); - targets.sort_by_key(|target| format!("{target:?}")); - assert_eq!(targets, vec![product("a.dot"), product("b.dot")]); - }); - } - - /// The in-process loop returns nothing, so a host reads what a pass achieved - /// from the recorded report. Recording only in the direct path would leave a - /// loop-driven host unable to see exhaustion at all. - #[test] - fn the_last_report_is_readable_once_a_pass_has_run() { - let state = RenewalState::default(); - assert!( - state.last_report().is_none(), - "no pass has run, which is not the same as a healthy one" - ); - - absorb_tick( - &state, - Ok(StatementRenewalReport { - period: 7, - outcomes: Vec::new(), - pruned: Vec::new(), - slots_exhausted: true, - }), - ); - - let seen = state.last_report().expect("a pass has run"); - assert_eq!(seen.period, 7); - assert!(seen.slots_exhausted, "exhaustion is the outcome a host most needs to read back"); - } - - /// Only the newest matters: a host reads this on resume and wants the state - /// now, not the first exhaustion it ever hit. - #[test] - fn the_last_report_keeps_the_newest_pass() { - let state = RenewalState::default(); - for period in [7, 8] { - absorb_tick( - &state, - Ok(StatementRenewalReport { - period, - outcomes: Vec::new(), - pruned: Vec::new(), - slots_exhausted: period == 7, - }), - ); - } - - let seen = state.last_report().expect("a pass has run"); - assert_eq!(seen.period, 8); - assert!(!seen.slots_exhausted, "the older exhaustion should not stick"); - } - - /// A tick that could not run at all must not erase the last pass a host has - /// not read yet. - #[test] - fn a_failed_tick_leaves_the_previous_report_readable() { - let state = RenewalState::default(); - absorb_tick( - &state, - Ok(StatementRenewalReport { - period: 7, - outcomes: Vec::new(), - pruned: Vec::new(), - slots_exhausted: true, - }), - ); - - absorb_tick(&state, Err("no active session".to_string())); - - let seen = state.last_report().expect("the earlier pass is still there"); - assert_eq!(seen.period, 7); - assert!(seen.slots_exhausted); - } - - /// Pruning rewrites the whole ledger, so it has to hold the lock across its - /// read too. Reading outside it lets a `track_targets` land in the gap and be - /// overwritten by a view that predates it, leaving that account tracked - /// nowhere and never renewed again. - #[test] - fn a_prune_does_not_overwrite_a_concurrent_track() { - let storage = YieldingStorage::default(); - let ledger_lock = lock(); - let device = - StatementRenewalTarget::Account { account_id: [9; 32], label: "device".to_string() }; - - futures::executor::block_on(async { - // A foreign entry, so the pass prunes and therefore writes. - track_targets(&storage, &ledger_lock, OTHER_OWNER, vec![device]).await.unwrap(); - - let (pruned, tracked) = futures::join!( - owned_targets(&storage, &ledger_lock, OWNER), - track_targets(&storage, &ledger_lock, OWNER, vec![product("a.dot")]), - ); - pruned.unwrap(); - tracked.unwrap(); - - assert_eq!( - read_targets(&storage, OWNER).await.unwrap(), - vec![product("a.dot")], - "the concurrently tracked target was overwritten by the prune" - ); - }); - } - - /// A raw account promised by a previous identity must not be renewed under a - /// later one: it would spend that identity's slots on an account it never - /// promised. - #[test] - fn a_foreign_target_is_dropped_from_the_ledger() { - let storage = MemStorage::default(); - let device = - StatementRenewalTarget::Account { account_id: [9; 32], label: "device".to_string() }; - - futures::executor::block_on(async { - track_targets(&storage, &lock(), OTHER_OWNER, vec![device]).await.unwrap(); - track_targets(&storage, &lock(), OWNER, vec![product("a.dot")]).await.unwrap(); - - let (targets, pruned) = owned_targets(&storage, &lock(), OWNER).await.unwrap(); - - assert_eq!(targets, vec![product("a.dot")]); - // Reported, not just dropped: the pass is a host's only view of the - // ledger, so a silent prune is one it cannot notice or re-track. - assert_eq!(pruned, vec!["device".to_string()]); - // Dropped, not merely skipped, so the cost is paid once. - assert_eq!( - read_entries(&storage).await.unwrap(), - vec![TrackedStatementRenewalTarget::new(product("a.dot"), OWNER)] - ); - }); - } - - #[test] - fn an_all_foreign_ledger_prunes_to_nothing() { - let storage = MemStorage::default(); - let device = - StatementRenewalTarget::Account { account_id: [9; 32], label: "device".to_string() }; - - futures::executor::block_on(async { - track_targets(&storage, &lock(), OTHER_OWNER, vec![device]).await.unwrap(); - - assert_eq!( - owned_targets(&storage, &lock(), OWNER).await.unwrap(), - (Vec::new(), vec!["device".to_string()]) - ); - assert_eq!(read_entries(&storage).await.unwrap(), Vec::new()); - }); - } - - #[test] - fn an_all_owned_ledger_is_not_rewritten() { - let storage = MemStorage::default(); - - futures::executor::block_on(async { - track_targets(&storage, &lock(), OWNER, vec![product("a.dot")]).await.unwrap(); - let after_seeding = storage.writes(); - - let (targets, _pruned) = owned_targets(&storage, &lock(), OWNER).await.unwrap(); - - assert_eq!(targets, vec![product("a.dot")]); - // Every tick calls this; rewriting the ledger each time would be waste. - assert_eq!(storage.writes(), after_seeding); - }); - } - - #[test] - fn an_unresolvable_target_is_skipped_not_fatal() { - // An all-digit product id past `u64::MAX` fails junction derivation with - // `NumericJunctionOutOfRange`. - let unresolvable = product(&"9".repeat(25)); - let entropy = [7u8; 32]; - - assert!(resolve_target(&entropy, "paseo", &unresolvable).is_err()); - let targets = [unresolvable, product("a.dot")]; - - // Resolving strictly loses the healthy target with the broken one. - assert!( - targets - .iter() - .map(|target| resolve_target(&entropy, "paseo", target)) - .collect::, _>>() - .is_err() - ); - - let resolved = resolve_targets(&entropy, "paseo", &targets); - assert_eq!(resolved.len(), 1); - assert_eq!(resolved[0].label, "product:a.dot"); - } - - /// A fresh ledger lock; each test drives one ledger in isolation. - fn lock() -> futures::lock::Mutex<()> { - futures::lock::Mutex::new(()) - } - - /// The targets in the ledger visible to the identity rooted at `owner`. - async fn read_targets( - storage: &(impl CoreStorage + ?Sized), - owner: [u8; 32], - ) -> Result, String> { - Ok(read_entries(storage) - .await? - .into_iter() - .filter(|entry| entry.is_owned_by(owner)) - .map(|entry| entry.target) - .collect()) - } - - fn product(product_id: &str) -> StatementRenewalTarget { - StatementRenewalTarget::ProductStatementAllowance { product_id: product_id.to_string() } - } - - #[test] - fn ledger_round_trips_dedupes_and_preserves_order() { - let storage = MemStorage::default(); - - futures::executor::block_on(async { - track_targets( - &storage, - &lock(), - OWNER, - vec![StatementRenewalTarget::WalletSso, product("a.dot")], - ) - .await - .unwrap(); - track_targets( - &storage, - &lock(), - OWNER, - vec![ - product("a.dot"), - StatementRenewalTarget::Account { - account_id: [9; 32], - label: "device".to_string(), - }, - ], - ) - .await - .unwrap(); - - assert_eq!( - read_targets(&storage, OWNER).await.unwrap(), - vec![ - StatementRenewalTarget::WalletSso, - product("a.dot"), - StatementRenewalTarget::Account { - account_id: [9; 32], - label: "device".to_string(), - }, - ] - ); - }); - } - - #[test] - fn the_ledger_lists_back_every_entry_in_track_order() { - let storage = MemStorage::default(); - let device = - StatementRenewalTarget::Account { account_id: [9; 32], label: "device".to_string() }; - - futures::executor::block_on(async { - track_targets( - &storage, - &lock(), - OWNER, - vec![StatementRenewalTarget::WalletSso, device.clone()], - ) - .await - .unwrap(); - - assert_eq!( - list_entries(&storage, &lock()).await.unwrap(), - vec![ - TrackedStatementRenewalTarget { - target: StatementRenewalTarget::WalletSso, - owner: None, - }, - TrackedStatementRenewalTarget { target: device, owner: Some(OWNER) }, - ] - ); - }); - } - - // The point of the reader is auditing finite slots, so it reports what is - // stored rather than what the active identity would resolve: an entry another - // identity promised is still occupying a row until a pass prunes it, and the - // owner is what lets a caller tell the two apart. - #[test] - fn a_listing_includes_a_foreign_entry_and_writes_nothing() { - let storage = MemStorage::default(); - let device = - StatementRenewalTarget::Account { account_id: [9; 32], label: "device".to_string() }; - - futures::executor::block_on(async { - track_targets(&storage, &lock(), OTHER_OWNER, vec![device.clone()]) - .await - .unwrap(); - track_targets(&storage, &lock(), OWNER, vec![product("a.dot")]).await.unwrap(); - let writes_before = storage.writes(); - - let listed = list_entries(&storage, &lock()).await.unwrap(); - - assert_eq!( - listed, - vec![ - TrackedStatementRenewalTarget { target: device, owner: Some(OTHER_OWNER) }, - TrackedStatementRenewalTarget { target: product("a.dot"), owner: None }, - ] - ); - // A read that rewrote the ledger could not be run without a session. - assert_eq!(storage.writes(), writes_before); - }); - } - - // The reader is the one ledger operation whose whole job is to run while a - // pass may be writing: a scheduled host wakes, lists, and decides. `track` - // is polled first so it holds the lock across its read, which is what makes - // the guarantee observable; without the lock the listing reads the ledger - // the track is in the middle of replacing. - #[test] - fn a_listing_during_a_track_reports_the_settled_ledger() { - let storage = YieldingStorage::default(); - let ledger_lock = lock(); - - futures::executor::block_on(async { - let (tracked, listed) = futures::join!( - track_targets(&storage, &ledger_lock, OWNER, vec![product("a.dot")]), - list_entries(&storage, &ledger_lock), - ); - tracked.unwrap(); - - assert_eq!( - listed.unwrap(), - vec![TrackedStatementRenewalTarget { target: product("a.dot"), owner: None }], - "the listing observed the ledger the track was replacing" - ); - }); - } - - #[test] - fn an_undecodable_ledger_lists_as_empty() { - let storage = MemStorage::default(); - - futures::executor::block_on(async { - storage - .write_core_storage(CoreStorageKey::StatementRenewalTargets, vec![0xff; 8]) - .await - .unwrap(); - - assert_eq!(list_entries(&storage, &lock()).await.unwrap(), Vec::new()); - }); - } - - #[test] - fn ledger_rejects_trailing_bytes() { - let mut blob = vec![TrackedStatementRenewalTarget::new(product("a.dot"), OWNER)].encode(); - blob.push(0xff); - assert!(decode_entries(&blob).is_err()); - } - - #[test] - fn an_undecodable_ledger_reads_as_empty() { - let storage = MemStorage::default(); - - futures::executor::block_on(async { - storage - .write_core_storage(CoreStorageKey::StatementRenewalTargets, vec![0xff, 0xff, 0xff]) - .await - .unwrap(); - - assert_eq!(read_targets(&storage, OWNER).await.unwrap(), Vec::new()); - }); - } - - #[test] - fn tracking_over_an_undecodable_ledger_starts_a_fresh_one() { - let storage = MemStorage::default(); - - futures::executor::block_on(async { - storage - .write_core_storage(CoreStorageKey::StatementRenewalTargets, vec![0xff; 3]) - .await - .unwrap(); - track_targets(&storage, &lock(), OWNER, vec![product("a.dot")]).await.unwrap(); - - assert_eq!(read_targets(&storage, OWNER).await.unwrap(), vec![product("a.dot")]); - }); - } - - #[test] - fn product_target_resolves_to_allocation_derivation() { - let entropy = [7u8; 32]; - let expected = - derive_sr25519_hard_path(&entropy, &["allowance", "statement-store", "a.dot"]) - .unwrap() - .public - .to_bytes(); - - let resolved = resolve_target(&entropy, "paseo", &product("a.dot")).unwrap(); - assert_eq!( - resolved, - ResolvedRenewalTarget { label: "product:a.dot".to_string(), account_id: expected } - ); - } - - #[test] - fn wallet_sso_target_resolves_to_the_responder_identity() { - let entropy = [7u8; 32]; - let expected = - crate::host_logic::product_account::derive_identity_keypair(&entropy, "paseo") - .unwrap() - .public - .to_bytes(); - - let resolved = - resolve_target(&entropy, "paseo", &StatementRenewalTarget::WalletSso).unwrap(); - - assert_eq!( - resolved, - ResolvedRenewalTarget { label: "wallet-sso".to_string(), account_id: expected } - ); - } - - #[test] - fn untracking_one_device_preserves_wallet_sso_and_other_devices() { - let storage = MemStorage::default(); - let first = - StatementRenewalTarget::Account { account_id: [8; 32], label: "device:08".to_string() }; - let second = - StatementRenewalTarget::Account { account_id: [9; 32], label: "device:09".to_string() }; - - futures::executor::block_on(async { - track_targets( - &storage, - &lock(), - OWNER, - vec![StatementRenewalTarget::WalletSso, first, second.clone()], - ) - .await - .unwrap(); - - assert!(untrack_account(&storage, &lock(), OWNER, &[8; 32]).await.unwrap()); - assert_eq!( - read_targets(&storage, OWNER).await.unwrap(), - vec![StatementRenewalTarget::WalletSso, second] - ); - }); - } - - #[test] - fn concurrent_track_and_untrack_preserve_an_unrelated_device() { - let storage = YieldingStorage::default(); - let ledger_lock = lock(); - let first = - StatementRenewalTarget::Account { account_id: [8; 32], label: "device:08".to_string() }; - let second = - StatementRenewalTarget::Account { account_id: [9; 32], label: "device:09".to_string() }; - - futures::executor::block_on(async { - track_targets(&storage, &ledger_lock, OWNER, vec![first]).await.unwrap(); - let (removed, tracked) = futures::join!( - untrack_account(&storage, &ledger_lock, OWNER, &[8; 32]), - track_targets(&storage, &ledger_lock, OWNER, vec![second.clone()]), - ); - assert!(removed.unwrap()); - tracked.unwrap(); - - assert_eq!(read_targets(&storage.0, OWNER).await.unwrap(), vec![second]); - }); - } - - #[test] - fn a_raw_account_is_hidden_from_another_identity() { - let storage = MemStorage::default(); - let device = - StatementRenewalTarget::Account { account_id: [9; 32], label: "device".to_string() }; - - futures::executor::block_on(async { - track_targets(&storage, &lock(), OWNER, vec![device.clone(), product("a.dot")]) - .await - .unwrap(); - - // The recipe resolves under any identity; the raw account does not. - assert_eq!( - read_targets(&storage, OWNER).await.unwrap(), - vec![device, product("a.dot")] - ); - assert_eq!(read_targets(&storage, OTHER_OWNER).await.unwrap(), vec![product("a.dot")]); - }); - } - - #[test] - fn the_same_raw_account_can_be_promised_by_two_identities() { - let storage = MemStorage::default(); - let device = - StatementRenewalTarget::Account { account_id: [9; 32], label: "device".to_string() }; - - futures::executor::block_on(async { - track_targets(&storage, &lock(), OWNER, vec![device.clone()]).await.unwrap(); - track_targets(&storage, &lock(), OTHER_OWNER, vec![device.clone()]) - .await - .unwrap(); - - // Distinct owners, so each identity renews it for itself. - assert_eq!(read_targets(&storage, OWNER).await.unwrap(), vec![device.clone()]); - assert_eq!(read_targets(&storage, OTHER_OWNER).await.unwrap(), vec![device]); - }); - } - - #[test] - fn untracking_a_device_preserves_the_other_identitys_entry() { - let storage = MemStorage::default(); - let device = - StatementRenewalTarget::Account { account_id: [9; 32], label: "device".to_string() }; - - futures::executor::block_on(async { - track_targets(&storage, &lock(), OWNER, vec![device.clone()]).await.unwrap(); - track_targets(&storage, &lock(), OTHER_OWNER, vec![device.clone()]) - .await - .unwrap(); - - assert!(untrack_account(&storage, &lock(), OWNER, &[9; 32]).await.unwrap()); - assert_eq!( - ( - read_targets(&storage, OWNER).await.unwrap(), - read_targets(&storage, OTHER_OWNER).await.unwrap(), - ), - (Vec::new(), vec![device]) - ); - }); - } - - #[test] - fn owner_key_follows_the_root_entropy() { - assert_ne!(owner_key(&[7u8; 32]).unwrap(), owner_key(&[8u8; 32]).unwrap()); - } - - // A product connection derives its allowance account from the normalized - // id, so tracking any other spelling renews an account no product uses. - #[test] - fn a_product_target_normalizes_its_identifier() { - for supplied in - [" truapi-playground.dot ", "TruAPI-Playground.dot", "TRUAPI-PLAYGROUND.DOT"] - { - assert_eq!( - product(supplied).normalized(), - Ok(product("truapi-playground.dot")), - "{supplied:?} did not normalize" - ); - } - assert_eq!( - product("not a product").normalized(), - Err("product_id not a product is not a valid product identifier".to_string()) - ); - for target in [ - StatementRenewalTarget::WalletSso, - StatementRenewalTarget::Account { account_id: [0x11; 32], label: "device".to_string() }, - ] { - assert_eq!(target.clone().normalized(), Ok(target)); - } - } -} diff --git a/rust/crates/truapi/src/runtime/signing_host/local_activation.rs b/rust/crates/truapi/src/runtime/signing_host/local_activation.rs index 8462c48159..ad8b15ae21 100644 --- a/rust/crates/truapi/src/runtime/signing_host/local_activation.rs +++ b/rust/crates/truapi/src/runtime/signing_host/local_activation.rs @@ -1,14 +1,5 @@ -use super::{SigningHost, product_authority_error}; -use crate::{ - host_logic::{ - product_account::{derive_identity_keypair, derive_root_keypair_from_entropy}, - session::SessionInfo, - sso::pairing::derive_identity_chat_private_key, - }, - runtime::{authority::AuthorityError, connected_session_ui_info}, -}; - -use zeroize::Zeroizing; +use super::SigningHost; +use crate::runtime::{authority::AuthorityError, connected_session_ui_info}; /// Activate the wallet from entropy supplied by the embedding host. #[async_trait::async_trait] @@ -37,28 +28,9 @@ impl LocalActivation for SigningHost { secret: Vec, lite_username: Option, ) -> Result<(), AuthorityError> { - let secret = Zeroizing::new(secret); - let root = derive_root_keypair_from_entropy(&secret).map_err(product_authority_error)?; - let public_key = root.public.to_bytes(); - let identity_account_id = derive_identity_keypair(&secret, self.network_suffix()) - .map_err(product_authority_error)? - .public - .to_bytes(); - let identity_chat_private_key = derive_identity_chat_private_key(&secret); - let session = SessionInfo { - public_key, - sso: None, - root_entropy_source: None, - identity_account_id: Some(identity_account_id), - identity_chat_private_key: Some(identity_chat_private_key), - // A local session has no answering remote device to address. - device_enc_public_key: None, - lite_username, - full_username: None, - }; - let ui_info = connected_session_ui_info(&session); - self.install_local_session(secret, session); - self.auth_state.connected(&ui_info); + let activation = self.wallet.prepare_activation(secret, lite_username)?; + let session = self.accounts.change_activation(|| self.wallet.install(activation)); + self.auth_state.connected(&connected_session_ui_info(&session)); Ok(()) } } diff --git a/rust/crates/truapi/src/runtime/signing_host/sso_service.rs b/rust/crates/truapi/src/runtime/signing_host/sso_service.rs deleted file mode 100644 index 8b67944aed..0000000000 --- a/rust/crates/truapi/src/runtime/signing_host/sso_service.rs +++ /dev/null @@ -1,623 +0,0 @@ -//! Incoming SSO requests, consent prompts and wallet authority calls. - -use std::sync::Arc; - -use crate::platform::{ - CreateTransactionReview, ResourceAllocationReview, SignPayloadReview, SignRawReview, - UserConfirmationReview, -}; -use futures::{FutureExt, pin_mut}; -use tracing::warn; -use truapi::latest as api; - -use super::{ - SigningHost, - sso_responder::{ - AllowanceAllocationError, allocate_bulletin_allowance, allocate_smart_contract_allowance, - allocate_statement_store_allowance, - }, -}; -use crate::{ - host_internal::{ - sso_messages::{ - CreateAccountProofResponse, CreateTransactionLegacyPayload, CreateTransactionPayload, - CreateTransactionRequest, CreateTransactionResponse, - CreateTransactionWithLegacyAccountRequest, GetAccountAliasResponse, - ListRingVrfKeysResponse, OnExistingAllowancePolicy, ProductRequest, - ProductSubtreeRequest, ProductSubtreeResponse, RegisterRingVrfKeyResponse, - RemoteMessage, ResourceAllocationRequest, ResourceAllocationResponse, - RingVrfSignResponse, SignRawWithLegacyAccountRequest, SignRawWithLegacyAccountResponse, - SignRequest, SignResponse, SignVrfResponse, SsoAllocatedResource, SsoAllocationOutcome, - }, - sso_wire::ResponseOutcome, - }, - host_logic::product_account::{derive_ring_vrf_domain_entropy, product_public_key_to_address}, - runtime::{ - authority::{ - AuthoritySession, CreateTransactionAuthorityRequest, ProductAuthority, - SignPayloadAuthorityRequest, SignRawAuthorityRequest, - }, - sso_service::{Dispatch, SsoReply, SsoRequestContext}, - }, -}; - -/// Handler error once the pairing host has withdrawn the request; never -/// posted, because a withdrawn request has no response. -const WITHDRAWN: &str = "Withdrawn"; - -/// SSO handlers served by a locally activated [`SigningHost`]. -pub struct SigningHostSsoService { - signing_host: Arc, -} - -impl SigningHostSsoService { - /// Serve requests and prompt through the signing host's platform. - pub fn new(signing_host: Arc) -> Self { - Self { signing_host } - } - - /// The signing session captured before dispatching one request. - pub fn current_session(&self) -> Option { - self.signing_host.current_session() - } - - /// Answer `message`, unless the pairing host withdraws it first. - /// - /// A `Cancel` withdraws the request it names and is itself not answered. - /// A withdrawn request has no response to post. - pub async fn answer(&self, message: RemoteMessage) -> Dispatch { - let withdrawals = self.signing_host.sso_withdrawals(); - let Some(request) = withdrawals.begin(&message.message_id) else { - return Dispatch::Withdrawn; - }; - let cx = self.current_session().map(|session| { - SsoRequestContext::new(&message.message_id, session, request.cancel.clone()) - }); - match self.dispatch(cx, message).await { - Dispatch::Withdraw(target) => { - withdrawals.withdraw(&target); - Dispatch::Withdraw(target) - }, - Dispatch::Response(_) if request.cancel.is_cancelled() => Dispatch::Withdrawn, - dispatch => dispatch, - } - } - - /// The person's answer to `review`, or `None` once the pairing host has - /// withdrawn the request, which leaves nothing authorized. - async fn prompt( - &self, - cx: &SsoRequestContext, - review: UserConfirmationReview, - ) -> Option> { - let answer = self.signing_host.platform.confirm_user_action(review).fuse(); - let withdrawn = cx.call.cancel().cancelled().fuse(); - pin_mut!(answer, withdrawn); - futures::select_biased! { - _ = withdrawn => None, - answer = answer => Some(answer), - } - } - - /// Run the platform confirmation seam; rejection and failure both refuse - /// the operation with an opaque reason (host-spec B.7). - async fn confirm( - &self, - cx: &SsoRequestContext, - review: UserConfirmationReview, - ) -> Result<(), String> { - match self.prompt(cx, review).await { - Some(Ok(true)) => Ok(()), - Some(Ok(false)) => Err("Rejected".to_string()), - Some(Err(err)) => Err(format!("confirmation failed: {}", err.reason)), - None => Err(WITHDRAWN.to_string()), - } - } - - async fn serve_sign( - &self, - cx: &SsoRequestContext, - request: SignRequest, - ) -> Result { - match request { - SignRequest::Payload(request) => { - let request = *request; - self.confirm( - cx, - UserConfirmationReview::SignPayload(SignPayloadReview::Product { - // A relayed request carries no caller identity. - calling_product_id: None, - request: request.clone(), - }), - ) - .await?; - self.signing_host - .sign_payload( - &cx.call, - &cx.session, - // A relayed request carries no caller identity, and - // this role confirms every one of them anyway. - None, - SignPayloadAuthorityRequest::Product(request), - ) - .await - .map_err(|err| err.to_string()) - }, - SignRequest::Raw(request) => self.serve_sign_raw(cx, request, true).await, - SignRequest::RawUnwatermarkedDeprecated(request) => { - self.serve_sign_raw(cx, request, false).await - }, - SignRequest::RawWithLegacyAccountUnwatermarkedDeprecated(request) => { - self.serve_sign_raw_with_legacy_account(cx, request, false).await - }, - } - } - - async fn serve_sign_raw( - &self, - cx: &SsoRequestContext, - request: api::HostSignRawRequest, - watermarked: bool, - ) -> Result { - self.confirm( - cx, - UserConfirmationReview::SignRaw(SignRawReview::Product { - calling_product_id: None, - request: request.clone(), - watermarked, - }), - ) - .await?; - self.signing_host - .sign_raw( - &cx.call, - &cx.session, - None, - SignRawAuthorityRequest::Product(request), - watermarked, - ) - .await - .map_err(|err| err.to_string()) - } - - async fn serve_sign_raw_with_legacy_account( - &self, - cx: &SsoRequestContext, - request: SignRawWithLegacyAccountRequest, - watermarked: bool, - ) -> Result { - let public_request = api::HostSignRawWithLegacyAccountRequest { - signer: product_public_key_to_address(request.account), - payload: request.data, - }; - self.confirm( - cx, - UserConfirmationReview::SignRaw(SignRawReview::LegacyAccount { - request: public_request.clone(), - watermarked, - }), - ) - .await?; - self.signing_host - .sign_raw( - &cx.call, - &cx.session, - None, - SignRawAuthorityRequest::LegacyAccount { - account: request.account, - request: public_request, - }, - watermarked, - ) - .await - .map_err(|err| err.to_string()) - } - - async fn serve_create_transaction( - &self, - cx: &SsoRequestContext, - review: CreateTransactionReview, - request: CreateTransactionAuthorityRequest, - ) -> Result, String> { - self.confirm(cx, UserConfirmationReview::CreateTransaction(review)).await?; - self.signing_host - .create_transaction(&cx.call, &cx.session, None, request) - .await - .map(|response| response.transaction) - .map_err(|err| err.to_string()) - } - - async fn allocate( - &self, - session: &AuthoritySession, - calling_product_id: &str, - resource: api::AllocatableResource, - on_existing: OnExistingAllowancePolicy, - ) -> Result { - let signing_host = &self.signing_host; - let services = &signing_host.services; - match resource { - api::AllocatableResource::StatementStoreAllowance => { - allocate_statement_store_allowance( - services, - signing_host, - session, - calling_product_id, - on_existing, - ) - .await - .map(|allocation| { - SsoAllocationOutcome::Allocated(SsoAllocatedResource::StatementStoreAllowance { - slot_account_key: allocation.secret, - }) - }) - }, - api::AllocatableResource::BulletinAllowance => allocate_bulletin_allowance( - services, - signing_host, - session, - calling_product_id, - on_existing, - ) - .await - .map(|slot_account_key| { - SsoAllocationOutcome::Allocated(SsoAllocatedResource::BulletinAllowance { - slot_account_key, - }) - }), - api::AllocatableResource::SmartContractAllowance(index) => { - allocate_smart_contract_allowance( - services, - signing_host, - session, - calling_product_id, - index, - on_existing, - ) - .await - .map(|()| { - SsoAllocationOutcome::Allocated(SsoAllocatedResource::SmartContractAllowance) - }) - }, - api::AllocatableResource::AutoSigning => { - let product_root_private_key = signing_host - .product_subtree_secret(calling_product_id) - .map_err(AllowanceAllocationError::Authority)?; - let root_entropy = signing_host.root_entropy()?; - let ring_vrf_domain_entropy = - derive_ring_vrf_domain_entropy(&root_entropy, calling_product_id) - .map_err(super::product_authority_error) - .map_err(AllowanceAllocationError::Authority)?; - Ok(SsoAllocationOutcome::Allocated(SsoAllocatedResource::AutoSigning { - product_root_private_key, - ring_vrf_domain_entropy, - })) - }, - } - } -} - -fn allocation_reply( - payload: Result, String>, - failures: Vec, -) -> SsoReply { - let mut outcome = resource_allocation_outcome(&payload); - if !failures.is_empty() { - let details = failures.join("; ").replace(['\r', '\n'], " "); - outcome.reason = Some(match outcome.reason { - Some(summary) => format!("{summary}: {details}"), - None => details, - }); - } - SsoReply::from(payload).with_outcome(outcome) -} - -/// Transcript outcome for an allocation batch: `ok` only when every requested -/// resource was allocated; otherwise `rejected`, `partial`, or `not_available` -/// with a count summary. -fn resource_allocation_outcome( - payload: &Result, String>, -) -> ResponseOutcome { - let outcomes = match payload { - Ok(outcomes) => outcomes, - Err(reason) => { - return ResponseOutcome { outcome: "error", reason: Some(reason.clone()) }; - }, - }; - let total = outcomes.len(); - let count = |wanted: fn(&SsoAllocationOutcome) -> bool| { - outcomes.iter().filter(|outcome| wanted(outcome)).count() - }; - let allocated = count(|outcome| matches!(outcome, SsoAllocationOutcome::Allocated(_))); - let rejected = count(|outcome| matches!(outcome, SsoAllocationOutcome::Rejected)); - let unavailable = count(|outcome| matches!(outcome, SsoAllocationOutcome::NotAvailable)); - if allocated == total { - return ResponseOutcome { outcome: "ok", reason: None }; - } - if allocated > 0 { - let mut reason = format!("{allocated} of {total} requested resources allocated"); - if rejected > 0 { - reason.push_str(&format!("; {rejected} rejected")); - } - if unavailable > 0 { - reason.push_str(&format!("; {unavailable} unavailable")); - } - return ResponseOutcome { outcome: "partial", reason: Some(reason) }; - } - if rejected > 0 { - let reason = if rejected == total { - if total == 1 { - "Requested resource was rejected".to_string() - } else { - format!("All {total} requested resources were rejected") - } - } else { - format!("No resources allocated; {rejected} rejected; {unavailable} unavailable") - }; - return ResponseOutcome { outcome: "rejected", reason: Some(reason) }; - } - ResponseOutcome { - outcome: "not_available", - reason: Some(if total == 1 { - "Requested resource is not available".to_string() - } else { - format!("None of the {total} requested resources are available") - }), - } -} - -#[truapi_macros::sso_service] -impl SigningHostSsoService { - /// Sign a payload or raw bytes with a product account. - async fn sign(&self, cx: &SsoRequestContext, request: SignRequest) -> SignResponse { - let payload = self.serve_sign(cx, request).await; - if let Err(reason) = &payload { - warn!(%reason, "sign request failed"); - } - payload - } - - /// Derive a contextual alias for a registered ring-VRF key. - async fn get_account_alias( - &self, - cx: &SsoRequestContext, - request: ProductRequest, - ) -> GetAccountAliasResponse { - self.signing_host.account_alias(&cx.call, &cx.session, request).await - } - - /// Allocate SSO-backed resources for a product. - async fn resource_allocation( - &self, - cx: &SsoRequestContext, - request: ResourceAllocationRequest, - ) -> ResourceAllocationResponse { - let mut failures = Vec::new(); - let payload = async { - let review = UserConfirmationReview::ResourceAllocation(ResourceAllocationReview { - calling_product_id: request.calling_product_id.clone(), - resources: request.resources.clone(), - }); - match self.prompt(cx, review).await { - Some(Ok(true)) => {}, - Some(Ok(false)) => { - return Ok(vec![SsoAllocationOutcome::Rejected; request.resources.len()]); - }, - Some(Err(err)) => return Err(format!("confirmation failed: {}", err.reason)), - None => return Err(WITHDRAWN.to_string()), - } - - self.signing_host - .require_current_session(&cx.session) - .map_err(|err| err.to_string())?; - let mut outcomes = Vec::with_capacity(request.resources.len()); - for resource in request.resources { - self.signing_host - .require_current_session(&cx.session) - .map_err(|err| err.to_string())?; - if cx.call.cancel().is_cancelled() { - return Err(WITHDRAWN.to_string()); - } - let outcome = self - .allocate( - &cx.session, - &request.calling_product_id, - resource, - request.on_existing, - ) - .await; - self.signing_host - .require_current_session(&cx.session) - .map_err(|err| err.to_string())?; - outcomes.push(outcome.unwrap_or_else(|err| { - let reason = err.to_string(); - warn!(%reason, "resource allocation item failed"); - failures.push(reason); - SsoAllocationOutcome::NotAvailable - })); - } - Ok(outcomes) - } - .await; - if let Err(reason) = &payload { - warn!(%reason, "resource allocation request failed"); - } - allocation_reply(payload, failures) - } - - /// Build a signed transaction for a product account. - async fn create_transaction( - &self, - cx: &SsoRequestContext, - request: CreateTransactionRequest, - ) -> CreateTransactionResponse { - let CreateTransactionPayload::V1(payload) = request.payload; - let payload = payload.into_product_payload(); - self.serve_create_transaction( - cx, - CreateTransactionReview::Product { calling_product_id: None, payload: payload.clone() }, - CreateTransactionAuthorityRequest::Product(payload), - ) - .await - } - - /// Build a signed transaction for the wallet's identity account. - async fn create_transaction_with_legacy_account( - &self, - cx: &SsoRequestContext, - request: CreateTransactionWithLegacyAccountRequest, - ) -> CreateTransactionResponse { - let CreateTransactionLegacyPayload::V1(payload) = request.payload; - self.serve_create_transaction( - cx, - CreateTransactionReview::LegacyAccount(payload.clone()), - CreateTransactionAuthorityRequest::IdentityAccount(payload), - ) - .await - } - - /// Sign raw data with a legacy account. - async fn sign_raw_with_legacy_account( - &self, - cx: &SsoRequestContext, - request: SignRawWithLegacyAccountRequest, - ) -> SignRawWithLegacyAccountResponse { - self.serve_sign_raw_with_legacy_account(cx, request, true) - .await - .map(|response| response.signature) - } - - /// Create a ring-VRF proof bound to a context and message. - async fn create_account_proof( - &self, - cx: &SsoRequestContext, - request: ProductRequest, - ) -> CreateAccountProofResponse { - self.signing_host.create_proof(&cx.call, &cx.session, request).await - } - - /// Sign an RFC-0023 VRF transcript. - async fn sign_vrf( - &self, - cx: &SsoRequestContext, - request: ProductRequest, - ) -> SignVrfResponse { - self.signing_host - .sign_vrf_request( - &cx.call, - &cx.session, - request.calling_product_id, - request.payload, - false, - ) - .await - .map_err(api::HostAccountSignVrfError::from) - } - - /// Consent-free product hard-subtree public key. - async fn product_subtree( - &self, - cx: &SsoRequestContext, - request: ProductSubtreeRequest, - ) -> ProductSubtreeResponse { - self.signing_host - .product_subtree_public_key(&cx.call, &cx.session, request.product_id) - .await - .map_err(|err| err.to_string()) - } - - /// Register a ring-VRF key owned by the calling product. - async fn register_ring_vrf_key( - &self, - cx: &SsoRequestContext, - request: ProductRequest, - ) -> RegisterRingVrfKeyResponse { - self.signing_host.register_ring_vrf_key(&cx.call, &cx.session, request).await - } - - /// List registered ring-VRF keys. - async fn list_ring_vrf_keys( - &self, - cx: &SsoRequestContext, - request: ProductRequest, - ) -> ListRingVrfKeysResponse { - self.signing_host.list_ring_vrf_keys(&cx.call, &cx.session, request).await - } - - /// Sign bytes directly with a registered ring-VRF key. - async fn ring_vrf_sign( - &self, - cx: &SsoRequestContext, - request: ProductRequest, - ) -> RingVrfSignResponse { - self.signing_host.ring_vrf_sign(&cx.call, &cx.session, request).await - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resource_allocation_summary_reflects_per_resource_outcomes() { - let result = resource_allocation_outcome(&Ok(vec![SsoAllocationOutcome::Allocated( - SsoAllocatedResource::SmartContractAllowance, - )])); - assert_eq!((result.outcome, result.reason), ("ok", None)); - - let result = resource_allocation_outcome(&Ok(vec![SsoAllocationOutcome::Rejected])); - assert_eq!( - (result.outcome, result.reason.as_deref()), - ("rejected", Some("Requested resource was rejected")) - ); - - let result = resource_allocation_outcome(&Ok(vec![ - SsoAllocationOutcome::Allocated(SsoAllocatedResource::BulletinAllowance { - slot_account_key: vec![1; 64], - }), - SsoAllocationOutcome::Rejected, - SsoAllocationOutcome::NotAvailable, - ])); - assert_eq!( - (result.outcome, result.reason.as_deref()), - ("partial", Some("1 of 3 requested resources allocated; 1 rejected; 1 unavailable")) - ); - - let result = resource_allocation_outcome(&Ok(vec![SsoAllocationOutcome::NotAvailable])); - assert_eq!( - (result.outcome, result.reason.as_deref()), - ("not_available", Some("Requested resource is not available")) - ); - } - - #[test] - fn mixed_unallocated_resources_report_rejection() { - let result = resource_allocation_outcome(&Ok(vec![ - SsoAllocationOutcome::Rejected, - SsoAllocationOutcome::NotAvailable, - ])); - - assert_eq!( - (result.outcome, result.reason.as_deref()), - ("rejected", Some("No resources allocated; 1 rejected; 1 unavailable")) - ); - } - - #[test] - fn allocation_transcript_includes_single_line_item_failures() { - let answer = allocation_reply( - Ok(vec![SsoAllocationOutcome::NotAvailable]), - vec!["rpc\nfailed".to_string(), "provider\rdown".to_string()], - ) - .finish( - "allocation-1", - crate::host_internal::sso_messages::v1::RemoteMessage::ResourceAllocationResponse, - ); - - assert_eq!(answer.outcome.outcome, "not_available"); - assert_eq!( - answer.outcome.reason.as_deref(), - Some("Requested resource is not available: rpc failed; provider down") - ); - } -} diff --git a/rust/crates/truapi/src/runtime/signing_host/tests/activation.rs b/rust/crates/truapi/src/runtime/signing_host/tests/activation.rs new file mode 100644 index 0000000000..26cfc0f682 --- /dev/null +++ b/rust/crates/truapi/src/runtime/signing_host/tests/activation.rs @@ -0,0 +1,270 @@ +use super::*; + +#[test] +fn invalid_activation_preserves_the_active_wallet_and_its_grants() { + let platform = Arc::new(StubPlatform { + sign_raw_confirmed: false, + resource_allocation_confirmed: true, + ..StubPlatform::default() + }); + let (services, authority) = signing_runtime_with_platform(platform.clone()); + futures::executor::block_on( + authority.activate_local_session_with_identity(ENTROPY.to_vec(), Some("alice".to_string())), + ) + .expect("initial activation succeeds"); + let session = active_session(&authority); + let runtime = product_runtime(services, authority.clone()); + auto_signing::grant_auto_signing(&runtime); + + let error = futures::executor::block_on( + authority.activate_local_session_with_identity(vec![0xCD; 17], Some("bob".to_string())), + ) + .expect_err("invalid BIP-39 entropy cannot replace the wallet"); + assert!(matches!(error, AuthorityError::Unavailable { .. })); + + let HostSignRawResponse::V1(response) = futures::executor::block_on(runtime.sign_raw( + &CallContext::default(), + HostSignRawRequest::V1(v01::HostSignRawRequest { + account: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: v01::DerivationIndex::Index(0), + }, + payload: v01::RawPayload::Bytes { bytes: b"still active".to_vec() }, + }), + )) + .expect("the active wallet still signs under its existing grant"); + let root = derive_root_keypair_from_entropy(&ENTROPY).expect("original root derives"); + let keypair = + derive_product_keypair(&root, "myapp.dot", index_bytes(0)).expect("product key derives"); + let signature = + schnorrkel::Signature::from_bytes(&response.signature).expect("64-byte signature"); + + assert_eq!( + ( + authority.wallet_for_tests().current_session(), + keypair + .public + .verify_simple(b"substrate", b"still active", &signature) + .is_ok(), + platform + .sign_raw_reviews + .lock() + .expect("raw signing review list mutex poisoned") + .is_empty(), + ), + (Some(session), true, true), + "a failed activation must preserve the session token, signing key and grant", + ); +} + +#[test] +fn pending_vrf_approval_tracks_wallet_activation() { + use crate::{ + host_internal::sso_messages::{RemoteMessage, RemoteMessageData, v1}, + runtime::sso_service::Dispatch, + }; + use futures::FutureExt; + use truapi::versioned::account::{HostAccountSignVrfError, HostAccountSignVrfRequest}; + + for remote in [false, true] { + for change in ["lock", "reactivate", "reset"] { + let (release, gate) = futures::channel::oneshot::channel(); + let platform = Arc::new(StubPlatform { + sign_vrf_confirmed: true, + sign_vrf_confirmation_gate: std::sync::Mutex::new(Some(gate)), + ..StubPlatform::default() + }); + let (services, authority) = signing_runtime_with_platform(platform.clone()); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .unwrap(); + let session = authority.wallet_for_tests().current_session().unwrap(); + let runtime = product_runtime(services, authority.clone()); + let service = authority.sso_responder().service_for_tests(); + let answer = async { + if remote { + let Dispatch::Response(answer) = service + .answer(RemoteMessage::request( + "pending-vrf".to_string(), + ProductRequest { + calling_product_id: "myapp.dot".to_string(), + payload: vrf_request("myapp.dot"), + }, + )) + .await + .map_err(v01::HostAccountSignVrfError::from)? + else { + panic!("expected a VRF response") + }; + let RemoteMessageData::V1(v1::RemoteMessage::SignVrfResponse(response)) = + answer.message.data + else { + panic!("expected a VRF signing response") + }; + response.payload.map(|_| ()) + } else { + runtime + .sign_vrf( + &CallContext::default(), + HostAccountSignVrfRequest::V1(vrf_request("myapp.dot")), + ) + .await + .map(|_| ()) + .map_err(|error| match error { + CallError::Domain(HostAccountSignVrfError::V1(error)) => error, + other => panic!("unexpected signing failure: {other:?}"), + }) + } + }; + futures::pin_mut!(answer); + assert!(answer.as_mut().now_or_never().is_none()); + match change { + "lock" => futures::executor::block_on(authority.disconnect()), + "reactivate" => { + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .unwrap(); + }, + "reset" => { + futures::executor::block_on(authority.clear_product_state("myapp.dot")).unwrap() + }, + _ => unreachable!(), + } + release.send(()).unwrap(); + let expected = if change == "reset" { + Ok(()) + } else { + Err(v01::HostAccountSignVrfError::NotConnected) + }; + assert_eq!( + ( + futures::executor::block_on(answer), + authority.wallet_for_tests().current_session() == Some(session), + platform.sign_vrf_reviews.lock().unwrap().len() + ), + (expected, change == "reset", 1), + "{change}, remote: {remote}", + ); + } + } +} + +#[test] +fn product_reset_during_allocation_review_cannot_restore_native_grants() { + use futures::FutureExt; + + let (release, gate) = futures::channel::oneshot::channel(); + let platform = Arc::new(StubPlatform { + resource_allocation_confirmed: true, + resource_allocation_confirmation_gate: std::sync::Mutex::new(Some(gate)), + ..StubPlatform::default() + }); + let (services, authority) = signing_runtime_with_platform(platform.clone()); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())).unwrap(); + let runtime = product_runtime(services, authority.clone()); + let cx = CallContext::default(); + let allocation = ResourceAllocation::request( + &runtime, + &cx, + HostRequestResourceAllocationRequest::V1(v01::HostRequestResourceAllocationRequest { + resources: vec![v01::AllocatableResource::AutoSigning], + }), + ); + futures::pin_mut!(allocation); + assert!(allocation.as_mut().now_or_never().is_none()); + futures::executor::block_on(authority.clear_product_state("myapp.dot")).unwrap(); + release.send(()).unwrap(); + let result = futures::executor::block_on(allocation); + let status = keeps_auto_signing(&authority, "myapp.dot"); + assert_eq!( + (result.map(|_| ()), status, platform.resource_allocation_reviews.lock().unwrap().len()), + ( + Err(CallError::Domain( + truapi::versioned::resource_allocation::HostRequestResourceAllocationError::V1( + v01::ResourceAllocationError::Unknown { + reason: AuthorityError::Disconnected.to_string() + } + ) + )), + false, + 1 + ), + ); +} + +#[test] +fn wallet_change_during_ring_preparation_rejects_the_alias() { + use futures::FutureExt; + + for lock in [false, true] { + let resolver = full_person_ring_resolver(); + let (_, authority) = + signing_runtime_with_ring_resolver(Arc::new(StubPlatform::default()), resolver.clone()); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())).unwrap(); + let session = authority.wallet_for_tests().current_session().unwrap(); + let ring = full_person_ring_location(); + register_full_person_key(&authority, &session, &ring); + let (release, gate) = futures::channel::oneshot::channel(); + *resolver.validation_gate.lock().unwrap() = Some(gate); + let product = ProductContext::new("peopl.dot".to_string()).unwrap(); + let cx = CallContext::default(); + let alias = authority.wallet_for_tests().account_alias( + local(&cx, &session, &product), + HostAccountGetAliasRequest { + key_handle: full_person_key_handle(), + context: v01::ProductProofContext { + product_id: "peopl.dot".to_string(), + suffix: v01::DerivationIndex::Index(0), + }, + ring_location: ring, + }, + ); + futures::pin_mut!(alias); + assert!(alias.as_mut().now_or_never().is_none()); + if lock { + futures::executor::block_on(authority.disconnect()); + } else { + futures::executor::block_on(authority.activate_local_session([0xCD; 16].to_vec())) + .unwrap(); + } + release.send(()).unwrap(); + assert_eq!( + futures::executor::block_on(alias), + Err(RingVrfError::from(AuthorityError::Disconnected)) + ); + } +} + +/// Allow once lasts for one unlocked wallet and one installed product: lock, +/// a wallet switch and a product reset each make the next request ask again. +#[test] +fn lock_wallet_switch_and_product_reset_forget_allow_once() { + let platform = Arc::new(StubPlatform { + permission_confirmation_decisions: std::sync::Mutex::new( + [crate::platform::PermissionDecision::AllowOnce; 4].into(), + ), + ..StubPlatform::default() + }); + let (_, authority) = signing_runtime_with_platform(platform.clone()); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())).unwrap(); + let product = ProductContext::new("myapp.dot".to_string()).unwrap(); + let prompts_after_request = || { + futures::executor::block_on(authority.accounts().get_account( + &CallContext::default(), + &product, + truapi::latest::ProductAccountId { + dot_ns_identifier: "peopl.dot".to_string(), + derivation_index: truapi::latest::DerivationIndex::Index(0), + }, + )) + .unwrap(); + platform.account_access_reviews.lock().unwrap().len() + }; + let mut prompts = vec![prompts_after_request(), prompts_after_request()]; + futures::executor::block_on(authority.disconnect()); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())).unwrap(); + prompts.push(prompts_after_request()); + futures::executor::block_on(authority.activate_local_session(vec![0xCD; 16])).unwrap(); + prompts.push(prompts_after_request()); + futures::executor::block_on(authority.clear_product_state("myapp.dot")).unwrap(); + prompts.push(prompts_after_request()); + assert_eq!(prompts, vec![1, 1, 2, 3, 4]); +} diff --git a/rust/crates/truapi/src/runtime/signing_host/tests/allowance_keys.rs b/rust/crates/truapi/src/runtime/signing_host/tests/allowance_keys.rs index 03ea0a17c1..e3045b8f3a 100644 --- a/rust/crates/truapi/src/runtime/signing_host/tests/allowance_keys.rs +++ b/rust/crates/truapi/src/runtime/signing_host/tests/allowance_keys.rs @@ -1,4 +1,5 @@ use super::*; +use crate::runtime::{allowances::AllowanceResource, authority::AccountGrant}; use futures::FutureExt; use parity_scale_codec::Encode; @@ -14,7 +15,7 @@ use crate::{ product_account::{derive_full_person_ring_vrf_entropy, derive_sr25519_hard_path}, statement_store::SUBMIT_STATEMENT_METHOD, }, - runtime::statement_allowance::slot, + runtime::{statement_allowance::slot, statement_store::StatementProofFailure}, }; const PRODUCT_ID: &str = "myapp.dot"; @@ -73,20 +74,28 @@ fn active_signing_host(platform: Arc) -> Arc { /// Bounded so a regression waiting on an unanswered chain read fails instead /// of hanging. -fn allowance_key(signing_host: &SigningHostRole) -> StatementStoreAllowanceKey { - futures::executor::block_on(async { - let session = signing_host.current_session().expect("a session is active"); +fn proof_signer(signing_host: &SigningHostRole) -> [u8; 32] { + let proof = futures::executor::block_on(async { + let session = signing_host.accounts().current_session().expect("a session is active"); let cx = CallContext::default(); futures::select! { - result = signing_host - .statement_store_allowance_key(&cx, &session, PRODUCT_ID.to_string()) + result = signing_host.accounts() + .create_authorized_statement_proof( + &cx, + &session, + PRODUCT_ID.to_string(), + crate::test_support::statement(), + ) .fuse() => result, _ = futures_timer::Delay::new(std::time::Duration::from_secs(30)).fuse() => { panic!("the proof blocked on a chain read") } } - }) - .expect("the proof is served") + }); + let Ok(truapi::latest::StatementProof::Sr25519 { signer, .. }) = proof else { + panic!("the statement must have an Sr25519 proof"); + }; + signer } fn sent_rpc_count(platform: &StubPlatform) -> usize { @@ -98,31 +107,35 @@ fn secret_key() -> StatementStoreAllowanceKey { } fn remember(signing_host: &SigningHostRole, product_id: &str, period: u32) { - signing_host - .local_grants - .lock() - .expect("local AutoSigning grant mutex poisoned") - .statement_allowance_keys - .insert(product_id.to_string(), (period, secret_key())); -} - -fn current_generation(signing_host: &SigningHostRole) -> u64 { - signing_host - .local_grants - .lock() - .expect("local AutoSigning grant mutex poisoned") - .activation_generation + let state = signing_host.session_state(); + let session = state.current().unwrap(); + let revision = signing_host.accounts().grants_for_tests().lifecycle().revision(); + futures::executor::block_on(signing_host.accounts().grants_for_tests().retain_allowance( + &state, + &session, + revision, + product_id, + &AccountGrant::StatementStore { key: secret_key(), period: Some(period) }, + )) + .unwrap(); } -fn remembered(signing_host: &SigningHostRole, product_id: &str, period: u32) -> Option<[u8; 64]> { - let state = signing_host - .local_grants - .lock() - .expect("local AutoSigning grant mutex poisoned"); - state - .statement_allowance_key(state.activation_generation, product_id, period) - .expect("the generation is current") - .map(|key| key.secret) +fn remembered(signing_host: &SigningHostRole, product_id: &str) -> Option<[u8; 64]> { + let state = signing_host.session_state(); + let session = state.current().unwrap(); + let revision = signing_host.accounts().grants_for_tests().lifecycle().revision(); + futures::executor::block_on(signing_host.accounts().grants_for_tests().cached_allowance( + &state, + &session, + revision, + product_id, + AllowanceResource::StatementStore, + )) + .unwrap() + .map(|grant| match grant { + AccountGrant::StatementStore { key, .. } => *key.as_secret_bytes(), + _ => panic!("expected statement-store grant"), + }) } #[test] @@ -130,45 +143,110 @@ fn a_second_proof_in_the_same_session_sends_nothing_to_the_chain() { let platform = chain_with_allocated_slot(); let signing_host = active_signing_host(platform.clone()); - let first = allowance_key(&signing_host); + let first = proof_signer(&signing_host); let sent_after_first = sent_rpc_count(&platform); - let second = allowance_key(&signing_host); + let second = proof_signer(&signing_host); assert_eq!( - (sent_after_first > 0, second.public_key, sent_rpc_count(&platform)), - (true, first.public_key, sent_after_first), + (sent_after_first > 0, second, sent_rpc_count(&platform)), + (true, first, sent_after_first), "the first proof must reach the chain, and a proof after it must not" ); } +/// The host obtains a product's sponsorship on its own; the user is not asked. #[test] -fn a_new_period_looks_the_allowance_up_again() { - let signing_host = active_signing_host(Arc::new(StubPlatform::default())); - remember(&signing_host, PRODUCT_ID, PERIOD); +fn a_first_authorized_proof_obtains_its_allowance_without_a_prompt() { + let platform = chain_with_allocated_slot(); + let signing_host = active_signing_host(platform.clone()); + + let signer = proof_signer(&signing_host); + let allowance = + derive_sr25519_hard_path(&ENTROPY, &["allowance", "statement-store", PRODUCT_ID]).unwrap(); assert_eq!( - ( - remembered(&signing_host, PRODUCT_ID, PERIOD), - remembered(&signing_host, PRODUCT_ID, PERIOD + 1), - ), - (Some(SECRET), None), - "the next period served the previous period's key" + (signer, platform.resource_allocation_reviews.lock().unwrap().len()), + (allowance.public.to_bytes(), 0) ); } #[test] -fn a_new_session_looks_the_allowance_up_again() { - let signing_host = active_signing_host(Arc::new(StubPlatform::default())); +fn a_new_period_keeps_the_sponsorship_key_until_submission() { + let platform = chain_with_allocated_slot(); + let signing_host = active_signing_host(platform.clone()); + let period = slot::current_period(crate::unix_time::current_unix_secs()); + remember(&signing_host, PRODUCT_ID, period.checked_sub(1).unwrap()); + + let signer = proof_signer(&signing_host); + assert_eq!( + (signer == secret_key().public_key, sent_rpc_count(&platform) > 0), + (true, false), + "proof creation reuses the key; submission renews its sponsorship", + ); +} + +#[test] +fn a_new_session_reuses_the_same_wallet_allowance() { + let platform = Arc::new(StubPlatform::default()); + let signing_host = active_signing_host(platform.clone()); remember(&signing_host, PRODUCT_ID, PERIOD); futures::executor::block_on(signing_host.disconnect()); - futures::executor::block_on(signing_host.activate_local_session(ENTROPY.to_vec())) - .expect("re-activation succeeds"); + futures::executor::block_on(signing_host.activate_local_session(ENTROPY.to_vec())).unwrap(); + let after_lock = remembered(&signing_host, PRODUCT_ID); + let restarted = active_signing_host(platform); + let state = restarted.session_state(); + let session = state.current().unwrap(); + let revision = restarted.accounts().grants_for_tests().lifecycle().revision(); + let restored = + futures::executor::block_on(restarted.accounts().grants_for_tests().cached_allowance( + &state, + &session, + revision, + PRODUCT_ID, + AllowanceResource::StatementStore, + )) + .unwrap(); + futures::executor::block_on(restarted.activate_local_session(vec![0xAC; 16])).unwrap(); + + assert_eq!( + ( + after_lock, + restored.map(|grant| match grant { + AccountGrant::StatementStore { key, period } => (period, *key.as_secret_bytes()), + _ => panic!("expected statement-store grant"), + }), + remembered(&restarted, PRODUCT_ID) + ), + (Some(SECRET), Some((None, SECRET)), None), + "same-wallet restart retains its key without exposing it to another owner", + ); +} +#[test] +fn deleting_a_wallet_clears_its_grants_and_only_disconnects_that_wallet() { + let platform = Arc::new(StubPlatform::default()); + let host = active_signing_host(platform.clone()); + let owner = host.session_state().current().unwrap().public_key; + remember(&host, PRODUCT_ID, PERIOD); + futures::executor::block_on(host.activate_local_session(vec![0xAC; 16])).unwrap(); + let other_owner = host.session_state().current().unwrap().public_key; + remember(&host, PRODUCT_ID, PERIOD); + futures::executor::block_on(host.clear_wallet_state(owner)).unwrap(); + let other_grant = remembered(&host, PRODUCT_ID); + let other_selected = host.session_state().current().unwrap().public_key; + futures::executor::block_on(host.activate_local_session(ENTROPY.to_vec())).unwrap(); + let deleted_grant = remembered(&host, PRODUCT_ID); + futures::executor::block_on(host.clear_wallet_state(owner)).unwrap(); assert_eq!( - remembered(&signing_host, PRODUCT_ID, PERIOD), - None, - "the second session served the first session's key" + ( + other_selected, + other_grant, + deleted_grant, + host.session_state().current().is_none(), + platform.auth_states.lock().unwrap().last().cloned(), + ), + (other_owner, Some(SECRET), None, true, Some(crate::platform::AuthState::Disconnected)), ); } @@ -178,13 +256,11 @@ fn clearing_a_product_forgets_only_its_key() { remember(&signing_host, PRODUCT_ID, PERIOD); remember(&signing_host, "other.dot", PERIOD); - signing_host.clear_product_state(PRODUCT_ID).expect("the product id is valid"); + futures::executor::block_on(signing_host.clear_product_state(PRODUCT_ID)) + .expect("the product id is valid"); assert_eq!( - ( - remembered(&signing_host, PRODUCT_ID, PERIOD), - remembered(&signing_host, "other.dot", PERIOD), - ), + (remembered(&signing_host, PRODUCT_ID), remembered(&signing_host, "other.dot"),), (None, Some(SECRET)), "clearing a product's state must forget its key and keep the others" ); @@ -193,46 +269,88 @@ fn clearing_a_product_forgets_only_its_key() { #[test] fn a_replaced_session_is_not_served_the_new_sessions_key() { let signing_host = active_signing_host(Arc::new(StubPlatform::default())); - let stale_generation = current_generation(&signing_host); + let authority_session = signing_host.accounts().current_session().unwrap(); futures::executor::block_on(signing_host.activate_local_session(ENTROPY.to_vec())) .expect("re-activation succeeds"); - remember(&signing_host, PRODUCT_ID, PERIOD); + remember( + &signing_host, + PRODUCT_ID, + slot::current_period(crate::unix_time::current_unix_secs()), + ); assert!( matches!( - signing_host - .local_grants - .lock() - .expect("local AutoSigning grant mutex poisoned") - .statement_allowance_key(stale_generation, PRODUCT_ID, PERIOD), - Err(AuthorityError::Disconnected) + futures::executor::block_on(signing_host.accounts().create_authorized_statement_proof( + &CallContext::default(), + &authority_session, + PRODUCT_ID.to_string(), + crate::test_support::statement(), + )), + Err(StatementProofFailure::NoSession), ), - "a request validated under the replaced session was served a key" + "a request validated under the replaced session was served a proof", ); } #[test] fn a_key_allocated_under_a_replaced_session_is_not_remembered() { let signing_host = active_signing_host(Arc::new(StubPlatform::default())); - let stale_generation = current_generation(&signing_host); + let state = signing_host.session_state(); + let session = state.current().unwrap(); + let revision = signing_host.accounts().grants_for_tests().lifecycle().revision(); futures::executor::block_on(signing_host.activate_local_session(ENTROPY.to_vec())) .expect("re-activation succeeds"); - let remembered_stale = signing_host - .local_grants - .lock() - .expect("local AutoSigning grant mutex poisoned") - .remember_statement_allowance_key( - stale_generation, - PRODUCT_ID.to_string(), - PERIOD, - secret_key(), - ); - + let remembered_stale = + futures::executor::block_on(signing_host.accounts().grants_for_tests().retain_allowance( + &state, + &session, + revision, + PRODUCT_ID, + &AccountGrant::StatementStore { key: secret_key(), period: Some(PERIOD) }, + )); assert_eq!( - (remembered_stale, remembered(&signing_host, PRODUCT_ID, PERIOD)), + (remembered_stale.map(|_| ()), remembered(&signing_host, PRODUCT_ID)), (Err(AuthorityError::Disconnected), None), - "a key allocated for a replaced session was handed back or remembered" + "a key allocated for a replaced session was handed back or remembered", + ); +} + +#[test] +fn product_reset_does_not_restore_a_pending_allowance() { + let (release, gate) = futures::channel::oneshot::channel(); + let platform = chain_with_allocated_slot(); + *platform.rpc_method_responses_gate.lock().unwrap() = Some(gate); + let signing_host = active_signing_host(platform.clone()); + let authority_session = signing_host.accounts().current_session().unwrap(); + let cx = CallContext::default(); + let allocation = signing_host.accounts().create_authorized_statement_proof( + &cx, + &authority_session, + PRODUCT_ID.to_string(), + crate::test_support::statement(), + ); + futures::pin_mut!(allocation); + assert!(allocation.as_mut().now_or_never().is_none()); + crate::test_support::wait_until( + || sent_rpc_count(&platform) > 0, + "allowance preparation did not reach the chain", + ); + futures::executor::block_on(signing_host.clear_product_state(PRODUCT_ID)).unwrap(); + release.send(()).unwrap(); + let result = futures::executor::block_on(async { + futures::select! { + result = allocation.fuse() => result.map(|_| ()), + _ = futures_timer::Delay::new(std::time::Duration::from_secs(5)).fuse() => panic!("stale acquisition did not stop"), + } + }); + assert_eq!( + ( + matches!(result, Err(StatementProofFailure::NoSession)), + remembered(&signing_host, PRODUCT_ID), + ), + (true, None), + "a late grant cannot restore the cleared key in memory", ); } @@ -258,6 +376,7 @@ fn submit_answered( futures::executor::block_on(signing_host.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); remember(&signing_host, PRODUCT_ID, PERIOD); + signing_host.set_grant_allowances_unchecked(true); let runtime = product_runtime_for(services, signing_host.clone(), PRODUCT_ID); let submitted = futures::executor::block_on(runtime.submit( @@ -289,13 +408,13 @@ fn submit_rejected(reason: &str, attempts: usize, signer: [u8; 32]) -> Arc Some(*key.as_secret_bytes()), + _ => None, + }) + }); + + assert_eq!(result, Some(retained)); +} + +#[test] +fn only_a_retained_sponsorship_renews_without_another_approval() { + let platform = chain_with_allocated_slot(); + let host = active_signing_host(platform.clone()); + let key = + derive_sr25519_hard_path(&ENTROPY, &["allowance", "statement-store", PRODUCT_ID]).unwrap(); + let state = host.session_state(); + let session = state.current().unwrap(); + let revision = host.accounts().grants_for_tests().lifecycle().revision(); + let product = crate::platform::ProductContext::new(PRODUCT_ID.to_string()).unwrap(); + futures::executor::block_on(async { + host.accounts() + .grants_for_tests() + .retain_allowance( + &state, + &session, + revision, + PRODUCT_ID, + &AccountGrant::StatementStore { + key: StatementStoreAllowanceKey::from_secret_bytes( + key.secret.to_bytes().to_vec(), + ) + .unwrap(), + period: Some(PERIOD), + }, + ) + .await + .unwrap(); + host.accounts() + .renew_statement_sponsorship(&CallContext::default(), &product, [0x11; 32]) + .await + .unwrap(); + assert_eq!(sent_rpc_count(&platform), 0); + host.accounts() + .renew_statement_sponsorship(&CallContext::default(), &product, key.public.to_bytes()) + .await + .unwrap(); + }); + assert_eq!( + ( + sent_rpc_count(&platform) > 0, + platform.resource_allocation_reviews.lock().unwrap().len(), + remembered(&host, PRODUCT_ID) + ), + (true, 0, Some(key.secret.to_bytes())) + ); +} + +#[test] +fn a_failed_bulletin_extension_keeps_the_retained_key_without_a_prompt() { + let platform = Arc::new(StubPlatform { + rpc_method_responses: vec![ + ("state_getStorage", "null".to_string()), + ("state_getStorage", "null".to_string()), + ], + unreachable_genesis: Some([0; 32]), + ..Default::default() + }); + let host = active_signing_host(platform.clone()); + let state = host.session_state(); + let session = state.current().unwrap(); + let authority_session = host.accounts().current_session().unwrap(); + let revision = host.accounts().grants_for_tests().lifecycle().revision(); + let key = derive_sr25519_hard_path(&ENTROPY, &["retained-bulletin"]).unwrap(); + let outcome = futures::executor::block_on(async { + host.accounts() + .grants_for_tests() + .retain_allowance( + &state, + &session, + revision, + PRODUCT_ID, + &AccountGrant::Bulletin( + crate::runtime::BulletinAllowanceKey::from_secret_bytes( + key.secret.to_bytes().to_vec(), + ) + .unwrap(), + ), + ) + .await + .unwrap(); + let submission = host + .accounts() + .submit_preimage( + &CallContext::default(), + std::time::Instant::now() + std::time::Duration::from_secs(1), + &authority_session, + PRODUCT_ID.to_string(), + b"quota required", + ) + .await; + let retained = host + .accounts() + .grants_for_tests() + .cached_allowance(&state, &session, revision, PRODUCT_ID, AllowanceResource::Bulletin) + .await + .unwrap(); + ( + matches!( + submission, + Err(crate::runtime::bulletin_rpc::BulletinSubmitError::Authority( + AuthorityError::Unavailable { .. } + )) + ), + retained.and_then(|grant| match grant { + AccountGrant::Bulletin(key) => Some(*key.as_secret_bytes()), + _ => None, + }), + ) + }); + assert_eq!( + (outcome, platform.resource_allocation_reviews.lock().unwrap().len()), + ((true, Some(key.secret.to_bytes())), 0) + ); +} + +#[test] +fn started_native_writes_survive_lock_but_not_an_interrupted_product_reset() { + for change in ["lock", "reset-and-drop-write", "reset"] { + let platform = Arc::new(StubPlatform::default()); + let host = active_signing_host(platform.clone()); + remember(&host, "other.dot", PERIOD); + let state = host.session_state(); + let session = state.current().unwrap(); + let revision = host.accounts().grants_for_tests().lifecycle().revision(); + let (release, gate) = futures::channel::oneshot::channel(); + *platform.core_storage_write_gate.lock().unwrap() = Some(gate); + let grant = AccountGrant::StatementStore { key: secret_key(), period: Some(PERIOD) }; + let mut write = Box::pin( + host.accounts() + .grants_for_tests() + .retain_allowance(&state, &session, revision, PRODUCT_ID, &grant), + ); + assert!(write.as_mut().now_or_never().is_none()); + if change == "lock" { + futures::executor::block_on(host.disconnect()); + } else { + let mut reset = Box::pin(host.clear_product_state(PRODUCT_ID)); + assert!(reset.as_mut().now_or_never().is_none()); + drop(reset); + } + if change == "reset-and-drop-write" { + drop(write); + let _ = release.send(()); + } else { + release.send(()).unwrap(); + assert_eq!( + futures::executor::block_on(write).map(|_| ()), + Err(AuthorityError::Disconnected) + ); + } + futures::executor::block_on(host.activate_local_session(ENTROPY.to_vec())).unwrap(); + assert_eq!( + (remembered(&host, PRODUCT_ID), remembered(&host, "other.dot")), + (if change == "lock" { Some(SECRET) } else { None }, Some(SECRET)), + "{change}", + ); + } +} diff --git a/rust/crates/truapi/src/runtime/signing_host/tests/auto_signing.rs b/rust/crates/truapi/src/runtime/signing_host/tests/auto_signing.rs index fc704e2b02..9de9f58354 100644 --- a/rust/crates/truapi/src/runtime/signing_host/tests/auto_signing.rs +++ b/rust/crates/truapi/src/runtime/signing_host/tests/auto_signing.rs @@ -4,14 +4,15 @@ use super::*; use crate::{ host_internal::sso_messages::{RemoteMessage, RemoteMessageData, v1}, - runtime::{signing_host::SigningHostSsoService, sso_service::Dispatch}, + runtime::sso_service::Dispatch, }; use truapi::versioned::{ - account::HostAccountSignVrfRequest, signing::HostSignRawWithLegacyAccountRequest, + account::HostAccountSignVrfRequest, resource_allocation::HostRequestResourceAllocationError, + signing::HostSignRawWithLegacyAccountRequest, }; /// Allocate an AutoSigning grant for the runtime's own product. -fn grant_auto_signing(runtime: &ProductRuntimeHost) { +pub fn grant_auto_signing(runtime: &ProductRuntimeHost) { let allocation = futures::executor::block_on(ResourceAllocation::request( runtime, &CallContext::default(), @@ -183,8 +184,8 @@ fn a_blessed_vrf_signature_skips_the_prompt_only_locally_for_its_own_account() { }; let own_signed = sign_locally(request.clone()); let foreign_signed = sign_locally(vrf_request("other.paseo")); - let Dispatch::Response(answer) = futures::executor::block_on( - SigningHostSsoService::new(activation).answer(RemoteMessage::request( + let Ok(Dispatch::Response(answer)) = futures::executor::block_on( + activation.sso_responder().service_for_tests().answer(RemoteMessage::request( "relayed-vrf".to_string(), ProductRequest { calling_product_id: "dim2.paseo".to_string(), payload: request }, )), @@ -229,3 +230,88 @@ fn a_blessed_product_still_confirms_legacy_account_signing() { assert_eq!((signed, platform.sign_raw_reviews.lock().unwrap().len()), (false, 1),); } + +#[test] +fn a_declined_allocation_keeps_no_auto_signing_key() { + let platform = Arc::new(StubPlatform::default()); + let (_, authority) = signing_runtime_with_platform(platform.clone()); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())).unwrap(); + let authority_session = authority.accounts().current_session().unwrap(); + let request = truapi::latest::HostRequestResourceAllocationRequest { + resources: vec![truapi::latest::AllocatableResource::AutoSigning], + }; + let result = futures::executor::block_on(authority.accounts().allocate_resources( + &CallContext::default(), + &authority_session, + &ProductContext::new("myapp.dot".to_string()).unwrap(), + request.clone(), + )); + let grant = keeps_auto_signing(&authority, "myapp.dot"); + assert_eq!( + (result, grant, platform.resource_allocation_reviews.lock().unwrap().clone()), + ( + Ok(truapi::latest::HostRequestResourceAllocationResponse { + outcomes: vec![truapi::latest::AllocationOutcome::Rejected], + }), + false, + vec![crate::platform::ResourceAllocationReview { + calling_product_id: "myapp.dot".to_string(), + resources: request.resources, + }], + ), + ); +} + +#[test] +fn cancelling_a_later_resource_keeps_the_first_native_authorization() { + use std::{ + future::Future, + task::{Context, Poll}, + }; + + let platform = Arc::new(StubPlatform { + resource_allocation_confirmed: true, + chain_connect_pending: true, + ..StubPlatform::default() + }); + let (services, authority) = signing_runtime_with_platform(platform); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())).unwrap(); + let runtime = product_runtime(services, authority.clone()); + let cancel = truapi::CancellationToken::default(); + let cx = CallContext::with_parts("partial-allocation".to_string(), cancel.clone()); + let mut allocation = Box::pin(ResourceAllocation::request( + &runtime, + &cx, + HostRequestResourceAllocationRequest::V1( + truapi::latest::HostRequestResourceAllocationRequest { + resources: vec![ + truapi::latest::AllocatableResource::AutoSigning, + truapi::latest::AllocatableResource::SmartContractAllowance( + truapi::latest::DerivationIndex::Index(0), + ), + ], + }, + ), + )); + assert_eq!( + allocation.as_mut().poll(&mut Context::from_waker(&futures::task::noop_waker())), + Poll::Pending, + ); + let retained_before_cancel = keeps_auto_signing(&authority, "myapp.dot"); + cancel.cancel(); + let result = futures::executor::block_on(allocation); + let retained_after_cancel = keeps_auto_signing(&authority, "myapp.dot"); + assert_eq!( + (result, retained_before_cancel, retained_after_cancel), + ( + Err(CallError::Domain(HostRequestResourceAllocationError::V1( + truapi::latest::ResourceAllocationError::Unknown { + reason: "Account authority request cancelled for partial-allocation" + .to_string(), + } + ))), + true, + true, + ), + ); +} diff --git a/rust/crates/truapi/src/runtime/signing_host/tests/cross_product_account.rs b/rust/crates/truapi/src/runtime/signing_host/tests/cross_product_account.rs index af7bd1af13..e8f943a1cc 100644 --- a/rust/crates/truapi/src/runtime/signing_host/tests/cross_product_account.rs +++ b/rust/crates/truapi/src/runtime/signing_host/tests/cross_product_account.rs @@ -145,7 +145,7 @@ fn a_blessed_product_signs_with_another_products_account_only_when_granted() { /// A `dim2next.paseo` execution that `dim2.paseo` granted its accounts, on a /// local signing session. One runtime is one execution, so what the user /// approved carries from one call to the next. -fn dim2next_runtime(platform: Arc) -> ProductRuntimeHost { +fn dim2next_runtime(platform: Arc) -> ProductRuntimeHost { cache_grant(&platform, "dim2.paseo", r#"{"dim2next":["context"]}"#); let (services, activation) = signing_runtime_with_platform(platform); futures::executor::block_on(activation.activate_local_session(ENTROPY.to_vec())) @@ -173,21 +173,30 @@ fn transaction_request(account: v01::ProductAccountId) -> HostCreateTransactionR HostCreateTransactionRequest::V1(payload) } -fn sign_payload_with(runtime: &ProductRuntimeHost, account: v01::ProductAccountId) { +fn sign_payload_with( + runtime: &ProductRuntimeHost, + account: v01::ProductAccountId, +) { futures::executor::block_on( runtime.sign_payload(&CallContext::default(), payload_request(account)), ) .expect("the grant admits the account and the user approves"); } -fn sign_raw_with(runtime: &ProductRuntimeHost, account: v01::ProductAccountId) { +fn sign_raw_with( + runtime: &ProductRuntimeHost, + account: v01::ProductAccountId, +) { futures::executor::block_on(runtime.sign_raw(&CallContext::default(), raw_request(account))) .expect("the grant admits the account and the user approves"); } /// Starts a transaction and leaves it once it has been confirmed and handed to /// the authority, which would then wait on a chain these tests do not run. -fn create_transaction_with(runtime: &ProductRuntimeHost, account: v01::ProductAccountId) { +fn create_transaction_with( + runtime: &ProductRuntimeHost, + account: v01::ProductAccountId, +) { let cx = CallContext::default(); let call = runtime.create_transaction(&cx, transaction_request(account)); assert!(call.now_or_never().is_none(), "the grant admits the account and the user approves",); diff --git a/rust/crates/truapi/src/runtime/signing_host/tests/local_preimages.rs b/rust/crates/truapi/src/runtime/signing_host/tests/local_preimages.rs index 3b64b7a17c..9ae7305ccb 100644 --- a/rust/crates/truapi/src/runtime/signing_host/tests/local_preimages.rs +++ b/rust/crates/truapi/src/runtime/signing_host/tests/local_preimages.rs @@ -19,7 +19,10 @@ fn approving_platform() -> Arc { } /// A product runtime on a signing host with a local session. -fn activated(unchecked: bool, withheld: &[&str]) -> (ProductRuntimeHost, Arc) { +fn activated( + unchecked: bool, + withheld: &[&str], +) -> (ProductRuntimeHost, Arc) { let (services, activation) = signing_runtime_with_platform(approving_platform()); futures::executor::block_on(activation.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); @@ -29,7 +32,7 @@ fn activated(unchecked: bool, withheld: &[&str]) -> (ProductRuntimeHost, Arc, value: &[u8], ) -> Result< RemotePreimageSubmitResponse, @@ -42,6 +45,26 @@ fn submit( )) } +/// The host obtains a product's Bulletin allowance on its own; only the submission is the +/// user's to confirm. +#[test] +fn a_first_submit_obtains_its_allowance_without_an_allocation_prompt() { + let platform = Arc::new(StubPlatform::default()); + let (services, activation) = signing_runtime_with_platform(platform.clone()); + futures::executor::block_on(activation.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + activation.set_grant_allowances_unchecked(true); + let runtime = product_runtime(services, activation); + let value = b"first preimage".to_vec(); + + let response = submit(&runtime, &value); + + assert_eq!( + (response, platform.resource_allocation_reviews.lock().unwrap().len()), + (Ok(RemotePreimageSubmitResponse::V1(preimage_key(&value).to_vec())), 0) + ); +} + #[test] fn an_unchecked_test_host_answers_a_submit_with_the_content_key_and_serves_it_back() { let (runtime, _) = activated(true, &[]); @@ -75,10 +98,7 @@ fn a_withheld_bulletin_allowance_still_refuses_the_submit() { assert_eq!( error, CallError::Domain(truapi::versioned::preimage::RemotePreimageSubmitError::V1( - v01::PreimageSubmitError::Unknown { - reason: "Bulletin allowance allocation was rejected by the signing host" - .to_string(), - } + v01::PreimageSubmitError::Unknown { reason: "Rejected".to_string() } )) ); } @@ -88,9 +108,9 @@ fn a_signing_host_sends_preimages_to_the_chain_unless_told_or_unchecked() { let (_, checked) = activated(false, &[]); let (_, unchecked) = activated(true, &[]); let (_, told) = activated(false, &[]); - told.set_submit_preimages_locally(true); + told.accounts().set_submit_preimages_locally(true); - assert!(!ProductAuthority::submits_preimages_locally(&*checked)); - assert!(ProductAuthority::submits_preimages_locally(&*unchecked)); - assert!(ProductAuthority::submits_preimages_locally(&*told)); + assert!(!checked.accounts().submits_preimages_locally()); + assert!(unchecked.accounts().submits_preimages_locally()); + assert!(told.accounts().submits_preimages_locally()); } diff --git a/rust/crates/truapi/src/runtime/signing_host/tests/raw_signing.rs b/rust/crates/truapi/src/runtime/signing_host/tests/raw_signing.rs index 2410c60df3..14d62f2f06 100644 --- a/rust/crates/truapi/src/runtime/signing_host/tests/raw_signing.rs +++ b/rust/crates/truapi/src/runtime/signing_host/tests/raw_signing.rs @@ -7,7 +7,7 @@ use crate::{ RemoteMessage, RemoteMessageData, SignRawWithLegacyAccountRequest, SignRequest, v1, }, platform::SignRawReview, - runtime::{signing_host::sso_service::SigningHostSsoService, sso_service::Dispatch}, + runtime::sso_service::Dispatch, }; use parity_scale_codec::{Decode, Encode}; use truapi::versioned::signing::{ @@ -86,7 +86,7 @@ fn paired_raw_signing_review_matches_the_signed_bytes_and_requires_confirmation( let (_, activation) = signing_runtime_with_platform(platform.clone()); futures::executor::block_on(activation.activate_local_session(ENTROPY.to_vec())) .unwrap(); - let service = SigningHostSsoService::new(activation); + let service = activation.sso_responder().service_for_tests(); let identity = derive_identity_keypair(&ENTROPY, TEST_NETWORK_SUFFIX).unwrap(); let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); let product = derive_product_keypair(&root, "myapp.dot", index_bytes(0)).unwrap(); @@ -129,7 +129,8 @@ fn paired_raw_signing_review_matches_the_signed_bytes_and_requires_confirmation( let request = SignRequest::decode(&mut encoded.as_slice()).unwrap(); RemoteMessage::request("proof".into(), request) }; - let Dispatch::Response(answer) = futures::executor::block_on(service.answer(message)) + let Ok(Dispatch::Response(answer)) = + futures::executor::block_on(service.answer(message)) else { panic!("expected signing response") }; diff --git a/rust/crates/truapi/src/runtime/signing_host/tests/remote_consent.rs b/rust/crates/truapi/src/runtime/signing_host/tests/remote_consent.rs new file mode 100644 index 0000000000..0773fa74e9 --- /dev/null +++ b/rust/crates/truapi/src/runtime/signing_host/tests/remote_consent.rs @@ -0,0 +1,287 @@ +use super::*; +use crate::{ + host_internal::{ + permissions::set_account_access_status, + sso_messages::{ProductRequest, RemoteMessage, RemoteMessageData, SignRequest, v1}, + }, + platform::{PermissionAuthorizationStatus, PermissionDecision, SignRawReview}, + runtime::sso_service::Dispatch, +}; +use truapi::latest::{HostAccountListRingVrfKeysRequest, RingVrfKeyDisclosure}; + +/// A paired host's request never rides on the product's AutoSigning grant: the phone +/// confirms it like any other. +#[test] +fn a_relayed_signature_is_confirmed_despite_an_auto_signing_grant() { + let platform = + Arc::new(StubPlatform { resource_allocation_confirmed: true, ..StubPlatform::default() }); + let (services, authority) = signing_runtime_with_platform(platform.clone()); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())).unwrap(); + auto_signing::grant_auto_signing(&product_runtime(services, authority.clone())); + let request = truapi::latest::HostSignRawRequest { + account: product_account(0), + payload: truapi::latest::RawPayload::Bytes { bytes: b"approval".to_vec() }, + }; + + let Ok(Dispatch::Response(answer)) = + futures::executor::block_on(authority.sso_responder().service_for_tests().answer( + RemoteMessage::request("relayed-raw".to_string(), SignRequest::Raw(request.clone())), + )) + else { + panic!("expected a signing response"); + }; + + let RemoteMessageData::V1(v1::RemoteMessage::SignResponse(response)) = answer.message.data + else { + panic!("expected a raw signing response"); + }; + assert_eq!( + ( + response.payload.map(|signed| signed.signature), + platform.sign_raw_reviews.lock().unwrap().clone() + ), + ( + Err(AuthorityError::Rejected.to_string()), + vec![SignRawReview::Product { calling_product_id: None, request, watermarked: true }] + ), + ); +} + +/// A relayed VRF request the wallet would refuse anyway is refused before the user is asked. +#[test] +fn a_relayed_oversized_vrf_transcript_is_refused_without_a_prompt() { + let platform = Arc::new(StubPlatform { sign_vrf_confirmed: true, ..StubPlatform::default() }); + let (_, authority) = signing_runtime_with_platform(platform.clone()); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())).unwrap(); + let items = vec![ + v01::VrfTranscriptItem { label: b"item".to_vec(), value: Vec::new() }; + crate::runtime::MAX_VRF_TRANSCRIPT_ITEMS + 1 + ]; + let request = truapi::latest::HostAccountSignVrfRequest { + account: product_account(0), + transcript_label: b"ctx".to_vec(), + items, + }; + + let Ok(Dispatch::Response(answer)) = futures::executor::block_on( + authority.sso_responder().service_for_tests().answer(RemoteMessage::request( + "relayed-oversized-vrf".to_string(), + ProductRequest { calling_product_id: "myapp.dot".to_string(), payload: request }, + )), + ) else { + panic!("expected a VRF response"); + }; + + let RemoteMessageData::V1(v1::RemoteMessage::SignVrfResponse(response)) = answer.message.data + else { + panic!("expected a VRF signing response"); + }; + let limit = crate::runtime::MAX_VRF_TRANSCRIPT_ITEMS; + assert_eq!( + (response.payload, platform.sign_vrf_reviews.lock().unwrap().len()), + ( + Err(v01::HostAccountSignVrfError::Unknown { + reason: format!( + "VRF transcript has {} items, at most {limit} are allowed", + limit + 1 + ), + }), + 0 + ) + ); +} + +#[test] +fn remote_vrf_cannot_reuse_a_native_auto_signing_grant() { + let platform = + Arc::new(StubPlatform { resource_allocation_confirmed: true, ..StubPlatform::default() }); + let (services, authority) = signing_runtime_with_platform(platform.clone()); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())).unwrap(); + auto_signing::grant_auto_signing(&product_runtime(services, authority.clone())); + let Ok(Dispatch::Response(answer)) = futures::executor::block_on( + authority.sso_responder().service_for_tests().answer(RemoteMessage::request( + "remote-vrf".to_string(), + ProductRequest { + calling_product_id: "myapp.dot".to_string(), + payload: vrf_request("myapp.dot"), + }, + )), + ) else { + panic!("expected a VRF response") + }; + let RemoteMessageData::V1(v1::RemoteMessage::SignVrfResponse(response)) = answer.message.data + else { + panic!("expected a VRF signing response") + }; + assert_eq!( + (response.payload.map(|_| ()), platform.sign_vrf_reviews.lock().unwrap().len(),), + (Err(v01::HostAccountSignVrfError::Rejected), 1), + ); +} + +#[test] +fn remote_account_access_reuses_shared_decisions_and_preserves_their_lifetime() { + for operation in ["alias", "list"] { + for (stored, decision, expected_status, prompts) in [ + ( + Some(PermissionAuthorizationStatus::Authorized), + PermissionDecision::Deny, + PermissionAuthorizationStatus::Authorized, + 0, + ), + ( + Some(PermissionAuthorizationStatus::Denied), + PermissionDecision::AllowAlways, + PermissionAuthorizationStatus::Denied, + 0, + ), + (None, PermissionDecision::AllowAlways, PermissionAuthorizationStatus::Authorized, 1), + (None, PermissionDecision::AllowOnce, PermissionAuthorizationStatus::NotDetermined, 1), + (None, PermissionDecision::Deny, PermissionAuthorizationStatus::Denied, 1), + ] { + let platform = Arc::new(StubPlatform { + permission_confirmation_decisions: std::sync::Mutex::new( + [decision, decision].into(), + ), + ..StubPlatform::default() + }); + cache_grant(&platform, "peopl.dot", "{}"); + let (_, authority) = + signing_runtime_with_ring_resolver(platform.clone(), full_person_ring_resolver()); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .unwrap(); + let session = authority.wallet_for_tests().current_session().unwrap(); + register_full_person_key(&authority, &session, &full_person_ring_location()); + let owner = if operation == "alias" { "peopl" } else { "foreign" }; + if let Some(status) = stored { + futures::executor::block_on(set_account_access_status( + platform.as_ref(), + "myapp", + owner, + status, + )) + .unwrap(); + } + let service = authority.sso_responder().service_for_tests(); + let mut outcomes = Vec::new(); + for request_id in ["first", "second"] { + let request = if operation == "alias" { + RemoteMessage::request( + request_id.to_string(), + ProductRequest { + calling_product_id: "myapp.dot".to_string(), + payload: HostAccountGetAliasRequest { + key_handle: full_person_key_handle(), + context: v01::ProductProofContext { + product_id: "myapp.dot".to_string(), + suffix: v01::DerivationIndex::Index(0), + }, + ring_location: full_person_ring_location(), + }, + }, + ) + } else { + RemoteMessage::request( + request_id.to_string(), + ProductRequest { + calling_product_id: "myapp.dot".to_string(), + payload: HostAccountListRingVrfKeysRequest { + owner: "foreign.dot".to_string(), + disclosure: RingVrfKeyDisclosure::PublicKey, + }, + }, + ) + }; + let Ok(Dispatch::Response(answer)) = + futures::executor::block_on(service.answer(request)) + else { + panic!("expected an account response") + }; + outcomes.push(match answer.message.data { + RemoteMessageData::V1(v1::RemoteMessage::GetAccountAliasResponse(response)) => { + response.payload.map(|_| ()) + }, + RemoteMessageData::V1(v1::RemoteMessage::ListRingVrfKeysResponse(response)) => { + response.payload.map(|_| ()) + }, + _ => panic!("unexpected account response"), + }); + } + let expected = if expected_status == PermissionAuthorizationStatus::Denied { + Err(RingVrfError::Rejected) + } else { + Ok(()) + }; + assert_eq!( + ( + outcomes, + platform.account_access_reviews.lock().unwrap().len(), + futures::executor::block_on( + crate::host_internal::permissions::account_access_status( + platform.as_ref(), + "myapp", + owner + ) + ) + .unwrap(), + ), + (vec![expected.clone(), expected], prompts, expected_status), + "{operation}, stored decision {stored:?}, answer {decision:?}", + ); + } + } +} + +#[test] +fn shared_denials_override_published_access_for_local_and_remote_callers() { + for storage_error in [None, Some("permission storage unavailable")] { + let platform = Arc::new(StubPlatform { + permission_storage_error: storage_error, + ..StubPlatform::default() + }); + cache_grant(&platform, "peopl.dot", r#"{"myapp":["context"]}"#); + deny_account_access(&platform, "myapp.dot", "peopl.dot"); + let (_, authority) = + signing_runtime_with_ring_resolver(platform.clone(), full_person_ring_resolver()); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())).unwrap(); + let session = authority.wallet_for_tests().current_session().unwrap(); + register_full_person_key(&authority, &session, &full_person_ring_location()); + let request = ProductRequest { + calling_product_id: "myapp.dot".to_string(), + payload: HostAccountRingVrfSignRequest { + key_handle: full_person_key_handle(), + message: b"published access".to_vec(), + }, + }; + let local = futures::executor::block_on(authority.wallet_for_tests().ring_vrf_sign( + local( + &CallContext::default(), + &session, + &ProductContext::new(request.calling_product_id.clone()).unwrap(), + ), + request.payload.clone(), + )); + let Ok(Dispatch::Response(answer)) = futures::executor::block_on( + authority + .sso_responder() + .service_for_tests() + .answer(RemoteMessage::request("remote-published-access".to_string(), request)), + ) else { + panic!("expected a ring-VRF response") + }; + let RemoteMessageData::V1(v1::RemoteMessage::RingVrfSignResponse(response)) = + answer.message.data + else { + panic!("expected a ring-VRF signing response") + }; + assert_eq!( + ( + local.map(|_| ()), + response.payload.map(|_| ()), + platform.account_access_reviews.lock().unwrap().len(), + ), + (Err(RingVrfError::NotAllowlisted), Err(RingVrfError::NotAllowlisted), 0), + "{storage_error:?}", + ); + } +} diff --git a/rust/crates/truapi/src/runtime/signing_host/tests/withheld_resources.rs b/rust/crates/truapi/src/runtime/signing_host/tests/withheld_resources.rs index ce9882bfd7..79b0ee9940 100644 --- a/rust/crates/truapi/src/runtime/signing_host/tests/withheld_resources.rs +++ b/rust/crates/truapi/src/runtime/signing_host/tests/withheld_resources.rs @@ -1,7 +1,14 @@ //! A withheld resource is refused while the rest of the request is granted. use super::*; -use crate::test_support::statement; +use crate::{ + host_internal::sso_messages::{ + OnExistingAllowancePolicy, RemoteMessage, RemoteMessageData, ResourceAllocationRequest, + SsoAllocationOutcome, v1, + }, + runtime::sso_service::Dispatch, + test_support::statement, +}; use truapi::{ api::StatementStore, versioned::statement_store::RemoteStatementStoreCreateProofAuthorizedRequest, @@ -15,7 +22,7 @@ fn granting_platform() -> Arc { /// Ask for `resources` and return the per-resource outcomes. fn allocate( - runtime: &ProductRuntimeHost, + runtime: &ProductRuntimeHost, resources: Vec, ) -> Vec { let response = futures::executor::block_on(ResourceAllocation::request( @@ -53,17 +60,48 @@ fn a_withheld_resource_is_refused_while_the_others_are_granted() { ); } +/// The test wallet withholds a resource from every caller, so a paired host +/// is refused it too; the SSO answer reports a refused item as not available. +#[test] +fn a_paired_request_is_refused_a_withheld_resource() { + let (_, activation) = signing_runtime_with_platform(granting_platform()); + futures::executor::block_on(activation.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + activation.set_withheld_resources(vec!["AutoSigning".to_string()]); + let Ok(Dispatch::Response(answer)) = futures::executor::block_on( + activation.sso_responder().service_for_tests().answer(RemoteMessage::request( + "remote-allocation".to_string(), + ResourceAllocationRequest { + calling_product_id: "myapp.dot".to_string(), + resources: vec![v01::AllocatableResource::AutoSigning], + on_existing: OnExistingAllowancePolicy::Increase, + }, + )), + ) else { + panic!("expected an allocation response") + }; + let RemoteMessageData::V1(v1::RemoteMessage::ResourceAllocationResponse(response)) = + answer.message.data + else { + panic!("expected a resource allocation response") + }; + assert_eq!(response.payload, Ok(vec![SsoAllocationOutcome::NotAvailable])); +} + #[test] fn withholding_nothing_leaves_every_resource_granted() { let (services, activation) = signing_runtime_with_platform(granting_platform()); futures::executor::block_on(activation.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); activation.set_grant_allowances_unchecked(true); - let runtime = product_runtime(services, activation); + let runtime = product_runtime(services, activation.clone()); assert_eq!( - allocate(&runtime, vec![v01::AllocatableResource::AutoSigning]), - vec![v01::AllocationOutcome::Allocated], + ( + allocate(&runtime, vec![v01::AllocatableResource::AutoSigning]), + keeps_auto_signing(&activation, "myapp.dot"), + ), + (vec![v01::AllocationOutcome::Allocated], false), ); } @@ -92,7 +130,7 @@ fn a_later_set_replaces_the_earlier_one() { } /// Ask for a statement proof the way `createProofAuthorized` does. -fn proof_is_signed(runtime: &ProductRuntimeHost) -> bool { +fn proof_is_signed(runtime: &ProductRuntimeHost) -> bool { futures::executor::block_on(StatementStore::create_proof_authorized( runtime, &CallContext::default(), @@ -132,30 +170,22 @@ fn a_granted_statement_store_allowance_signs_the_proof_path() { assert!(proof_is_signed(&runtime)); } -/// The bulletin keys allocate on their own too, both the first one a preimage -/// submission asks for and the refreshed one it falls back to. #[test] -fn a_withheld_bulletin_allowance_yields_no_key_on_either_call() { +fn a_withheld_bulletin_allowance_prevents_submission() { let (_services, activation) = signing_runtime_with_platform(granting_platform()); - futures::executor::block_on(activation.activate_local_session(ENTROPY.to_vec())) - .expect("activation succeeds"); + futures::executor::block_on(activation.activate_local_session(ENTROPY.to_vec())).unwrap(); activation.set_grant_allowances_unchecked(true); activation.set_withheld_resources(vec!["BulletinAllowance".to_string()]); - let session = activation.current_session().expect("the session just made"); - let cx = CallContext::default(); - - let keys = futures::executor::block_on(async { - [ - activation - .bulletin_allowance_key(&cx, &session, "myapp.dot".to_string()) - .await - .is_ok(), - activation - .refresh_bulletin_allowance_key(&cx, &session, "myapp.dot".to_string()) - .await - .is_ok(), - ] - }); - - assert_eq!(keys, [false, false]); + let session = activation.accounts().current_session().unwrap(); + let result = futures::executor::block_on(activation.accounts().submit_preimage( + &CallContext::default(), + std::time::Instant::now() + std::time::Duration::from_secs(1), + &session, + "myapp.dot".to_string(), + b"withheld", + )); + assert!(matches!( + result, + Err(crate::runtime::bulletin_rpc::BulletinSubmitError::Authority(AuthorityError::Rejected)) + )); } diff --git a/rust/crates/truapi/src/runtime/signing_host/wallet_account_holder.rs b/rust/crates/truapi/src/runtime/signing_host/wallet_account_holder.rs new file mode 100644 index 0000000000..1f42175187 --- /dev/null +++ b/rust/crates/truapi/src/runtime/signing_host/wallet_account_holder.rs @@ -0,0 +1,425 @@ +//! Wallet activation, authorization and resource issuance. + +mod account; +mod allowance; +mod allowance_renewal; +#[cfg(test)] +mod allowance_tests; +mod pairing; +pub use allowance::AllowanceAllocationError; +pub use allowance_renewal::StatementRenewalTarget; +#[cfg(not(target_arch = "wasm32"))] +pub use allowance_renewal::TrackedStatementRenewalTarget; + +use std::sync::{Arc, Mutex}; +use truapi::latest::ProductAccountId; +use zeroize::Zeroizing; + +use crate::{ + host_internal::sso_messages::RingVrfError, + host_logic::{ + entropy::{derive_product_entropy, root_entropy_source}, + product_account::{ + ProductAccountError, derivation_index_bytes, derive_full_person_ring_vrf_entropy, + derive_identity_keypair, derive_lite_person_ring_vrf_entropy, derive_product_keypair, + derive_product_subtree_keypair, derive_ring_vrf_domain_entropy, + derive_ring_vrf_entropy, derive_root_keypair_from_entropy, derive_sr25519_hard_path, + }, + session::{SessionInfo, SessionState}, + sso::pairing::{ + ResponderIdentity, derive_identity_chat_private_key, derive_x25519_keypair_from_entropy, + }, + }, + platform::normalize_product_identifier, + runtime::{ + authority::{ + AccountHolder, AuthorityError, AuthoritySession, authority_session_validation_id, + }, + product_consent::ProductConsent, + statement_allowance::{ + PersonhoodSigner, StatementAllowanceError, collection::PersonhoodCollection, proof, + ring::RingParams, + }, + vrf::{self, Vrf}, + }, +}; + +/// RFC-0022 domain for the responder's persistent SSO X25519 key. +const SSO_ENCRYPTION_DOMAIN: &[u8] = b"sso"; + +/// Owns the active wallet session and its zeroizable entropy. +pub struct WalletAccountHolder { + services: Arc, + ring_resolver: Arc, + ring_vrf_registry: Arc, + renewal: allowance_renewal::RenewalState, + #[cfg(feature = "test-host")] + resource_controls: Arc, + network_suffix: String, + consent: Arc, + lifecycle: Mutex, + session_state: Arc, +} + +#[derive(Default)] +struct WalletState { + activation: u64, + keys: Option, +} + +impl WalletState { + fn advance(&mut self) { + self.activation = self.activation.checked_add(1).expect("wallet activation exhausted"); + } + + fn session(&self, session: &SessionInfo) -> AuthoritySession { + let mut validation_id = authority_session_validation_id(session); + validation_id.extend_from_slice(b":activation:"); + validation_id.extend_from_slice(&self.activation.to_le_bytes()); + AuthoritySession::from_session_info(session, validation_id) + } + + fn require_session( + &self, + current: Option, + session: &AuthoritySession, + ) -> Result { + let current = current.ok_or(AuthorityError::Disconnected)?; + if self.session(¤t).validation_id != session.validation_id { + return Err(AuthorityError::Disconnected); + } + Ok(current) + } +} + +struct WalletPersonhoodSigner<'a> { + wallet: &'a WalletAccountHolder, + session: &'a AuthoritySession, + vrf: Vrf, +} + +impl PersonhoodSigner for WalletPersonhoodSigner<'_> { + fn member( + &self, + collection: PersonhoodCollection, + ) -> Result<[u8; 32], StatementAllowanceError> { + self.wallet.with_keys(self.session, |keys| { + Ok(self + .vrf + .member(&keys.personhood_entropy(collection)) + .map_err(proof::vrf_error)?) + }) + } + + fn alias( + &self, + collection: PersonhoodCollection, + context: &[u8], + ) -> Result<[u8; 32], StatementAllowanceError> { + self.wallet.with_keys(self.session, |keys| { + Ok(self + .vrf + .alias(&keys.personhood_entropy(collection), context) + .map_err(proof::vrf_error)?) + }) + } + + fn prove( + &self, + ring: &RingParams, + context: &[u8], + message: &[u8], + ) -> Result, StatementAllowanceError> { + self.wallet.with_keys(self.session, |keys| { + proof::ring_vrf_proof( + &self.vrf, + proof::domain_for_ring_exponent(ring.exponent)?, + &keys.personhood_entropy(ring.collection), + &ring.members, + context, + message, + ) + }) + } +} + +/// Validated activation material, installed only after host grants are invalidated. +pub struct PreparedWalletActivation { + keys: WalletKeys, + session: SessionInfo, +} + +impl WalletAccountHolder { + /// Inject a ring resolver for account-operation tests. + #[cfg(test)] + pub fn new_with_ring_resolver( + services: Arc, + network_suffix: String, + consent: Arc, + ring_resolver: Arc, + ring_vrf_registry: Arc, + ) -> WalletAccountHolder { + WalletAccountHolder { + ring_resolver, + ..WalletAccountHolder::new(services, network_suffix, consent, ring_vrf_registry) + } + } + + /// An unlocked wallet over `platform`, for allowance and renewal tests. + #[cfg(test)] + fn for_tests( + platform: crate::test_support::StubPlatform, + network_suffix: &str, + entropy: u8, + ) -> Self { + let services = crate::runtime::RuntimeServices::new( + Arc::new(platform), + crate::platform::HostInfo { + name: "wallet test".to_string(), + icon: None, + version: None, + platform: truapi::latest::HostPlatform::Unknown, + }, + [0; 32], + [0; 32], + [0; 32], + crate::test_support::test_spawner(), + ); + let registry = + crate::runtime::ring_vrf_registry::RingVrfRegistryStore::new(services.platform.clone()); + let consent = Arc::new(ProductConsent::new(services.platform.clone())); + let wallet = Self::new(services, network_suffix.to_string(), consent, registry); + wallet.install(wallet.prepare_activation(vec![entropy; 32], None).unwrap()); + wallet + } + + /// Start locked, with no wallet secrets, asking `consent` before a product uses another + /// product's ring-VRF keys. + pub fn new( + services: Arc, + network_suffix: String, + consent: Arc, + ring_vrf_registry: Arc, + ) -> Self { + Self { + ring_resolver: super::ring_vrf::ChainRingResolver::new(services.chain.clone()), + ring_vrf_registry, + services, + renewal: allowance_renewal::RenewalState::default(), + #[cfg(feature = "test-host")] + resource_controls: Arc::new( + crate::runtime::test_resource_controls::TestResourceControls::default(), + ), + network_suffix, + consent, + lifecycle: Mutex::new(WalletState::default()), + session_state: SessionState::new(), + } + } + + fn with_keys>( + &self, + session: &AuthoritySession, + use_keys: impl FnOnce(&WalletKeys) -> Result, + ) -> Result { + let state = self.lifecycle.lock().expect("wallet lifecycle mutex poisoned"); + state.require_session(self.session_state.current(), session)?; + use_keys(state.keys.as_ref().ok_or(AuthorityError::Disconnected)?) + } + + async fn personhood_signer<'a>( + &'a self, + session: &'a AuthoritySession, + ) -> Result, StatementAllowanceError> { + let vrf = vrf::load().await.map_err(proof::vrf_error)?; + self.require_current_session(session)?; + Ok(WalletPersonhoodSigner { wallet: self, session, vrf }) + } +} + +impl WalletAccountHolder { + /// Shared synthetic controls for the wallet and its native host. + #[cfg(feature = "test-host")] + pub fn resource_controls( + &self, + ) -> &Arc { + &self.resource_controls + } + + /// Connection-status subscriptions for the active wallet. + pub fn session_state(&self) -> Arc { + self.session_state.clone() + } + + /// Validate and derive activation material without changing the active wallet. + pub fn prepare_activation( + &self, + secret: Vec, + lite_username: Option, + ) -> Result { + let keys = WalletKeys::new(secret, self.network_suffix.clone()); + let public_key = keys.root_public_key().map_err(product_authority_error)?; + let identity_account_id = keys.identity_keypair()?.public.to_bytes(); + let identity_chat_private_key = derive_identity_chat_private_key(&keys.entropy); + Ok(PreparedWalletActivation { + keys, + session: SessionInfo { + public_key, + sso: None, + root_entropy_source: None, + identity_account_id: Some(identity_account_id), + identity_chat_private_key: Some(identity_chat_private_key), + device_enc_public_key: None, + lite_username, + full_username: None, + }, + }) + } + + /// Install under the host's grant lock so session and grant changes are atomic. + pub fn install(&self, activation: PreparedWalletActivation) -> SessionInfo { + let mut state = self.lifecycle.lock().expect("wallet lifecycle mutex poisoned"); + state.advance(); + state.keys = Some(activation.keys); + self.session_state.set_session(activation.session.clone()); + activation.session + } + + /// Clear under the host's grant lock, dropping the active wallet secrets. + pub fn clear(&self) { + let mut state = self.lifecycle.lock().expect("wallet lifecycle mutex poisoned"); + state.advance(); + state.keys.take(); + self.session_state.clear_session(); + } +} + +struct WalletKeys { + entropy: Zeroizing>, + network_suffix: String, +} + +impl WalletKeys { + fn new(entropy: Vec, network_suffix: String) -> Self { + Self { entropy: Zeroizing::new(entropy), network_suffix } + } + + fn root_public_key(&self) -> Result<[u8; 32], ProductAccountError> { + derive_root_keypair_from_entropy(&self.entropy).map(|root| root.public.to_bytes()) + } + + fn product_subtree_public_key(&self, product_id: &str) -> Result<[u8; 32], AuthorityError> { + let root = + derive_root_keypair_from_entropy(&self.entropy).map_err(product_authority_error)?; + derive_product_subtree_keypair(&root, product_id) + .map(|keypair| keypair.public.to_bytes()) + .map_err(product_authority_error) + } + + fn product_subtree_secret(&self, product_id: &str) -> Result<[u8; 64], AuthorityError> { + let root = + derive_root_keypair_from_entropy(&self.entropy).map_err(product_authority_error)?; + let product_id = normalize_product_identifier(product_id) + .map_err(|err| AuthorityError::Unavailable { reason: err.to_string() })?; + derive_product_subtree_keypair(&root, &product_id) + .map(|keypair| keypair.secret.to_bytes()) + .map_err(product_authority_error) + } + + fn product_keypair( + &self, + account: &ProductAccountId, + ) -> Result { + let root = + derive_root_keypair_from_entropy(&self.entropy).map_err(product_authority_error)?; + let product_id = normalize_product_identifier(&account.dot_ns_identifier) + .map_err(|err| AuthorityError::Unavailable { reason: err.to_string() })?; + derive_product_keypair( + &root, + &product_id, + derivation_index_bytes(&account.derivation_index), + ) + .map_err(product_authority_error) + } + + fn identity_keypair(&self) -> Result { + derive_identity_keypair(&self.entropy, &self.network_suffix) + .map_err(product_authority_error) + } + + fn ring_vrf_entropy( + &self, + handle: &ProductAccountId, + ) -> Result, RingVrfError> { + derive_ring_vrf_entropy(&self.entropy, &handle.dot_ns_identifier, &handle.derivation_index) + .map(Zeroizing::new) + .map_err(|err| RingVrfError::Unknown { reason: err.to_string() }) + } + + fn ring_vrf_domain_entropy(&self, product_id: &str) -> Result<[u8; 32], ProductAccountError> { + derive_ring_vrf_domain_entropy(&self.entropy, product_id) + } + + fn personhood_entropy(&self, collection: PersonhoodCollection) -> Zeroizing<[u8; 32]> { + Zeroizing::new(match collection { + PersonhoodCollection::People => { + derive_full_person_ring_vrf_entropy(&self.entropy, &self.network_suffix) + }, + PersonhoodCollection::LitePeople => { + derive_lite_person_ring_vrf_entropy(&self.entropy, &self.network_suffix) + }, + }) + } + + /// RFC-0007 product-scoped entropy. + fn derive_entropy(&self, product_id: &str, context: &[u8]) -> Result<[u8; 32], AuthorityError> { + derive_product_entropy(&self.entropy, product_id, context) + .map_err(|err| AuthorityError::Unknown { reason: err.to_string() }) + } + + fn contact_handles(&self) -> crate::runtime::contacts::ContactHandles { + crate::runtime::contacts::ContactHandles::from_root_entropy_source( + &self.root_entropy_source(), + ) + } + + /// Purpose-limited entropy shared with a paired host. + fn root_entropy_source(&self) -> [u8; 32] { + root_entropy_source(&self.entropy) + } + + fn statement_allowance_key( + &self, + product_id: &str, + ) -> Result { + derive_sr25519_hard_path(&self.entropy, &["allowance", "statement-store", product_id]) + } + + fn bulletin_allowance_key( + &self, + product_id: &str, + ) -> Result { + derive_sr25519_hard_path(&self.entropy, &["allowance", "bulletin", product_id]) + } + + fn responder_identity(&self) -> Result<(ResponderIdentity, [u8; 32]), ProductAccountError> { + let statement = derive_identity_keypair(&self.entropy, &self.network_suffix)?; + let (encryption_secret_key, encryption_public_key) = + derive_x25519_keypair_from_entropy(&self.entropy, SSO_ENCRYPTION_DOMAIN); + let identity_chat_private_key = derive_identity_chat_private_key(&self.entropy); + Ok(( + ResponderIdentity { + statement_secret: statement.secret.to_bytes(), + statement_public_key: statement.public.to_bytes(), + encryption_secret_key, + encryption_public_key, + }, + identity_chat_private_key, + )) + } +} + +/// Map unavailable wallet derivations to the account-operation error contract. +fn product_authority_error(err: ProductAccountError) -> AuthorityError { + AuthorityError::Unavailable { reason: err.to_string() } +} diff --git a/rust/crates/truapi/src/runtime/signing_host/wallet_account_holder/account.rs b/rust/crates/truapi/src/runtime/signing_host/wallet_account_holder/account.rs new file mode 100644 index 0000000000..c39e6b8ff9 --- /dev/null +++ b/rust/crates/truapi/src/runtime/signing_host/wallet_account_holder/account.rs @@ -0,0 +1,771 @@ +//! Wallet account execution and access to other products' keys. + +use super::{AllowanceAllocationError, WalletAccountHolder, product_authority_error}; +use crate::{ + host_internal::{ + extrinsic::{Sr25519Signer, build_signed_transaction, local_transaction_metadata}, + sso_messages::{OnExistingAllowancePolicy, RingVrfError}, + transaction::sign_extrinsic_payload, + }, + host_logic::{ + features::genesis_for, + product_account::{SR25519_SIGNING_CONTEXT, personhood_product_id}, + raw_signing::raw_payload_bytes, + }, + platform::{PermissionAuthorizationStatus, normalize_product_identifier}, + runtime::{ + allowances::AllowanceResource, + authority::{ + AccountCaller, AccountGrant, AccountGrantOutcome, AccountHolder, AccountInvocation, + AuthorityError, AuthoritySession, AutoSigningKey, BulletinAllowanceKey, + CreateTransactionAuthorityRequest, GrantKeeping, SignPayloadAuthorityRequest, + SignRawAuthorityRequest, StatementStoreAllowanceKey, SubtreeResolution, + }, + remote_authority_call, remote_authority_context, + signing_host::ring_vrf::{MemberCandidate, context_bytes, create_proof}, + statement_allowance::collection::PersonhoodCollection, + until_cancelled, + vrf::{self, Vrf}, + }, +}; +use futures::{ + StreamExt, + stream::{self, BoxStream}, +}; +use truapi::{ + latest as api, + latest::{ + ChainIdentifier, DerivationIndex, HostAccountCreateProofRequest, + HostAccountGetAliasRequest, HostAccountListRingVrfKeysRequest, + HostAccountRegisterRingVrfKeyRequest, HostAccountRingVrfSignRequest, ProductAccountId, + RingLocation, RingLocationJunction, + }, +}; + +impl WalletAccountHolder { + fn caller_key_handle( + invocation: &AccountInvocation<'_>, + index: DerivationIndex, + ) -> Result { + let owner = invocation.caller.product_id().ok_or(RingVrfError::NotAllowlisted)?; + Ok(ProductAccountId { + dot_ns_identifier: normalize_product_identifier(owner) + .map_err(|err| RingVrfError::Unknown { reason: err.to_string() })?, + derivation_index: index, + }) + } + + async fn derive_ring_vrf_member( + &self, + session: &AuthoritySession, + handle: &ProductAccountId, + ) -> Result<[u8; 32], RingVrfError> { + let vrf = vrf::load().await?; + self.with_keys(session, |keys| vrf.member(&*keys.ring_vrf_entropy(handle)?)) + } + + async fn record_registration( + &self, + session: &AuthoritySession, + handle: ProductAccountId, + ring: RingLocation, + public_key: [u8; 32], + ) -> Result<(), RingVrfError> { + let mut update = self.ring_vrf_registry.prepare_update(session.public_key).await?; + self.require_current_session(session)?; + update.register(handle, ring, public_key)?; + update.persist().await?; + self.require_current_session(session)?; + Ok(()) + } + + /// Derive the product's hard-subtree public key from the active session root. + /// Returns `None` when no session is active. + pub fn derive_subtree_public_key( + &self, + product_id: &str, + ) -> Result, AuthorityError> { + let product_id = normalize_product_identifier(product_id) + .map_err(|err| AuthorityError::Unavailable { reason: err.to_string() })?; + let Some(session) = self.current_session() else { + return Ok(None); + }; + self.with_keys(&session, |keys| keys.product_subtree_public_key(&product_id)) + .map(Some) + } +} + +impl WalletAccountHolder { + fn transaction_keypair( + keys: &super::WalletKeys, + request: &CreateTransactionAuthorityRequest, + ) -> Result { + match request { + CreateTransactionAuthorityRequest::Product(payload) => { + keys.product_keypair(&payload.signer) + }, + CreateTransactionAuthorityRequest::LegacyAccount { product_account, request } => { + let keypair = keys.product_keypair(product_account)?; + if keypair.public.to_bytes() != request.signer { + return Err(AuthorityError::Unknown { reason: "signing host: legacy signer does not match the product slot-zero account".to_string() }); + } + Ok(keypair) + }, + CreateTransactionAuthorityRequest::IdentityAccount(request) => { + let keypair = keys.identity_keypair()?; + if keypair.public.to_bytes() != request.signer { + return Err(AuthorityError::Unavailable { reason: "signing host: the requested identity account is not available in this CLI wallet".to_string() }); + } + Ok(keypair) + }, + } + } + + async fn register_builtin_personhood_keys_if_needed( + &self, + session: &AuthoritySession, + owner: &str, + ) -> Result<(), RingVrfError> { + if owner != personhood_product_id(&self.network_suffix) { + return Ok(()); + } + let chains = self + .services + .platform + .supported_chains() + .await + .map_err(|error| RingVrfError::Unknown { reason: error.reason })?; + let chain_id = + genesis_for(&chains, ChainIdentifier::People).ok_or(RingVrfError::RingNotFound)?; + let entries = self.ring_vrf_registry.owner_entries(session.public_key, owner).await?; + let missing = [(PersonhoodCollection::People, 0), (PersonhoodCollection::LitePeople, 1)] + .into_iter() + .filter(|(collection, index)| { + !entries.iter().any(|entry| { + entry.handle.derivation_index == DerivationIndex::Index(*index) && + entry.rings.iter().any(|ring| { + ring.chain_id == chain_id && + matches!( + ring.junctions.as_slice(), + [RingLocationJunction::PalletInstance(_), RingLocationJunction::CollectionId(identifier)] + if identifier.as_slice() == collection.identifier() + ) + }) + }) + }) + .collect::>(); + if missing.is_empty() { + self.require_current_session(session)?; + return Ok(()); + } + let pallet_index = self.ring_resolver.members_pallet_index(&chain_id).await?; + let vrf = vrf::load().await?; + for (collection, index) in missing { + let handle = ProductAccountId { + dot_ns_identifier: owner.to_string(), + derivation_index: DerivationIndex::Index(index), + }; + let public_key = + self.with_keys(session, |keys| vrf.member(&*keys.ring_vrf_entropy(&handle)?))?; + let ring = RingLocation { + chain_id, + junctions: vec![ + RingLocationJunction::PalletInstance(pallet_index), + RingLocationJunction::CollectionId(collection.identifier().to_vec()), + ], + }; + let mut update = self.ring_vrf_registry.prepare_update(session.public_key).await?; + self.require_current_session(session)?; + update.register(handle, ring, public_key)?; + update.persist().await?; + self.require_current_session(session)?; + } + Ok(()) + } + + async fn registered_ring_vrf_entry( + &self, + session: &AuthoritySession, + handle: &api::ProductAccountId, + ) -> Result, RingVrfError> { + self.require_current_session(session)?; + let entry = self.ring_vrf_registry.entry(session.public_key, handle).await?; + self.require_current_session(session)?; + Ok(entry) + } + + async fn registered_ring_vrf_entry_for_ring( + &self, + session: &AuthoritySession, + handle: &api::ProductAccountId, + ring: &api::RingLocation, + ) -> Result { + let entry = self + .registered_ring_vrf_entry(session, handle) + .await? + .ok_or(RingVrfError::KeyNotRegistered)?; + if !entry.rings.contains(ring) { + return Err(RingVrfError::KeyNotInRing); + } + Ok(entry) + } + + fn require_matching_registered_public_key( + vrf: &Vrf, + entry: &api::RegisteredRingVrfKey, + entropy: &[u8; 32], + ) -> Result<(), RingVrfError> { + if entry.public_key != Some(vrf.member(entropy)?) { + return Err(RingVrfError::Unknown { + reason: "registered ring-VRF public key does not match the active wallet" + .to_string(), + }); + } + Ok(()) + } + + /// Keep access checks and key derivation bound to the same canonical owner. + async fn require_ring_vrf_key_access( + &self, + caller: AccountCaller<'_>, + handle: &api::ProductAccountId, + ) -> Result< + (api::ProductAccountId, crate::runtime::product_manifest::AuthorizedAccess), + RingVrfError, + > { + let access = crate::runtime::product_manifest::ring_vrf_key_access_granted( + &self.services, + self.services.platform.as_ref(), + caller.product_id().ok_or(RingVrfError::Rejected)?, + handle, + ) + .await?; + Ok(( + api::ProductAccountId { + dot_ns_identifier: access.owner.clone(), + derivation_index: handle.derivation_index.clone(), + }, + access, + )) + } + + async fn require_account_access( + &self, + invocation: &AccountInvocation<'_>, + requester: &str, + owner: &str, + ) -> Result<(), RingVrfError> { + let status = + until_cancelled(invocation.call, self.consent.account_access(requester, owner)) + .await? + .map_err(|error| RingVrfError::Unknown { reason: error.to_string() })?; + if status == PermissionAuthorizationStatus::Authorized { + Ok(()) + } else { + Err(RingVrfError::Rejected) + } + } +} + +#[async_trait::async_trait] +impl AccountHolder for WalletAccountHolder { + const GRANT_KEEPING: GrantKeeping = GrantKeeping::Wallet; + + fn current_session(&self) -> Option { + let state = self.lifecycle.lock().expect("wallet lifecycle mutex poisoned"); + let session = self.session_state.current()?; + state.keys.as_ref()?; + Some(state.session(&session)) + } + + fn require_current_session(&self, session: &AuthoritySession) -> Result<(), AuthorityError> { + self.lifecycle + .lock() + .expect("wallet lifecycle mutex poisoned") + .require_session(self.session_state.current(), session) + .map(|_| ()) + } + + async fn allocate_grants<'a>( + &'a self, + invocation: AccountInvocation<'a>, + request: api::HostRequestResourceAllocationRequest, + policy: OnExistingAllowancePolicy, + ) -> Result>, AuthorityError> { + let caller = invocation.caller.product_id().ok_or(AuthorityError::Rejected)?; + self.require_current_session(invocation.session)?; + let product_id = caller.to_string(); + Ok(stream::unfold( + (request.resources.into_iter(), product_id, invocation), + move |(mut resources, product_id, invocation)| async move { + let resource = resources.next()?; + let outcome = async { + self.require_current_session(invocation.session)?; + if let Some(reason) = invocation.call.cancel().reason() { + return Err(crate::runtime::authority_cancellation_error( + invocation.call, + reason, + ) + .into()); + } + #[cfg(feature = "test-host")] + self.resource_controls.refuse_withheld(&resource)?; + let product_id = product_id.as_str(); + let grant = match resource { + api::AllocatableResource::StatementStoreAllowance => { + let allocation = self + .allocate_statement_store_allowance( + invocation.session, + product_id, + policy, + ) + .await?; + AccountGrant::StatementStore { + key: StatementStoreAllowanceKey::from_secret_bytes( + allocation.secret, + )?, + period: Some(allocation.period), + } + }, + api::AllocatableResource::BulletinAllowance => { + AccountGrant::Bulletin(BulletinAllowanceKey::from_secret_bytes( + self.allocate_bulletin_allowance( + invocation.session, + product_id, + policy, + ) + .await?, + )?) + }, + api::AllocatableResource::SmartContractAllowance(index) => { + self.allocate_smart_contract_allowance( + invocation.session, + product_id, + index, + policy, + ) + .await?; + AccountGrant::SmartContract + }, + api::AllocatableResource::AutoSigning => self + .with_keys::<_, AuthorityError>(invocation.session, |keys| { + Ok(AccountGrant::AutoSigning(AutoSigningKey::from_parts( + keys.product_subtree_secret(product_id)?, + keys.ring_vrf_domain_entropy(product_id) + .map_err(product_authority_error)?, + ))) + })?, + }; + Ok(grant) + } + .await; + let outcome = self + .require_current_session(invocation.session) + .map_err(AllowanceAllocationError::from) + .and(outcome); + let outcome = match outcome { + Ok(grant) => Ok(AccountGrantOutcome::Allocated(grant)), + Err(AllowanceAllocationError::Authority( + error @ (AuthorityError::Disconnected | AuthorityError::Cancelled(_)), + )) => Err(error), + Err(AllowanceAllocationError::Authority(AuthorityError::Rejected)) => { + Ok(AccountGrantOutcome::Rejected) + }, + Err(error) => { + Ok(AccountGrantOutcome::NotAvailable { reason: Some(error.to_string()) }) + }, + }; + Some((outcome, (resources, product_id, invocation))) + }, + ) + .boxed()) + } + + async fn renew_statement_sponsorship( + &self, + invocation: AccountInvocation<'_>, + account_id: [u8; 32], + ) -> Result<(), AuthorityError> { + let AccountCaller::Local { product, .. } = invocation.caller else { + return Err(AuthorityError::Rejected); + }; + self.require_current_session(invocation.session)?; + #[cfg(feature = "test-host")] + self.resource_controls + .refuse_withheld(&api::AllocatableResource::StatementStoreAllowance)?; + until_cancelled( + invocation.call, + self.ensure_statement_slot( + invocation.session, + &product.product_id, + account_id, + OnExistingAllowancePolicy::Ignore, + ), + ) + .await? + .map_err(AllowanceAllocationError::into_authority_error)?; + self.require_current_session(invocation.session) + } + + async fn ensure_allowance( + &self, + invocation: AccountInvocation<'_>, + resource: AllowanceResource, + policy: OnExistingAllowancePolicy, + ) -> Result { + let resource = match resource { + AllowanceResource::StatementStore => api::AllocatableResource::StatementStoreAllowance, + AllowanceResource::Bulletin => api::AllocatableResource::BulletinAllowance, + }; + let mut grants = self + .allocate_grants( + invocation, + api::HostRequestResourceAllocationRequest { resources: vec![resource] }, + policy, + ) + .await?; + match grants.next().await.transpose()? { + Some(AccountGrantOutcome::Allocated(grant)) => Ok(grant), + Some(AccountGrantOutcome::NotAvailable { reason }) => { + Err(AuthorityError::Unavailable { + reason: reason.unwrap_or_else(|| "Allowance unavailable".to_string()), + }) + }, + Some(AccountGrantOutcome::Rejected) | None => Err(AuthorityError::Rejected), + } + } + + async fn product_subtree_public_key<'a>( + &'a self, + invocation: AccountInvocation<'a>, + product_id: String, + ) -> Result, AuthorityError> { + self.require_current_session(invocation.session)?; + let product_id = normalize_product_identifier(&product_id) + .map_err(|err| AuthorityError::Unavailable { reason: err.to_string() })?; + self.with_keys(invocation.session, |keys| keys.product_subtree_public_key(&product_id)) + .map(SubtreeResolution::Known) + } + + async fn sign_vrf( + &self, + invocation: AccountInvocation<'_>, + request: api::HostAccountSignVrfRequest, + ) -> Result { + self.with_keys(invocation.session, |keys| { + let keypair = keys.product_keypair(&request.account)?; + let (pre_output, proof) = crate::dynamic_vrf::sign_dynamic_vrf( + &keypair, + &request.transcript_label, + request.items.iter().map(|item| (item.label.as_slice(), item.value.as_slice())), + ); + Ok(api::VrfSignature { pre_output, proof }) + }) + } + + async fn sign_payload( + &self, + invocation: AccountInvocation<'_>, + request: SignPayloadAuthorityRequest, + ) -> Result { + self.require_current_session(invocation.session)?; + let cx = remote_authority_context(invocation.call); + remote_authority_call(&cx, async { + self.with_keys(invocation.session, |keys| { + let (account, payload) = match request { + SignPayloadAuthorityRequest::Product(request) => { + (request.account, request.payload) + }, + SignPayloadAuthorityRequest::LegacyAccount { product_account, request } => { + (product_account, request.payload) + }, + }; + Ok(sign_extrinsic_payload(&keys.product_keypair(&account)?, payload)?) + }) + }) + .await + } + + async fn sign_raw( + &self, + invocation: AccountInvocation<'_>, + request: SignRawAuthorityRequest, + watermarked: bool, + ) -> Result { + self.require_current_session(invocation.session)?; + let cx = remote_authority_context(invocation.call); + remote_authority_call(&cx, async { + self.with_keys(invocation.session, |keys| { + let (keypair, payload) = match request { + SignRawAuthorityRequest::Product(request) => (keys.product_keypair(&request.account)?, request.payload), + SignRawAuthorityRequest::LegacyAccount { product_account, request } => (keys.product_keypair(&product_account)?, request.payload), + SignRawAuthorityRequest::IdentityAccount { account, request } => { + let keypair = keys.identity_keypair()?; + if keypair.public.to_bytes() != account { + return Err(AuthorityError::Unavailable { reason: "signing host: the requested legacy account is not available in this CLI wallet".to_string() }); + } + (keypair, request.payload) + } + }; + let message = raw_payload_bytes(payload, watermarked)?; + let signature = keypair.secret.sign_simple(SR25519_SIGNING_CONTEXT, &message, &keypair.public).to_bytes(); + Ok(api::HostSignPayloadResponse { signature: signature.to_vec(), signed_transaction: None }) + }) + }).await + } + + async fn create_transaction( + &self, + invocation: AccountInvocation<'_>, + request: CreateTransactionAuthorityRequest, + ) -> Result { + self.require_current_session(invocation.session)?; + let cx = remote_authority_context(invocation.call); + remote_authority_call(&cx, async { + self.with_keys(invocation.session, |keys| { + Self::transaction_keypair(keys, &request).map(|_| ()) + })?; + let (genesis_hash, call_data, extensions, tx_ext_version) = match &request { + CreateTransactionAuthorityRequest::Product(payload) => ( + payload.genesis_hash, + &payload.call_data, + &payload.extensions, + payload.tx_ext_version, + ), + CreateTransactionAuthorityRequest::LegacyAccount { request, .. } | + CreateTransactionAuthorityRequest::IdentityAccount(request) => ( + request.genesis_hash, + &request.call_data, + &request.extensions, + request.tx_ext_version, + ), + }; + let metadata = local_transaction_metadata(&self.services.chain, genesis_hash).await?; + self.with_keys(invocation.session, |keys| { + let keypair = Self::transaction_keypair(keys, &request)?; + Ok(api::HostCreateTransactionResponse { + transaction: build_signed_transaction( + &Sr25519Signer::from_keypair(&keypair), + genesis_hash, + call_data, + extensions, + tx_ext_version, + metadata, + )?, + }) + }) + }) + .await + } + + async fn account_alias( + &self, + invocation: AccountInvocation<'_>, + request: HostAccountGetAliasRequest, + ) -> Result { + let session = invocation.session; + self.require_current_session(session)?; + // Aliases expose the same identity as `create_proof`, so both must enforce + // the same RFC-0024 grant and context restrictions. + let granted = + match self.require_ring_vrf_key_access(invocation.caller, &request.key_handle).await { + Ok(granted) => Some(granted), + Err(RingVrfError::NotAllowlisted) => None, + Err(err) => return Err(err), + }; + let key_handle = match granted { + Some((key_handle, access)) => { + crate::runtime::product_manifest::require_context_access( + &self.services, + &access, + &request.context, + ) + .await?; + key_handle + }, + None => { + // Permission lookups and prompts must use the same canonical identifiers. + let requester = normalize_product_identifier( + invocation.caller.product_id().ok_or(RingVrfError::NotAllowlisted)?, + ) + .map_err(|_| RingVrfError::NotAllowlisted)?; + let owner = normalize_product_identifier(&request.key_handle.dot_ns_identifier) + .map_err(|_| RingVrfError::NotAllowlisted)?; + self.require_account_access(&invocation, &requester, &owner).await?; + api::ProductAccountId { + dot_ns_identifier: owner, + derivation_index: request.key_handle.derivation_index.clone(), + } + }, + }; + let vrf = vrf::load().await?; + let entry = self + .registered_ring_vrf_entry_for_ring(session, &key_handle, &request.ring_location) + .await?; + self.ring_resolver.validate(&request.ring_location).await?; + let context = context_bytes(&request.context); + self.with_keys(session, |keys| { + let entropy = keys.ring_vrf_entropy(&key_handle)?; + Self::require_matching_registered_public_key(&vrf, &entry, &entropy)?; + let alias = vrf.alias(&entropy, &context)?; + Ok(api::ContextualAlias { context, alias: alias.to_vec() }) + }) + } + + async fn create_proof( + &self, + invocation: AccountInvocation<'_>, + request: HostAccountCreateProofRequest, + ) -> Result { + let session = invocation.session; + self.require_current_session(session)?; + let (key_handle, access) = + self.require_ring_vrf_key_access(invocation.caller, &request.key_handle).await?; + // A grant cannot expose the owner's alias in a third product's context unless + // that product's manifest grants the caller `context`. + crate::runtime::product_manifest::require_context_access( + &self.services, + &access, + &request.context, + ) + .await?; + let vrf = vrf::load().await?; + let entry = self + .registered_ring_vrf_entry_for_ring(session, &key_handle, &request.ring_location) + .await?; + let candidate = self.with_keys(session, |keys| { + let entropy = keys.ring_vrf_entropy(&key_handle)?; + Self::require_matching_registered_public_key(&vrf, &entry, &entropy)?; + Ok::<_, RingVrfError>(MemberCandidate { member: vrf.member(&entropy)? }) + })?; + let resolved = self.ring_resolver.resolve(&request.ring_location, &[candidate]).await?; + self.with_keys(session, |keys| { + let entropy = keys.ring_vrf_entropy(&key_handle)?; + let context = context_bytes(&request.context); + let (proof, alias) = + create_proof(&vrf, &entropy, &resolved, &context, &request.message)?; + Ok(api::HostAccountCreateProofResponse { + proof, + contextual_alias: api::ContextualAlias { context, alias: alias.to_vec() }, + ring_index: resolved.ring_index, + ring_revision: resolved.ring_revision, + }) + }) + } + + async fn register_ring_vrf_key( + &self, + invocation: AccountInvocation<'_>, + request: HostAccountRegisterRingVrfKeyRequest, + ) -> Result<[u8; 32], RingVrfError> { + self.require_current_session(invocation.session)?; + self.ring_resolver.validate(&request.ring).await?; + let handle = Self::caller_key_handle(&invocation, request.index)?; + let public_key = self.derive_ring_vrf_member(invocation.session, &handle).await?; + self.record_registration(invocation.session, handle, request.ring, public_key) + .await?; + Ok(public_key) + } + + async fn record_ring_vrf_key( + &self, + invocation: AccountInvocation<'_>, + request: HostAccountRegisterRingVrfKeyRequest, + public_key: [u8; 32], + ) -> Result<(), RingVrfError> { + self.require_current_session(invocation.session)?; + let handle = Self::caller_key_handle(&invocation, request.index)?; + if self.derive_ring_vrf_member(invocation.session, &handle).await? != public_key { + return Err(RingVrfError::Unknown { + reason: "the kept AutoSigning key derives a different ring-VRF key".to_string(), + }); + } + self.record_registration(invocation.session, handle, request.ring, public_key) + .await + } + + async fn list_ring_vrf_keys( + &self, + invocation: AccountInvocation<'_>, + request: HostAccountListRingVrfKeysRequest, + ) -> Result, RingVrfError> { + let session = invocation.session; + self.require_current_session(session)?; + let owner = normalize_product_identifier(&request.owner) + .map_err(|err| RingVrfError::Unknown { reason: err.to_string() })?; + // Ownership checks and permission decisions must use canonical identifiers. + let caller = normalize_product_identifier( + invocation.caller.product_id().ok_or(RingVrfError::NotAllowlisted)?, + ) + .map_err(|_| RingVrfError::NotAllowlisted)?; + if caller != owner { + self.require_account_access(&invocation, &caller, &owner).await?; + } + + self.register_builtin_personhood_keys_if_needed(session, &owner).await?; + let mut entries = self.ring_vrf_registry.owner_entries(session.public_key, &owner).await?; + self.require_current_session(session)?; + if request.disclosure == api::RingVrfKeyDisclosure::Anonymized { + for entry in &mut entries { + entry.public_key = None; + } + } + Ok(entries) + } + + async fn ring_vrf_sign( + &self, + invocation: AccountInvocation<'_>, + request: HostAccountRingVrfSignRequest, + ) -> Result, RingVrfError> { + let session = invocation.session; + self.require_current_session(session)?; + let (key_handle, _access) = + self.require_ring_vrf_key_access(invocation.caller, &request.key_handle).await?; + let vrf = vrf::load().await?; + let entry = self + .registered_ring_vrf_entry(session, &key_handle) + .await? + .ok_or(RingVrfError::KeyNotRegistered)?; + self.with_keys(session, |keys| { + let entropy = keys.ring_vrf_entropy(&key_handle)?; + Self::require_matching_registered_public_key(&vrf, &entry, &entropy)?; + vrf.sign(&entropy, &request.message) + }) + } + + async fn sign_statement_store_product_payload( + &self, + invocation: AccountInvocation<'_>, + account: api::ProductAccountId, + payload: Vec, + ) -> Result<[u8; 64], AuthorityError> { + let cx = remote_authority_context(invocation.call); + remote_authority_call(&cx, async { + self.with_keys(invocation.session, |keys| { + let keypair = keys.product_keypair(&account)?; + Ok(keypair + .secret + .sign_simple(SR25519_SIGNING_CONTEXT, &payload, &keypair.public) + .to_bytes()) + }) + }) + .await + } + + fn derive_entropy( + &self, + session: &AuthoritySession, + product_id: &str, + context: &[u8], + ) -> Result<[u8; 32], AuthorityError> { + self.with_keys(session, |keys| keys.derive_entropy(product_id, context)) + } + + fn contact_handles( + &self, + session: &AuthoritySession, + ) -> Result { + self.with_keys(session, |keys| Ok(keys.contact_handles())) + } +} diff --git a/rust/crates/truapi/src/runtime/signing_host/wallet_account_holder/allowance.rs b/rust/crates/truapi/src/runtime/signing_host/wallet_account_holder/allowance.rs new file mode 100644 index 0000000000..27877c0b3a --- /dev/null +++ b/rust/crates/truapi/src/runtime/signing_host/wallet_account_holder/allowance.rs @@ -0,0 +1,445 @@ +//! Wallet resource approval and allowance issuance. + +use crate::runtime::allowances::current_unix_secs; + +use super::WalletAccountHolder; +use crate::{ + chain_runtime::RuntimeFailure, + host_internal::sso_messages::OnExistingAllowancePolicy, + host_logic::product_account::ProductAccountError, + runtime::{ + authority::{AccountHolder, AuthorityError, AuthoritySession}, + statement_allowance::StatementAllowanceError, + statement_store_rpc::StatementStoreRpcClientError, + }, +}; +use tracing::{debug, warn}; +use truapi::latest as api; + +/// Leave the product runtime one minute to receive and process the SSO response +/// before its 300-second remote-authority deadline expires. +const BULLETIN_AUTHORIZATION_WAIT: std::time::Duration = std::time::Duration::from_secs(240); + +/// Failure while deriving or allocating a Statement Store/Bulletin allowance. +#[derive(Debug, thiserror::Error)] +pub enum AllowanceAllocationError { + /// Signing host session or authority state was unavailable. + #[error("{0}")] + Authority(#[from] AuthorityError), + /// The host serves no chain for this role, so there is nothing to claim on. + #[error("host serves no {chain} chain")] + ChainNotServed { + /// Role that could not be resolved. + chain: &'static str, + }, + /// Reading the host's chain set failed. + #[error("supported chains: {0}")] + SupportedChains(String), + /// Product-account key derivation failed. + #[error("{0}")] + ProductAccount(#[from] ProductAccountError), + /// Chain state, metadata, ring, slot, proof, or extrinsic allocation failed. + #[error("{0}")] + StatementAllowance(#[from] StatementAllowanceError), + /// Runtime service could not open the required Statement Store RPC client. + #[error("{0}")] + StatementStoreRpcClient(#[from] StatementStoreRpcClientError), + /// Runtime service could not open the required Bulletin RPC client. + #[error("{context}: {source}")] + ChainRpcClient { + /// Client context, naming which chain failed. + context: &'static str, + /// Chain runtime failure. + #[source] + source: RuntimeFailure, + }, + /// The signing account is not in any personhood ring. + #[error("signing account is not a personhood ring member; cannot grant {resource} allowance")] + MissingPersonhoodMembership { + /// Resource name. + resource: &'static str, + }, +} + +impl AllowanceAllocationError { + /// Preserve authority failures while reporting unavailable allowance work. + pub fn into_authority_error(self) -> AuthorityError { + match self { + Self::Authority(err) | + Self::StatementAllowance(StatementAllowanceError::Authority(err)) => err, + other => AuthorityError::Unavailable { reason: other.to_string() }, + } + } +} + +/// A product's statement-store allowance key, and the period it holds a slot in. +pub struct StatementStoreAllocation { + /// sr25519 secret of the product's allowance account. + pub secret: Vec, + /// Allowance period the slot was found or claimed in. + pub period: u32, +} + +impl WalletAccountHolder { + /// Issue a statement allowance with the period actually found or registered. + pub async fn allocate_statement_store_allowance( + &self, + session: &AuthoritySession, + product_id: &str, + policy: OnExistingAllowancePolicy, + ) -> Result { + let target = self.with_keys::<_, AllowanceAllocationError>(session, |keys| { + Ok(keys.statement_allowance_key(product_id)?.public.to_bytes()) + })?; + let period = self.ensure_statement_slot(session, product_id, target, policy).await?; + self.with_keys(session, |keys| { + Ok(StatementStoreAllocation { + secret: keys.statement_allowance_key(product_id)?.secret.to_bytes().to_vec(), + period, + }) + }) + } + + /// Renew an authorized sponsorship without disclosing another key. + pub async fn ensure_statement_slot( + &self, + session: &AuthoritySession, + product_id: &str, + target: [u8; 32], + policy: OnExistingAllowancePolicy, + ) -> Result { + use super::allowance_renewal::StatementRenewalTarget; + use crate::runtime::statement_allowance::{ + self, CollectionScanParams, PooledRegistrationParams, allocated_in, + collection::PersonhoodCollection, find_including_rings, + register_statement_account_pooled, scan_collections, + }; + + #[cfg(feature = "test-host")] + if self.resource_controls.grants_allowances_unchecked() { + return Ok(statement_allowance::slot::current_period(current_unix_secs()?)); + } + let client = + self.services.statement_store.chain_client("statement-store allowance").await?; + let rpc = client.rpc(); + let chain = self.services.chain_context.get(&client).await?; + let network_suffix = statement_allowance::slot::read_network_suffix(rpc).await?; + let period = statement_allowance::slot::current_period(current_unix_secs()?); + let reuse_existing = matches!(policy, OnExistingAllowancePolicy::Ignore); + + // Hold through scan and submission so renewal cannot select the same slot. + let _registration = self.renewal.registration_lock().lock().await; + + // Existing allocations need no proof or costly ring snapshot. + let signer = self.personhood_signer(session).await?; + let scans = scan_collections( + rpc, + &chain.metadata, + &signer, + &PersonhoodCollection::ALL, + CollectionScanParams { + network_suffix: &network_suffix, + period, + target: &target, + reuse_existing, + }, + ) + .await?; + if let Some((collection, seq)) = allocated_in(&scans) { + debug!( + %product_id, + period, + seq, + %collection, + "statement-store allowance already allocated" + ); + return Ok(period); + } + + // Every ring back to index 0, because a membership that stopped being + // re-included still proves against the ring that holds it. + let memberships = find_including_rings( + rpc, + &chain.metadata, + &signer, + &PersonhoodCollection::ALL, + u32::MAX, + ) + .await?; + if memberships.is_empty() { + return Err(AllowanceAllocationError::MissingPersonhoodMembership { + resource: "statement-store", + }); + } + self.require_current_session(session)?; + let outcome = register_statement_account_pooled( + rpc, + &chain.metadata, + &chain.state, + &signer, + &scans, + &memberships, + PooledRegistrationParams { + target: &target, + period, + network_suffix: &network_suffix, + reuse_existing, + // Only renewal may reclaim slots belonging to its own ledger. + allow_eviction: false, + protected: &[], + }, + ) + .await?; + match outcome { + statement_allowance::RegistrationOutcome::Registered { + block_hash, + seq, + ring_index, + collection, + } => { + debug!( + %product_id, + %block_hash, + seq, + ring_index, + %collection, + "registered statement-store allowance" + ); + }, + statement_allowance::RegistrationOutcome::AlreadyAllocated { seq, collection } => { + debug!( + %product_id, + seq, + %collection, + "statement-store allowance already allocated" + ); + }, + } + self.require_current_session(session)?; + if let Err(reason) = self + .track_statement_renewal_targets_for( + session, + vec![StatementRenewalTarget::ProductStatementAllowance { + product_id: product_id.to_string(), + }], + ) + .await + { + warn!(%product_id, %reason, "failed to record statement-store renewal target"); + } + Ok(period) + } + + /// Issue the product's Bulletin allowance key. + pub async fn allocate_bulletin_allowance( + &self, + session: &AuthoritySession, + product_id: &str, + policy: OnExistingAllowancePolicy, + ) -> Result, AllowanceAllocationError> { + use crate::runtime::statement_allowance::{ + self, claim_long_term_storage, collection::PersonhoodCollection, + fetch_bulletin_allowance, find_including_rings, wait_bulletin_authorization, + }; + + #[cfg(feature = "test-host")] + if self.resource_controls.grants_allowances_unchecked() { + return self.bulletin_secret(session, product_id); + } + let target = self.with_keys::<_, AllowanceAllocationError>(session, |keys| { + Ok(keys.bulletin_allowance_key(product_id)?.public.to_bytes()) + })?; + + let bulletin_rpc = statement_allowance::rpc::RpcClient::new( + self.services.bulletin.client("bulletin allowance").await.map_err(|source| { + AllowanceAllocationError::ChainRpcClient { + context: "bulletin allowance client", + source, + } + })?, + ); + let current_allowance = fetch_bulletin_allowance(&bulletin_rpc, &target).await?; + if matches!(policy, OnExistingAllowancePolicy::Ignore) && + current_allowance.is_some_and(|allowance| allowance.available()) + { + return self.bulletin_secret(session, product_id); + } + + let people_client = + self.services.statement_store.chain_client("bulletin allowance claim").await?; + let people_rpc = people_client.rpc(); + let chain = self.services.chain_context.get(&people_client).await?; + let network_suffix = statement_allowance::slot::read_network_suffix(people_rpc).await?; + let signer = self.personhood_signer(session).await?; + // Prefer light membership so switching collections cannot reset the person's budget. + let memberships = find_including_rings( + people_rpc, + &chain.metadata, + &signer, + &PersonhoodCollection::ALL, + u32::MAX, + ) + .await?; + let membership = memberships + .iter() + .find(|membership| membership.collection == PersonhoodCollection::LitePeople) + .or_else(|| memberships.first()) + .ok_or(AllowanceAllocationError::MissingPersonhoodMembership { + resource: "Bulletin", + })?; + let period_duration = + statement_allowance::slot::long_term_storage_period_duration(&chain.metadata)?; + let period = statement_allowance::slot::current_long_term_storage_period( + current_unix_secs()?, + period_duration, + )?; + self.require_current_session(session)?; + let outcome = claim_long_term_storage(statement_allowance::LongTermStorageClaim { + rpc: people_rpc, + metadata: &chain.metadata, + chain_state: &chain.state, + signer: &signer, + network_suffix: &network_suffix, + target: &target, + period, + ring: membership, + }) + .await?; + let statement_allowance::LongTermStorageOutcome::Claimed { + block_hash, + counter, + ring_index, + } = outcome; + debug!( + %product_id, + %block_hash, + counter, + ring_index, + "claimed Bulletin long-term storage allowance" + ); + + let authorization = wait_bulletin_authorization( + &bulletin_rpc, + &target, + current_allowance, + 1, + BULLETIN_AUTHORIZATION_WAIT, + ) + .await?; + debug!( + %product_id, + remained_size = authorization.remained_size, + remained_transactions = authorization.remained_transactions, + "Bulletin authorization visible" + ); + self.bulletin_secret(session, product_id) + } + + fn bulletin_secret( + &self, + session: &AuthoritySession, + product_id: &str, + ) -> Result, AllowanceAllocationError> { + self.with_keys(session, |keys| { + Ok(keys.bulletin_allowance_key(product_id)?.secret.to_bytes().to_vec()) + }) + } + + /// Fund the selected product account on the host's Asset Hub chain. + pub async fn allocate_smart_contract_allowance( + &self, + session: &AuthoritySession, + product_id: &str, + derivation_index: api::DerivationIndex, + policy: OnExistingAllowancePolicy, + ) -> Result<(), AllowanceAllocationError> { + use truapi::latest::ChainIdentifier; + + use crate::{ + host_logic::features, + runtime::statement_allowance::{ + self, ChainClient, collection::PersonhoodCollection, find_including_rings, pgas, + }, + }; + + let target = self.with_keys(session, |keys| { + keys.product_keypair(&api::ProductAccountId { + dot_ns_identifier: product_id.to_string(), + derivation_index, + }) + .map(|key| key.public.to_bytes()) + })?; + + let chains = features::supported_chains(self.services.platform.as_ref()) + .await + .map_err(|err| AllowanceAllocationError::SupportedChains(err.reason))?; + let asset_hub_genesis = features::genesis_for(&chains, ChainIdentifier::AssetHub) + .ok_or(AllowanceAllocationError::ChainNotServed { chain: "Asset Hub" })?; + let asset_hub_client = ChainClient::new( + statement_allowance::rpc::RpcClient::new(subxt_rpcs::RpcClient::new( + self.services + .chain + .rpc_client("PGAS allowance", &asset_hub_genesis) + .await + .map_err(|source| AllowanceAllocationError::ChainRpcClient { + context: "Asset Hub PGAS client", + source, + })?, + )), + asset_hub_genesis, + ); + let asset_hub = self.services.chain_context.get(&asset_hub_client).await?; + + // Reusing a funded account preserves the person's daily claim slots. + if matches!(policy, OnExistingAllowancePolicy::Ignore) && + pgas::holds_a_full_claim(asset_hub_client.rpc(), &asset_hub.metadata, &target) + .await? + { + debug!(%product_id, "PGAS allowance already funded; leaving it alone"); + return Ok(()); + } + let network_suffix = + statement_allowance::slot::read_network_suffix(asset_hub_client.rpc()).await?; + + let people_client = + self.services.statement_store.chain_client("PGAS allowance ring").await?; + let people_rpc = people_client.rpc(); + let people = self.services.chain_context.get(&people_client).await?; + + let signer = self.personhood_signer(session).await?; + // A PGAS claim uses the strongest available membership. + let membership = find_including_rings( + people_rpc, + &people.metadata, + &signer, + &PersonhoodCollection::ALL, + u32::MAX, + ) + .await? + .into_iter() + .next() + .ok_or(AllowanceAllocationError::MissingPersonhoodMembership { resource: "PGAS" })?; + + self.require_current_session(session)?; + let outcome = pgas::claim_pgas(pgas::PgasClaim { + asset_hub_rpc: asset_hub_client.rpc(), + asset_hub: &asset_hub, + people_rpc, + people_metadata: &people.metadata, + signer: &signer, + network_suffix: &network_suffix, + target: &target, + ring: &membership, + }) + .await?; + debug!( + %product_id, + day = outcome.day, + slot_index = outcome.slot_index, + ring_index = outcome.ring_index, + block = %outcome.block_hash, + "claimed PGAS allowance" + ); + Ok(()) + } +} diff --git a/rust/crates/truapi/src/runtime/signing_host/wallet_account_holder/allowance_renewal.rs b/rust/crates/truapi/src/runtime/signing_host/wallet_account_holder/allowance_renewal.rs new file mode 100644 index 0000000000..ec8737bf1f --- /dev/null +++ b/rust/crates/truapi/src/runtime/signing_host/wallet_account_holder/allowance_renewal.rs @@ -0,0 +1,1202 @@ +//! Wallet-owned allowance renewal ledger and chain operations. + +use crate::platform::{CoreStorage, CoreStorageKey, normalize_product_identifier}; +use futures::lock::Mutex; +use parity_scale_codec::{Decode, Encode}; +use tracing::{debug, info, warn}; + +use super::{WalletAccountHolder, WalletKeys}; +use crate::runtime::{ + allowances::current_unix_secs, + authority::{AccountHolder, AuthorityError, AuthoritySession}, + statement_allowance::{ + self, fetch_chain_state, fetch_metadata, find_including_rings, + renewal::{ + RenewalChainContext, ResolvedRenewalTarget, StatementRenewalReport, renew_targets, + }, + }, +}; + +/// A statement account or derivation recipe the wallet keeps renewed. +#[derive(Clone, Debug, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Enum))] +pub enum StatementRenewalTarget { + /// `//allowance//statement-store//{product_id}` from the active root entropy. + ProductStatementAllowance { + /// Product the allowance account belongs to. + product_id: String, + }, + /// `//wallet//sso` from the active root entropy. + WalletSso, + /// A fixed account, e.g. a pairing peer's device statement key. + Account { + /// Account to keep allowed. + account_id: [u8; 32], + /// Human-readable name used in logs and reports. + label: String, + }, +} + +/// Renewal recipes follow the active wallet; fixed accounts belong to their recorded owner. +#[derive(Clone, Debug, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Record))] +pub struct TrackedStatementRenewalTarget { + /// The account, or the recipe for one, that the host promised to renew. + pub target: StatementRenewalTarget, + /// Root public key that promised a raw account id. A recipe carries none + /// and resolves under whichever identity is active. + pub owner: Option<[u8; 32]>, +} + +impl StatementRenewalTarget { + /// Match the product identifier used by account derivation. + fn normalized(self) -> Result { + match self { + Self::ProductStatementAllowance { product_id } => { + let normalized = normalize_product_identifier(&product_id).map_err(|_| { + format!("product_id {product_id} is not a valid product identifier") + })?; + Ok(Self::ProductStatementAllowance { product_id: normalized }) + }, + target @ (Self::WalletSso | Self::Account { .. }) => Ok(target), + } + } +} + +impl TrackedStatementRenewalTarget { + /// Record `target` under `owner`, which only raw account ids retain. + fn new(target: StatementRenewalTarget, owner: [u8; 32]) -> Self { + let owner = match &target { + StatementRenewalTarget::Account { .. } => Some(owner), + StatementRenewalTarget::ProductStatementAllowance { .. } | + StatementRenewalTarget::WalletSso => None, + }; + Self { target, owner } + } + + /// Whether this entry belongs to the identity rooted at `owner`. + fn is_owned_by(&self, owner: [u8; 32]) -> bool { + self.owner.is_none_or(|recorded| recorded == owner) + } +} + +/// Shared wallet coordination for on-demand allowance issuance and renewal. +#[derive(Default)] +pub struct RenewalState { + /// Serializes slot registrations between the renewal pass and on-demand + /// allocation so both cannot race for the same free slot. + registration_lock: Mutex<()>, + /// Serializes read-modify-write cycles on the ledger so a concurrent + /// allocation cannot drop another's entry. + ledger_lock: Mutex<()>, + /// Retain loop results until the host reads them. + last_report: std::sync::Mutex>, +} + +impl RenewalState { + /// Serialize registration scans with competing issuers. + pub fn registration_lock(&self) -> &Mutex<()> { + &self.registration_lock + } + + fn ledger_lock(&self) -> &Mutex<()> { + &self.ledger_lock + } + + /// Record the pass a host has not seen the return value of. + fn record_report(&self, report: &StatementRenewalReport) { + if let Ok(mut last) = self.last_report.lock() { + *last = Some(report.clone()); + } + } + + /// The most recent pass the loop ran. `None` until one has run, which a host + /// should read as "not yet" rather than as healthy. + fn last_report(&self) -> Option { + self.last_report.lock().ok().and_then(|last| last.clone()) + } +} + +/// Unreadable entries are discarded; later allocations rebuild the ledger. +async fn read_entries( + storage: &(impl CoreStorage + ?Sized), +) -> Result, String> { + let Some(blob) = storage + .read_core_storage(CoreStorageKey::StatementRenewalTargets) + .await + .map_err(|err| format!("renewal ledger read failed: {}", err.reason))? + else { + return Ok(Vec::new()); + }; + match decode_entries(&blob) { + Ok(entries) => Ok(entries), + Err(reason) => { + warn!(%reason, "discarding an undecodable renewal ledger"); + Ok(Vec::new()) + }, + } +} + +/// Read a settled ledger while competing writes hold the same lock. +async fn list_entries( + storage: &(impl CoreStorage + ?Sized), + ledger_lock: &Mutex<()>, +) -> Result, String> { + let _guard = ledger_lock.lock().await; + read_entries(storage).await +} + +async fn write_entries( + storage: &(impl CoreStorage + ?Sized), + entries: &[TrackedStatementRenewalTarget], +) -> Result<(), String> { + storage + .write_core_storage(CoreStorageKey::StatementRenewalTargets, entries.encode()) + .await + .map_err(|err| format!("renewal ledger write failed: {}", err.reason)) +} + +fn decode_entries(blob: &[u8]) -> Result, String> { + let mut input = blob; + let entries = Vec::::decode(&mut input) + .map_err(|err| format!("invalid persisted renewal targets: {err}"))?; + if !input.is_empty() { + return Err("invalid persisted renewal targets: trailing bytes".to_string()); + } + Ok(entries) +} + +/// Stable labels identify pruned entries even without an active wallet. +fn target_label(target: &StatementRenewalTarget) -> String { + match target { + StatementRenewalTarget::ProductStatementAllowance { product_id } => { + format!("product:{product_id}") + }, + StatementRenewalTarget::WalletSso => "wallet-sso".to_string(), + StatementRenewalTarget::Account { label, .. } => label.clone(), + } +} + +fn resolve_target( + keys: &WalletKeys, + target: &StatementRenewalTarget, +) -> Result { + let label = target_label(target); + match target { + StatementRenewalTarget::ProductStatementAllowance { product_id } => { + let pair = keys.statement_allowance_key(product_id).map_err(|err| err.to_string())?; + Ok(ResolvedRenewalTarget { label, account_id: pair.public.to_bytes() }) + }, + StatementRenewalTarget::WalletSso => { + let pair = keys.identity_keypair().map_err(|err| err.to_string())?; + Ok(ResolvedRenewalTarget { label, account_id: pair.public.to_bytes() }) + }, + StatementRenewalTarget::Account { account_id, .. } => { + Ok(ResolvedRenewalTarget { label, account_id: *account_id }) + }, + } +} + +impl WalletAccountHolder { + /// Record `targets` in the ledger under the active identity. + pub async fn track_statement_renewal_targets( + &self, + targets: Vec, + ) -> Result<(), String> { + let session = + self.current_session().ok_or_else(|| AuthorityError::Disconnected.to_string())?; + self.track_statement_renewal_targets_for(&session, targets).await + } + + /// Keep an allocation's renewal record bound to its original wallet. + pub async fn track_statement_renewal_targets_for( + &self, + session: &AuthoritySession, + targets: Vec, + ) -> Result<(), String> { + let targets = targets + .into_iter() + .map(StatementRenewalTarget::normalized) + .collect::, _>>()?; + let _guard = self.renewal.ledger_lock().lock().await; + self.require_current_session(session).map_err(|error| error.to_string())?; + let mut entries = read_entries(self.services.platform.as_ref()).await?; + self.require_current_session(session).map_err(|error| error.to_string())?; + let mut changed = false; + for target in targets { + let entry = TrackedStatementRenewalTarget::new(target, session.public_key); + if !entries.contains(&entry) { + entries.push(entry); + changed = true; + } + } + if changed { + write_entries(self.services.platform.as_ref(), &entries).await?; + self.require_current_session(session).map_err(|error| error.to_string())?; + } + Ok(()) + } + + /// List tracked entries without requiring wallet unlock. + pub async fn statement_renewal_targets( + &self, + ) -> Result, String> { + list_entries(self.services.platform.as_ref(), self.renewal.ledger_lock()).await + } + + /// Identify which fixed ledger entries belong to the active wallet. + pub fn statement_renewal_owner_key(&self) -> Result<[u8; 32], String> { + self.current_session() + .map(|session| session.public_key) + .ok_or_else(|| AuthorityError::Disconnected.to_string()) + } + + /// Stop renewing one fixed statement account for the active identity. + pub async fn untrack_statement_renewal_account( + &self, + account_id: &[u8; 32], + ) -> Result { + let session = + self.current_session().ok_or_else(|| AuthorityError::Disconnected.to_string())?; + let _guard = self.renewal.ledger_lock().lock().await; + self.require_current_session(&session).map_err(|error| error.to_string())?; + let mut entries = read_entries(self.services.platform.as_ref()).await?; + self.require_current_session(&session).map_err(|error| error.to_string())?; + let original_len = entries.len(); + entries.retain(|entry| { + entry.owner != Some(session.public_key) || + !matches!( + &entry.target, + StatementRenewalTarget::Account { + account_id: existing, + .. + } if existing == account_id + ) + }); + if entries.len() == original_len { + return Ok(false); + } + write_entries(self.services.platform.as_ref(), &entries).await?; + self.require_current_session(&session).map_err(|error| error.to_string())?; + Ok(true) + } + + /// One renewal pass: resolve the ledger against the active session and renew + /// every target for the current period. + pub async fn renew_statement_allowances(&self) -> Result { + let session = + self.current_session().ok_or_else(|| AuthorityError::Disconnected.to_string())?; + let period = statement_allowance::slot::current_period( + current_unix_secs().map_err(|err| err.to_string())?, + ); + let (targets, pruned) = self.owned_targets(&session).await?; + let resolved = self + .with_keys::<_, AuthorityError>(&session, |keys| Ok(resolve_targets(keys, &targets))) + .map_err(|error| error.to_string())?; + if resolved.is_empty() { + return Ok(StatementRenewalReport { + period, + outcomes: Vec::new(), + pruned, + slots_exhausted: false, + }); + } + + let signer = self.personhood_signer(&session).await.map_err(|error| error.to_string())?; + let rpc = statement_allowance::rpc::RpcClient::new( + self.services + .statement_store + .client("statement-allowance renewal") + .await + .map_err(|err| err.to_string())?, + ); + let metadata = fetch_metadata(&rpc).await.map_err(|err| err.to_string())?; + let chain_state = fetch_chain_state(&rpc).await.map_err(|err| err.to_string())?; + let network_suffix = statement_allowance::slot::read_network_suffix(&rpc) + .await + .map_err(|err| err.to_string())?; + // Every ring back to index 0, because a membership that stopped being + // re-included still proves against the ring that holds it. + let memberships = find_including_rings( + &rpc, + &metadata, + &signer, + &super::PersonhoodCollection::ALL, + u32::MAX, + ) + .await + .map_err(|err| err.to_string())?; + if memberships.is_empty() { + return Err( + "signing account is not a member of any personhood ring; cannot renew statement-store allowances" + .to_string(), + ); + } + let context = RenewalChainContext { + rpc: &rpc, + metadata: &metadata, + chain_state: &chain_state, + network_suffix: &network_suffix, + signer: &signer, + collections: &super::PersonhoodCollection::ALL, + memberships: &memberships, + }; + self.require_current_session(&session).map_err(|err| err.to_string())?; + let mut report = + renew_targets(&context, period, &resolved, self.renewal.registration_lock()).await; + self.require_current_session(&session).map_err(|err| err.to_string())?; + report.pruned = pruned; + Ok(report) + } + + /// Renew the active wallet and record the periodic pass result. + pub async fn renewal_tick(&self) { + if self.current_session().is_none() { + debug!("skipping statement-store renewal tick; no active session"); + return; + } + absorb_tick(&self.renewal, self.renew_statement_allowances().await); + } + + /// Most recent result from the host's periodic renewal loop. + pub fn last_statement_renewal_report(&self) -> Option { + self.renewal.last_report() + } +} + +impl WalletAccountHolder { + // A stale owner must not prune entries tracked by a newer wallet. + async fn owned_targets( + &self, + session: &AuthoritySession, + ) -> Result<(Vec, Vec), String> { + let _guard = self.renewal.ledger_lock().lock().await; + self.require_current_session(session).map_err(|error| error.to_string())?; + let entries = read_entries(self.services.platform.as_ref()).await?; + self.require_current_session(session).map_err(|error| error.to_string())?; + let (owned, foreign): (Vec<_>, Vec<_>) = + entries.into_iter().partition(|entry| entry.is_owned_by(session.public_key)); + let pruned: Vec = foreign.iter().map(|entry| target_label(&entry.target)).collect(); + if !foreign.is_empty() { + warn!(dropped = ?pruned, "pruning renewal targets promised by a previous identity"); + write_entries(self.services.platform.as_ref(), &owned).await?; + } + Ok((owned.into_iter().map(|entry| entry.target).collect(), pruned)) + } +} + +/// Skip unusable entries so they cannot prevent renewal of other targets. +fn resolve_targets( + keys: &WalletKeys, + targets: &[StatementRenewalTarget], +) -> Vec { + targets + .iter() + .filter_map(|target| match resolve_target(keys, target) { + Ok(resolved) => Some(resolved), + Err(reason) => { + warn!(?target, %reason, "skipping an unresolvable renewal target"); + None + }, + }) + .collect() +} + +/// Preserve the last completed pass when a later tick fails. +fn absorb_tick(state: &RenewalState, result: Result) { + match result { + Ok(report) => { + if report.slots_exhausted { + warn!(period = report.period, "statement-store renewal hit slot exhaustion"); + } else { + info!( + period = report.period, + targets = report.outcomes.len(), + "statement-store renewal pass complete" + ); + } + state.record_report(&report); + }, + // A tick that could not run leaves the previous pass readable rather than + // replacing it with nothing: "the last thing we know" beats "no idea". + Err(reason) => warn!(%reason, "statement-store renewal tick failed"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + host_logic::product_account::derive_sr25519_hard_path, test_support::StubPlatform, + }; + use std::{ + collections::HashMap, + sync::{Arc, Mutex}, + }; + + use truapi::latest::GenericError; + + fn wallet_for(storage: Arc, entropy: u8) -> WalletAccountHolder { + let platform = StubPlatform { core_storage_override: Some(storage), ..Default::default() }; + WalletAccountHolder::for_tests(platform, "paseo", entropy) + } + + #[test] + fn stale_renewal_cannot_prune_the_replacement_wallets_targets() { + use futures::FutureExt; + + for waiting_on_lock in [true, false] { + let storage = Arc::new(YieldingStorage::default()); + let wallet = wallet_for(storage.clone(), 8); + let target = StatementRenewalTarget::Account { + account_id: [9; 32], + label: "wallet B device".to_string(), + }; + futures::executor::block_on( + wallet.track_statement_renewal_targets(vec![target.clone()]), + ) + .unwrap(); + let owner = wallet.current_session().unwrap().public_key; + wallet.install(wallet.prepare_activation(vec![7; 32], None).unwrap()); + let selected = wallet.current_session().unwrap(); + let guard = waiting_on_lock + .then(|| futures::executor::block_on(wallet.renewal.ledger_lock().lock())); + let renewal = wallet.renew_statement_allowances(); + futures::pin_mut!(renewal); + assert!(renewal.as_mut().now_or_never().is_none()); + wallet.install(wallet.prepare_activation(vec![8; 32], None).unwrap()); + drop(guard); + if waiting_on_lock { + futures::executor::block_on( + wallet.track_statement_renewal_targets(vec![target.clone()]), + ) + .unwrap(); + } + let result = futures::executor::block_on(renewal); + let tracked = futures::executor::block_on(wallet.track_statement_renewal_targets_for( + &selected, + vec![StatementRenewalTarget::Account { + account_id: [8; 32], + label: "wallet A device".to_string(), + }], + )); + assert_eq!( + ( + result, + tracked, + futures::executor::block_on(read_entries(storage.as_ref())).unwrap() + ), + ( + Err("Disconnected".to_string()), + Err("Disconnected".to_string()), + vec![TrackedStatementRenewalTarget { target, owner: Some(owner) }] + ), + ); + } + } + + #[derive(Default)] + struct MemStorage { + inner: Mutex, Vec>>, + writes: Mutex, + } + + impl MemStorage { + fn writes(&self) -> usize { + *self.writes.lock().expect("write counter mutex poisoned") + } + } + + #[crate::platform::async_trait] + impl CoreStorage for MemStorage { + async fn read_core_storage( + &self, + key: CoreStorageKey, + ) -> Result>, GenericError> { + Ok(self.inner.lock().expect("storage mutex poisoned").get(&key.encode()).cloned()) + } + + async fn write_core_storage( + &self, + key: CoreStorageKey, + value: Vec, + ) -> Result<(), GenericError> { + *self.writes.lock().expect("write counter mutex poisoned") += 1; + self.inner.lock().expect("storage mutex poisoned").insert(key.encode(), value); + Ok(()) + } + + async fn clear_core_storage(&self, key: CoreStorageKey) -> Result<(), GenericError> { + self.inner.lock().expect("storage mutex poisoned").remove(&key.encode()); + Ok(()) + } + } + + /// Yield once, so a concurrently polled task can run. + async fn yield_once() { + let mut yielded = false; + futures::future::poll_fn(move |cx| { + if yielded { + core::task::Poll::Ready(()) + } else { + yielded = true; + cx.waker().wake_by_ref(); + core::task::Poll::Pending + } + }) + .await + } + + /// Storage that yields *after* serving a read, so a second reader observes + /// the same value before the first writes its update back. Without + /// something serializing the cycle, one update is lost. + #[derive(Default)] + struct YieldingStorage(MemStorage); + + #[crate::platform::async_trait] + impl CoreStorage for YieldingStorage { + async fn read_core_storage( + &self, + key: CoreStorageKey, + ) -> Result>, GenericError> { + let value = self.0.read_core_storage(key).await; + yield_once().await; + value + } + + async fn write_core_storage( + &self, + key: CoreStorageKey, + value: Vec, + ) -> Result<(), GenericError> { + self.0.write_core_storage(key, value).await + } + + async fn clear_core_storage(&self, key: CoreStorageKey) -> Result<(), GenericError> { + self.0.clear_core_storage(key).await + } + } + + #[test] + fn concurrent_tracks_do_not_drop_an_entry() { + let storage = Arc::new(YieldingStorage::default()); + let wallet = wallet_for(storage.clone(), 7); + let owner = wallet.current_session().unwrap().public_key; + + futures::executor::block_on(async { + let (first, second) = futures::join!( + wallet.track_statement_renewal_targets(vec![product("a.dot")]), + wallet.track_statement_renewal_targets(vec![product("b.dot")]), + ); + first.unwrap(); + second.unwrap(); + + let mut targets = read_targets(storage.as_ref(), owner).await.unwrap(); + targets.sort_by_key(|target| format!("{target:?}")); + assert_eq!(targets, vec![product("a.dot"), product("b.dot")]); + }); + } + + /// The in-process loop returns nothing, so a host reads what a pass achieved + /// from the recorded report. Recording only in the direct path would leave a + /// loop-driven host unable to see exhaustion at all. + #[test] + fn the_last_report_is_readable_once_a_pass_has_run() { + let state = RenewalState::default(); + assert!( + state.last_report().is_none(), + "no pass has run, which is not the same as a healthy one" + ); + + absorb_tick( + &state, + Ok(StatementRenewalReport { + period: 7, + outcomes: Vec::new(), + pruned: Vec::new(), + slots_exhausted: true, + }), + ); + + let seen = state.last_report().expect("a pass has run"); + assert_eq!(seen.period, 7); + assert!(seen.slots_exhausted, "exhaustion is the outcome a host most needs to read back"); + } + + /// Only the newest matters: a host reads this on resume and wants the state + /// now, not the first exhaustion it ever hit. + #[test] + fn the_last_report_keeps_the_newest_pass() { + let state = RenewalState::default(); + for period in [7, 8] { + absorb_tick( + &state, + Ok(StatementRenewalReport { + period, + outcomes: Vec::new(), + pruned: Vec::new(), + slots_exhausted: period == 7, + }), + ); + } + + let seen = state.last_report().expect("a pass has run"); + assert_eq!(seen.period, 8); + assert!(!seen.slots_exhausted, "the older exhaustion should not stick"); + } + + /// A tick that could not run at all must not erase the last pass a host has + /// not read yet. + #[test] + fn a_failed_tick_leaves_the_previous_report_readable() { + let state = RenewalState::default(); + absorb_tick( + &state, + Ok(StatementRenewalReport { + period: 7, + outcomes: Vec::new(), + pruned: Vec::new(), + slots_exhausted: true, + }), + ); + + absorb_tick(&state, Err("no active session".to_string())); + + let seen = state.last_report().expect("the earlier pass is still there"); + assert_eq!(seen.period, 7); + assert!(seen.slots_exhausted); + } + + /// Pruning rewrites the whole ledger, so it has to hold the lock across its + /// read too. Reading outside it lets a tracking call land in the gap and be + /// overwritten by a view that predates it, leaving that account tracked + /// nowhere and never renewed again. + #[test] + fn a_prune_does_not_overwrite_a_concurrent_track() { + let storage = Arc::new(YieldingStorage::default()); + let wallet = wallet_for(storage.clone(), 7); + let owner = wallet.current_session().unwrap().public_key; + let other_wallet = wallet_for(storage.clone(), 8); + let device = + StatementRenewalTarget::Account { account_id: [9; 32], label: "device".to_string() }; + + futures::executor::block_on(async { + // A foreign entry, so the pass prunes and therefore writes. + other_wallet.track_statement_renewal_targets(vec![device]).await.unwrap(); + + let session = wallet.current_session().unwrap(); + let (pruned, tracked) = futures::join!( + wallet.owned_targets(&session), + wallet.track_statement_renewal_targets(vec![product("a.dot")]), + ); + pruned.unwrap(); + tracked.unwrap(); + + assert_eq!( + read_targets(storage.as_ref(), owner).await.unwrap(), + vec![product("a.dot")], + "the concurrently tracked target was overwritten by the prune" + ); + }); + } + + /// A raw account promised by a previous identity must not be renewed under a + /// later one: it would spend that identity's slots on an account it never + /// promised. + #[test] + fn a_foreign_target_is_dropped_from_the_ledger() { + let storage = Arc::new(MemStorage::default()); + let wallet = wallet_for(storage.clone(), 7); + let owner = wallet.current_session().unwrap().public_key; + let other_wallet = wallet_for(storage.clone(), 8); + let device = + StatementRenewalTarget::Account { account_id: [9; 32], label: "device".to_string() }; + + futures::executor::block_on(async { + other_wallet.track_statement_renewal_targets(vec![device]).await.unwrap(); + wallet.track_statement_renewal_targets(vec![product("a.dot")]).await.unwrap(); + + let (targets, pruned) = + wallet.owned_targets(&wallet.current_session().unwrap()).await.unwrap(); + + assert_eq!(targets, vec![product("a.dot")]); + // Reported, not just dropped: the pass is a host's only view of the + // ledger, so a silent prune is one it cannot notice or re-track. + assert_eq!(pruned, vec!["device".to_string()]); + // Dropped, not merely skipped, so the cost is paid once. + assert_eq!( + read_entries(storage.as_ref()).await.unwrap(), + vec![TrackedStatementRenewalTarget::new(product("a.dot"), owner)] + ); + }); + } + + #[test] + fn an_all_foreign_ledger_prunes_to_nothing() { + let storage = Arc::new(MemStorage::default()); + let wallet = wallet_for(storage.clone(), 7); + let other_wallet = wallet_for(storage.clone(), 8); + let device = + StatementRenewalTarget::Account { account_id: [9; 32], label: "device".to_string() }; + + futures::executor::block_on(async { + other_wallet.track_statement_renewal_targets(vec![device]).await.unwrap(); + + assert_eq!( + wallet.owned_targets(&wallet.current_session().unwrap()).await.unwrap(), + (Vec::new(), vec!["device".to_string()]) + ); + assert_eq!(read_entries(storage.as_ref()).await.unwrap(), Vec::new()); + }); + } + + #[test] + fn an_all_owned_ledger_is_not_rewritten() { + let storage = Arc::new(MemStorage::default()); + let wallet = wallet_for(storage.clone(), 7); + + futures::executor::block_on(async { + wallet.track_statement_renewal_targets(vec![product("a.dot")]).await.unwrap(); + let after_seeding = storage.writes(); + + let (targets, _pruned) = + wallet.owned_targets(&wallet.current_session().unwrap()).await.unwrap(); + + assert_eq!(targets, vec![product("a.dot")]); + // Every tick calls this; rewriting the ledger each time would be waste. + assert_eq!(storage.writes(), after_seeding); + }); + } + + #[test] + fn an_unresolvable_target_is_skipped_not_fatal() { + // An all-digit product id past `u64::MAX` fails junction derivation with + // `NumericJunctionOutOfRange`. + let unresolvable = product(&"9".repeat(25)); + let keys = WalletKeys::new(vec![7; 32], "paseo".to_string()); + + assert!(resolve_target(&keys, &unresolvable).is_err()); + let targets = [unresolvable, product("a.dot")]; + + // Resolving strictly loses the healthy target with the broken one. + assert!( + targets + .iter() + .map(|target| resolve_target(&keys, target)) + .collect::, _>>() + .is_err() + ); + + let resolved = resolve_targets(&keys, &targets); + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].label, "product:a.dot"); + } + + /// The targets in the ledger visible to the identity rooted at `owner`. + async fn read_targets( + storage: &(impl CoreStorage + ?Sized), + owner: [u8; 32], + ) -> Result, String> { + Ok(read_entries(storage) + .await? + .into_iter() + .filter(|entry| entry.is_owned_by(owner)) + .map(|entry| entry.target) + .collect()) + } + + fn product(product_id: &str) -> StatementRenewalTarget { + StatementRenewalTarget::ProductStatementAllowance { product_id: product_id.to_string() } + } + + #[test] + fn ledger_round_trips_dedupes_and_preserves_order() { + let storage = Arc::new(MemStorage::default()); + let wallet = wallet_for(storage.clone(), 7); + let owner = wallet.current_session().unwrap().public_key; + + futures::executor::block_on(async { + wallet + .track_statement_renewal_targets(vec![ + StatementRenewalTarget::WalletSso, + product("a.dot"), + ]) + .await + .unwrap(); + wallet + .track_statement_renewal_targets(vec![ + product("a.dot"), + StatementRenewalTarget::Account { + account_id: [9; 32], + label: "device".to_string(), + }, + ]) + .await + .unwrap(); + + assert_eq!( + read_targets(storage.as_ref(), owner).await.unwrap(), + vec![ + StatementRenewalTarget::WalletSso, + product("a.dot"), + StatementRenewalTarget::Account { + account_id: [9; 32], + label: "device".to_string(), + }, + ] + ); + }); + } + + #[test] + fn the_ledger_lists_back_every_entry_in_track_order() { + let storage = Arc::new(MemStorage::default()); + let wallet = wallet_for(storage.clone(), 7); + let owner = wallet.current_session().unwrap().public_key; + let device = + StatementRenewalTarget::Account { account_id: [9; 32], label: "device".to_string() }; + + futures::executor::block_on(async { + wallet + .track_statement_renewal_targets(vec![ + StatementRenewalTarget::WalletSso, + device.clone(), + ]) + .await + .unwrap(); + + assert_eq!( + wallet.statement_renewal_targets().await.unwrap(), + vec![ + TrackedStatementRenewalTarget { + target: StatementRenewalTarget::WalletSso, + owner: None, + }, + TrackedStatementRenewalTarget { target: device, owner: Some(owner) }, + ] + ); + }); + } + + // The point of the reader is auditing finite slots, so it reports what is + // stored rather than what the active identity would resolve: an entry another + // identity promised is still occupying a row until a pass prunes it, and the + // owner is what lets a caller tell the two apart. + #[test] + fn a_listing_includes_a_foreign_entry_and_writes_nothing() { + let storage = Arc::new(MemStorage::default()); + let wallet = wallet_for(storage.clone(), 7); + let other_wallet = wallet_for(storage.clone(), 8); + let other_owner = other_wallet.current_session().unwrap().public_key; + let device = + StatementRenewalTarget::Account { account_id: [9; 32], label: "device".to_string() }; + + futures::executor::block_on(async { + other_wallet + .track_statement_renewal_targets(vec![device.clone()]) + .await + .unwrap(); + wallet.track_statement_renewal_targets(vec![product("a.dot")]).await.unwrap(); + let writes_before = storage.writes(); + + let listed = wallet.statement_renewal_targets().await.unwrap(); + + assert_eq!( + listed, + vec![ + TrackedStatementRenewalTarget { target: device, owner: Some(other_owner) }, + TrackedStatementRenewalTarget { target: product("a.dot"), owner: None }, + ] + ); + // A read that rewrote the ledger could not be run without a session. + assert_eq!(storage.writes(), writes_before); + }); + } + + // The reader is the one ledger operation whose whole job is to run while a + // pass may be writing: a scheduled host wakes, lists, and decides. `track` + // is polled first so it holds the lock across its read, which is what makes + // the guarantee observable; without the lock the listing reads the ledger + // the track is in the middle of replacing. + #[test] + fn a_listing_during_a_track_reports_the_settled_ledger() { + let storage = Arc::new(YieldingStorage::default()); + let wallet = wallet_for(storage.clone(), 7); + + futures::executor::block_on(async { + let (tracked, listed) = futures::join!( + wallet.track_statement_renewal_targets(vec![product("a.dot")]), + wallet.statement_renewal_targets(), + ); + tracked.unwrap(); + + assert_eq!( + listed.unwrap(), + vec![TrackedStatementRenewalTarget { target: product("a.dot"), owner: None }], + "the listing observed the ledger the track was replacing" + ); + }); + } + + #[test] + fn an_undecodable_ledger_lists_as_empty() { + let storage = Arc::new(MemStorage::default()); + let wallet = wallet_for(storage.clone(), 7); + + futures::executor::block_on(async { + storage + .write_core_storage(CoreStorageKey::StatementRenewalTargets, vec![0xff; 8]) + .await + .unwrap(); + + assert_eq!(wallet.statement_renewal_targets().await.unwrap(), Vec::new()); + }); + } + + #[test] + fn ledger_rejects_trailing_bytes() { + let mut blob = vec![TrackedStatementRenewalTarget::new(product("a.dot"), [1; 32])].encode(); + blob.push(0xff); + assert!(decode_entries(&blob).is_err()); + } + + #[test] + fn an_undecodable_ledger_reads_as_empty() { + let storage = Arc::new(MemStorage::default()); + let wallet = wallet_for(storage.clone(), 7); + let owner = wallet.current_session().unwrap().public_key; + + futures::executor::block_on(async { + storage + .write_core_storage(CoreStorageKey::StatementRenewalTargets, vec![0xff, 0xff, 0xff]) + .await + .unwrap(); + + assert_eq!(read_targets(storage.as_ref(), owner).await.unwrap(), Vec::new()); + }); + } + + #[test] + fn tracking_over_an_undecodable_ledger_starts_a_fresh_one() { + let storage = Arc::new(MemStorage::default()); + let wallet = wallet_for(storage.clone(), 7); + let owner = wallet.current_session().unwrap().public_key; + + futures::executor::block_on(async { + storage + .write_core_storage(CoreStorageKey::StatementRenewalTargets, vec![0xff; 3]) + .await + .unwrap(); + wallet.track_statement_renewal_targets(vec![product("a.dot")]).await.unwrap(); + + assert_eq!( + read_targets(storage.as_ref(), owner).await.unwrap(), + vec![product("a.dot")] + ); + }); + } + + #[test] + fn product_target_resolves_to_allocation_derivation() { + let entropy = [7u8; 32]; + let expected = + derive_sr25519_hard_path(&entropy, &["allowance", "statement-store", "a.dot"]) + .unwrap() + .public + .to_bytes(); + + let resolved = resolve_target( + &WalletKeys::new(entropy.to_vec(), "paseo".to_string()), + &product("a.dot"), + ) + .unwrap(); + assert_eq!( + resolved, + ResolvedRenewalTarget { label: "product:a.dot".to_string(), account_id: expected } + ); + } + + #[test] + fn wallet_sso_target_resolves_to_the_responder_identity() { + let entropy = [7u8; 32]; + let expected = + crate::host_logic::product_account::derive_identity_keypair(&entropy, "paseo") + .unwrap() + .public + .to_bytes(); + + let resolved = resolve_target( + &WalletKeys::new(entropy.to_vec(), "paseo".to_string()), + &StatementRenewalTarget::WalletSso, + ) + .unwrap(); + + assert_eq!( + resolved, + ResolvedRenewalTarget { label: "wallet-sso".to_string(), account_id: expected } + ); + } + + #[test] + fn untracking_one_device_preserves_wallet_sso_and_other_devices() { + let storage = Arc::new(MemStorage::default()); + let wallet = wallet_for(storage.clone(), 7); + let owner = wallet.current_session().unwrap().public_key; + let first = + StatementRenewalTarget::Account { account_id: [8; 32], label: "device:08".to_string() }; + let second = + StatementRenewalTarget::Account { account_id: [9; 32], label: "device:09".to_string() }; + + futures::executor::block_on(async { + wallet + .track_statement_renewal_targets(vec![ + StatementRenewalTarget::WalletSso, + first, + second.clone(), + ]) + .await + .unwrap(); + + assert!(wallet.untrack_statement_renewal_account(&[8; 32]).await.unwrap()); + assert_eq!( + read_targets(storage.as_ref(), owner).await.unwrap(), + vec![StatementRenewalTarget::WalletSso, second] + ); + }); + } + + #[test] + fn concurrent_track_and_untrack_preserve_an_unrelated_device() { + let storage = Arc::new(YieldingStorage::default()); + let wallet = wallet_for(storage.clone(), 7); + let owner = wallet.current_session().unwrap().public_key; + let first = + StatementRenewalTarget::Account { account_id: [8; 32], label: "device:08".to_string() }; + let second = + StatementRenewalTarget::Account { account_id: [9; 32], label: "device:09".to_string() }; + + futures::executor::block_on(async { + wallet.track_statement_renewal_targets(vec![first]).await.unwrap(); + let (removed, tracked) = futures::join!( + wallet.untrack_statement_renewal_account(&[8; 32]), + wallet.track_statement_renewal_targets(vec![second.clone()]), + ); + assert!(removed.unwrap()); + tracked.unwrap(); + + assert_eq!(read_targets(&storage.0, owner).await.unwrap(), vec![second]); + }); + } + + #[test] + fn a_raw_account_is_hidden_from_another_identity() { + let storage = Arc::new(MemStorage::default()); + let wallet = wallet_for(storage.clone(), 7); + let owner = wallet.current_session().unwrap().public_key; + let other_wallet = wallet_for(storage.clone(), 8); + let other_owner = other_wallet.current_session().unwrap().public_key; + let device = + StatementRenewalTarget::Account { account_id: [9; 32], label: "device".to_string() }; + + futures::executor::block_on(async { + wallet + .track_statement_renewal_targets(vec![device.clone(), product("a.dot")]) + .await + .unwrap(); + + // The recipe resolves under any identity; the raw account does not. + assert_eq!( + read_targets(storage.as_ref(), owner).await.unwrap(), + vec![device, product("a.dot")] + ); + assert_eq!( + read_targets(storage.as_ref(), other_owner).await.unwrap(), + vec![product("a.dot")] + ); + }); + } + + #[test] + fn the_same_raw_account_can_be_promised_by_two_identities() { + let storage = Arc::new(MemStorage::default()); + let wallet = wallet_for(storage.clone(), 7); + let owner = wallet.current_session().unwrap().public_key; + let other_wallet = wallet_for(storage.clone(), 8); + let other_owner = other_wallet.current_session().unwrap().public_key; + let device = + StatementRenewalTarget::Account { account_id: [9; 32], label: "device".to_string() }; + + futures::executor::block_on(async { + wallet.track_statement_renewal_targets(vec![device.clone()]).await.unwrap(); + other_wallet + .track_statement_renewal_targets(vec![device.clone()]) + .await + .unwrap(); + + // Distinct owners, so each identity renews it for itself. + assert_eq!(read_targets(storage.as_ref(), owner).await.unwrap(), vec![device.clone()]); + assert_eq!(read_targets(storage.as_ref(), other_owner).await.unwrap(), vec![device]); + }); + } + + #[test] + fn untracking_a_device_preserves_the_other_identitys_entry() { + let storage = Arc::new(MemStorage::default()); + let wallet = wallet_for(storage.clone(), 7); + let owner = wallet.current_session().unwrap().public_key; + let other_wallet = wallet_for(storage.clone(), 8); + let other_owner = other_wallet.current_session().unwrap().public_key; + let device = + StatementRenewalTarget::Account { account_id: [9; 32], label: "device".to_string() }; + + futures::executor::block_on(async { + wallet.track_statement_renewal_targets(vec![device.clone()]).await.unwrap(); + other_wallet + .track_statement_renewal_targets(vec![device.clone()]) + .await + .unwrap(); + + assert!(wallet.untrack_statement_renewal_account(&[9; 32]).await.unwrap()); + assert_eq!( + ( + read_targets(storage.as_ref(), owner).await.unwrap(), + read_targets(storage.as_ref(), other_owner).await.unwrap(), + ), + (Vec::new(), vec![device]) + ); + }); + } + + #[test] + fn owner_key_follows_the_root_entropy() { + assert_ne!( + WalletKeys::new(vec![7; 32], "paseo".to_string()).root_public_key().unwrap(), + WalletKeys::new(vec![8; 32], "paseo".to_string()).root_public_key().unwrap() + ); + } + + // A product connection derives its allowance account from the normalized + // id, so tracking any other spelling renews an account no product uses. + #[test] + fn a_product_target_normalizes_its_identifier() { + for supplied in + [" truapi-playground.dot ", "TruAPI-Playground.dot", "TRUAPI-PLAYGROUND.DOT"] + { + assert_eq!( + product(supplied).normalized(), + Ok(product("truapi-playground.dot")), + "{supplied:?} did not normalize" + ); + } + assert_eq!( + product("not a product").normalized(), + Err("product_id not a product is not a valid product identifier".to_string()) + ); + for target in [ + StatementRenewalTarget::WalletSso, + StatementRenewalTarget::Account { account_id: [0x11; 32], label: "device".to_string() }, + ] { + assert_eq!(target.clone().normalized(), Ok(target)); + } + } +} diff --git a/rust/crates/truapi/src/runtime/signing_host/wallet_account_holder/allowance_tests.rs b/rust/crates/truapi/src/runtime/signing_host/wallet_account_holder/allowance_tests.rs new file mode 100644 index 0000000000..dcd2aea7ac --- /dev/null +++ b/rust/crates/truapi/src/runtime/signing_host/wallet_account_holder/allowance_tests.rs @@ -0,0 +1,229 @@ +use super::*; +use crate::{runtime::AccountHolder, test_support::StubPlatform}; + +fn wallet(suffix: &str) -> WalletAccountHolder { + WalletAccountHolder::for_tests(StubPlatform::default(), suffix, 7) +} + +#[test] +fn reserved_identities_follow_the_network_and_collection_order() { + for suffix in ["dot", "paseo"] { + let wallet = wallet(suffix); + let session = wallet.current_session().unwrap(); + let signer = futures::executor::block_on(wallet.personhood_signer(&session)).unwrap(); + let members = PersonhoodCollection::ALL + .map(|collection| (collection, signer.member(collection).unwrap())); + let expected = [(PersonhoodCollection::People, 0), (PersonhoodCollection::LitePeople, 1)] + .map(|(collection, index)| { + let entropy = derive_ring_vrf_entropy( + &[7; 32], + &format!("peopl.{suffix}"), + &truapi::latest::DerivationIndex::Index(index), + ) + .unwrap(); + (collection, signer.vrf.member(&entropy).unwrap()) + }); + assert_eq!(members, expected); + let identity = derive_identity_keypair(&[7; 32], suffix).unwrap(); + assert_eq!(session.identity_account_id, Some(identity.public.to_bytes())); + } +} + +#[test] +fn wallet_replacement_during_revision_read_prevents_allowance_submission() { + use crate::runtime::statement_allowance::{ + self as allocation, CollectionScan, PooledRegistrationParams, + rpc::{RpcClient, testing::ScriptedRpc}, + slot, + }; + use futures::FutureExt; + use parity_scale_codec::Encode; + + let wallet = wallet("paseo"); + let session = wallet.current_session().unwrap(); + let signer = futures::executor::block_on(wallet.personhood_signer(&session)).unwrap(); + let ring = RingParams { + collection: PersonhoodCollection::LitePeople, + members: vec![signer.member(PersonhoodCollection::LitePeople).unwrap()], + exponent: 9, + ring_index: 0, + block_hash: "0xfinal".to_string(), + }; + let state = allocation::extension::ChainState { + spec_version: 1_000_000, + transaction_version: 1, + genesis_hash: [0xab; 32], + nonce: 0, + restrict_origins: false, + }; + let verified = format!(r#""0x{}""#, hex::encode(([0x22u8; 32], 0u32, 1_000u64).encode())); + let scripted = ScriptedRpc::new(["null", verified.as_str()]); + scripted.script_subscription([r#"{"inBlock":"0xb10c"}"#]); + let release = scripted.pause_response(0); + let rpc = RpcClient::new(subxt_rpcs::RpcClient::new(scripted.clone())); + let scans = + [CollectionScan { collection: ring.collection, selection: slot::SlotSelection::Free(0) }]; + let memberships = [ring]; + let registration = allocation::register_statement_account_pooled( + &rpc, + allocation::people_test_metadata(), + &state, + &signer, + &scans, + &memberships, + PooledRegistrationParams { + target: &[0x22; 32], + period: 7, + network_suffix: b"paseo", + reuse_existing: true, + allow_eviction: false, + protected: &[], + }, + ); + futures::pin_mut!(registration); + assert!(registration.as_mut().now_or_never().is_none()); + wallet.install(wallet.prepare_activation(vec![7; 32], None).unwrap()); + release.send(()).unwrap(); + let result = futures::executor::block_on(registration) + .map(|_| ()) + .map_err(|error| AllowanceAllocationError::from(error).into_authority_error()); + let methods: Vec<_> = scripted.calls().into_iter().map(|(method, _)| method).collect(); + assert_eq!( + (result, methods), + (Err(AuthorityError::Disconnected), vec!["state_getStorage".to_string()]) + ); +} + +#[test] +fn wallet_lock_after_a_full_pgas_batch_prevents_the_next_batch() { + use crate::runtime::statement_allowance::{ + self as allocation, + rpc::{RpcClient, testing::ScriptedRpc}, + slot, + }; + use futures::FutureExt; + + let wallet = wallet("paseo"); + let session = wallet.current_session().unwrap(); + let signer = futures::executor::block_on(wallet.personhood_signer(&session)).unwrap(); + let responses: Vec<_> = std::iter::repeat_n(r#""0x""#, 10) + .chain(std::iter::repeat_n("null", 10)) + .collect(); + let scripted = ScriptedRpc::new(responses); + let release = scripted.pause_response(0); + let rpc = RpcClient::new(subxt_rpcs::RpcClient::new(scripted.clone())); + let scan = slot::scan_pgas_slot_excluding( + &rpc, + allocation::asset_hub_test_metadata(), + PersonhoodCollection::LitePeople, + &signer, + b"paseo", + 7, + &[], + ); + futures::pin_mut!(scan); + assert!(scan.as_mut().now_or_never().is_none()); + wallet.clear(); + release.send(()).unwrap(); + let result = futures::executor::block_on(scan) + .map_err(|error| AllowanceAllocationError::from(error).into_authority_error()); + let methods: Vec<_> = scripted.calls().into_iter().map(|(method, _)| method).collect(); + assert_eq!( + (result, methods), + (Err(AuthorityError::Disconnected), vec!["state_queryStorageAt".to_string()]) + ); +} + +#[test] +fn wallet_replacement_after_the_first_renewal_submission_stops_the_pass() { + use crate::runtime::statement_allowance::{ + self as allocation, + renewal::{ + RenewalChainContext, ResolvedRenewalTarget, StatementRenewalOutcome, + TargetRenewalStatus, renew_targets, + }, + rpc::{RpcClient, testing::ScriptedRpc}, + }; + use futures::FutureExt; + use parity_scale_codec::Encode; + + let wallet = wallet("paseo"); + let session = wallet.current_session().unwrap(); + let signer = futures::executor::block_on(wallet.personhood_signer(&session)).unwrap(); + let rings = [RingParams { + collection: PersonhoodCollection::LitePeople, + members: vec![signer.member(PersonhoodCollection::LitePeople).unwrap()], + exponent: 9, + ring_index: 0, + block_hash: "0xfinal".to_string(), + }]; + let state = allocation::extension::ChainState { + spec_version: 1_000_000, + transaction_version: 1, + genesis_hash: [0xab; 32], + nonce: 0, + restrict_origins: false, + }; + let targets = [ + ResolvedRenewalTarget { label: "first".to_string(), account_id: [0x21; 32] }, + ResolvedRenewalTarget { label: "second".to_string(), account_id: [0x22; 32] }, + ]; + let first = format!(r#""0x{}""#, hex::encode((targets[0].account_id, 0u32, 1_000u64).encode())); + let second = + format!(r#""0x{}""#, hex::encode((targets[1].account_id, 1u32, 1_000u64).encode())); + let mut responses = vec!["null"; 31]; + responses.extend([first.as_str(), first.as_str()]); + responses.extend(std::iter::repeat_n("null", 10)); + responses.push(second.as_str()); + let scripted = ScriptedRpc::new(responses); + scripted.script_subscription([r#"{"inBlock":"0xb10c"}"#]); + scripted.script_subscription([r#"{"inBlock":"0xb10d"}"#]); + let release = scripted.pause_response(5); + let rpc = RpcClient::new(subxt_rpcs::RpcClient::new(scripted.clone())); + let context = RenewalChainContext { + rpc: &rpc, + metadata: allocation::people_test_metadata(), + chain_state: &state, + network_suffix: b"paseo", + collections: &[PersonhoodCollection::LitePeople], + signer: &signer, + memberships: &rings, + }; + let lock = futures::lock::Mutex::new(()); + let renewal = renew_targets(&context, 7, &targets, &lock); + futures::pin_mut!(renewal); + assert!(renewal.as_mut().now_or_never().is_none()); + wallet.install(wallet.prepare_activation(vec![8; 32], None).unwrap()); + release.send(()).unwrap(); + let report = futures::executor::block_on(renewal); + let methods: Vec<_> = scripted.calls().into_iter().map(|(method, _)| method).collect(); + assert_eq!( + (report.outcomes, methods), + ( + vec![ + StatementRenewalOutcome { + label: "first".to_string(), + status: TargetRenewalStatus::Registered { + seq: 0, + block_hash: "0xb10c".to_string(), + }, + }, + StatementRenewalOutcome { + label: "second".to_string(), + status: TargetRenewalStatus::Failed { reason: "Disconnected".to_string() }, + }, + ], + vec![ + "state_queryStorageAt", + "state_queryStorageAt", + "state_queryStorageAt", + "state_getStorage", + "author_submitAndWatchExtrinsic", + "state_getStorage" + ] + .into_iter() + .map(str::to_string) + .collect() + ) + ); +} diff --git a/rust/crates/truapi/src/runtime/signing_host/wallet_account_holder/pairing.rs b/rust/crates/truapi/src/runtime/signing_host/wallet_account_holder/pairing.rs new file mode 100644 index 0000000000..7627eddad9 --- /dev/null +++ b/rust/crates/truapi/src/runtime/signing_host/wallet_account_holder/pairing.rs @@ -0,0 +1,107 @@ +use super::{WalletAccountHolder, product_authority_error}; +use crate::{ + host_logic::{ + session::SsoSessionInfo, + sso::pairing::{ + bootstrap_topic, encrypt_v2_handshake_response, establish_responder_session_info, v2, + }, + statement_store::build_signed_statement, + }, + runtime::{ + authority::{AuthorityError, AuthoritySession}, + sso_remote::fresh_statement_expiry, + sso_responder_service::PairedSsoPeer, + }, +}; +use parity_scale_codec::Encode; + +impl WalletAccountHolder { + /// Verify both keys of an externally owned SSO transport. + pub fn require_sso_identity( + &self, + session: &AuthoritySession, + statement_public_key: [u8; 32], + encryption_public_key: [u8; 32], + ) -> Result<(), AuthorityError> { + self.with_keys(session, |keys| { + let (identity, _) = keys.responder_identity().map_err(product_authority_error)?; + if identity.statement_public_key != statement_public_key || + identity.encryption_public_key != encryption_public_key + { + return Err(AuthorityError::Unavailable { + reason: "SSO transport identity does not match the active wallet".to_string(), + }); + } + Ok(()) + }) + } + + /// Derive transport material for a peer under the selected activation. + pub fn sso_session( + &self, + session: &AuthoritySession, + peer: PairedSsoPeer, + ) -> Result { + self.with_keys(session, |keys| { + let (identity, _) = keys.responder_identity().map_err(product_authority_error)?; + establish_responder_session_info( + &identity, + peer.statement_account_id, + peer.encryption_public_key, + ) + .map_err(|reason| AuthorityError::Unknown { reason }) + }) + } + + /// Encrypt delegated derivation material before it leaves the wallet. + pub fn pairing_answer( + &self, + session: &AuthoritySession, + peer: PairedSsoPeer, + device_enc_pub_key: [u8; 32], + ) -> Result<(SsoSessionInfo, Vec), AuthorityError> { + self.with_keys(session, |keys| { + let (identity, chat_private_key) = + keys.responder_identity().map_err(product_authority_error)?; + let transport = establish_responder_session_info( + &identity, + peer.statement_account_id, + peer.encryption_public_key, + ) + .map_err(|reason| AuthorityError::Unknown { reason })?; + let response = v2::EncryptedResponse::Success(Box::new(v2::Success { + identity_account_id: identity.statement_public_key, + root_account_id: session.public_key, + identity_chat_private_key: chat_private_key, + sso_enc_pub_key: identity.encryption_public_key, + device_enc_pub_key, + root_entropy_source: keys.root_entropy_source(), + })); + let statement = prepare_handshake_answer(&transport, peer, &response)?; + Ok((transport, statement)) + }) + } + + /// Sign a pairing notice only while its original wallet is selected. + pub fn pairing_notice( + &self, + session: &AuthoritySession, + peer: PairedSsoPeer, + response: &v2::EncryptedResponse, + ) -> Result, AuthorityError> { + let transport = self.sso_session(session, peer)?; + prepare_handshake_answer(&transport, peer, response) + } +} + +fn prepare_handshake_answer( + session: &SsoSessionInfo, + peer: PairedSsoPeer, + response: &v2::EncryptedResponse, +) -> Result, AuthorityError> { + let handshake = encrypt_v2_handshake_response(peer.encryption_public_key, response) + .map_err(|reason| AuthorityError::Unknown { reason })?; + let topic = bootstrap_topic(peer.statement_account_id, peer.encryption_public_key); + build_signed_statement(session, topic, topic, handshake.encode(), fresh_statement_expiry()) + .map_err(|reason| AuthorityError::Unknown { reason }) +} diff --git a/rust/crates/truapi/src/runtime/sso_account_holder_service.rs b/rust/crates/truapi/src/runtime/sso_account_holder_service.rs new file mode 100644 index 0000000000..c771752b8c --- /dev/null +++ b/rust/crates/truapi/src/runtime/sso_account_holder_service.rs @@ -0,0 +1,538 @@ +//! Incoming SSO account requests and wallet grant responses. + +use std::sync::Arc; + +use futures::StreamExt; +use tracing::warn; +use truapi::latest as api; + +use super::{ + authority::{AccountGrant, AccountGrantOutcome, AccountInvocation}, + product_consent::ProductConsent, + signing_host::WalletAccountHolder, + validate_vrf_transcript, +}; +use crate::{ + host_internal::{ + sso_messages::{ + CreateAccountProofResponse, CreateTransactionLegacyPayload, CreateTransactionPayload, + CreateTransactionRequest, CreateTransactionResponse, + CreateTransactionWithLegacyAccountRequest, GetAccountAliasResponse, + ListRingVrfKeysResponse, ProductRequest, ProductSubtreeRequest, ProductSubtreeResponse, + RegisterRingVrfKeyResponse, RemoteMessage, ResourceAllocationRequest, + ResourceAllocationResponse, RingVrfSignResponse, SignRawWithLegacyAccountRequest, + SignRawWithLegacyAccountResponse, SignRequest, SignResponse, SignVrfResponse, + SsoAllocatedResource, SsoAllocationOutcome, + }, + sso_wire::ResponseOutcome, + }, + host_logic::product_account::product_public_key_to_address, + platform::{ + ResourceAllocationReview, SignVrfReview, UserConfirmationReview, + normalize_product_identifier, + }, + runtime::{ + authority::{ + AccountHolder, AuthorityError, AuthoritySession, CreateTransactionAuthorityRequest, + SignPayloadAuthorityRequest, SignRawAuthorityRequest, + }, + sso_service::{Dispatch, SsoReply, SsoRequestContext, SsoWithdrawals}, + }, +}; + +/// Incoming requests for one authenticated wallet activation and peer. +#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Object))] +pub struct SsoAccountHolderService { + wallet: Arc, + consent: Arc, + session: AuthoritySession, + withdrawals: SsoWithdrawals, +} + +impl SsoAccountHolderService { + /// Bind one peer to the activation that authenticated its transport, confirming + /// each of its requests through `consent`. + pub fn new( + wallet: Arc, + consent: Arc, + session: AuthoritySession, + ) -> Self { + Self { wallet, consent, session, withdrawals: SsoWithdrawals::default() } + } + + /// Run the wallet's `operation` only once the user confirms the paired host's request. + async fn confirmed( + &self, + invocation: AccountInvocation<'_>, + review: UserConfirmationReview, + operation: impl Future>, + ) -> Result { + self.consent.review(invocation.call, invocation.caller, review).await?; + operation.await + } + + /// Require the activation that authenticated this peer. + pub fn require_current_session(&self) -> Result<(), AuthorityError> { + self.wallet.require_current_session(&self.session) + } + + /// Apply a withdrawal without waiting behind the request it cancels. + pub fn handle_control(&self, message: &RemoteMessage) -> Option { + let target = message.withdrawn_request_id()?; + self.withdrawals.withdraw(target); + Some(Dispatch::Withdraw(target.to_string())) + } + + /// Answer a request without releasing stale or withdrawn wallet results. + pub async fn answer(&self, message: RemoteMessage) -> Result { + if let Some(control) = self.handle_control(&message) { + return Ok(control); + } + let Some(request) = self.withdrawals.begin(&message.message_id) else { + return Ok(Dispatch::Withdrawn); + }; + if self.require_current_session().is_err() { + return Ok(self.dispatch(None, message).await); + } + let cx = SsoRequestContext::new( + &message.message_id, + self.session.clone(), + request.cancel.clone(), + ); + let dispatch = self.dispatch(Some(cx), message.clone()).await; + if matches!(dispatch, Dispatch::Response(_)) { + if request.cancel.is_cancelled() { + return Ok(Dispatch::Withdrawn); + } + if self.require_current_session().is_err() { + return Ok(self.dispatch(None, message).await); + } + } + Ok(dispatch) + } + + async fn serve_sign( + &self, + cx: &SsoRequestContext, + request: SignRequest, + ) -> Result { + let watermarked = matches!(request, SignRequest::Raw(_)); + match request { + SignRequest::Payload(request) => { + let invocation = cx.account_invocation(None); + let request = SignPayloadAuthorityRequest::Product(*request); + let review = request.review(invocation.caller); + self.confirmed(invocation, review, self.wallet.sign_payload(invocation, request)) + .await + }, + SignRequest::Raw(raw) | SignRequest::RawUnwatermarkedDeprecated(raw) => { + self.serve_sign_raw(cx, SignRawAuthorityRequest::Product(raw), watermarked) + .await + }, + SignRequest::RawWithLegacyAccountUnwatermarkedDeprecated(request) => { + self.serve_sign_raw_with_legacy_account(cx, request, false).await + }, + } + .map_err(|error| error.to_string()) + } + + async fn serve_sign_raw( + &self, + cx: &SsoRequestContext, + request: SignRawAuthorityRequest, + watermarked: bool, + ) -> Result { + let invocation = cx.account_invocation(None); + let review = request.review(invocation.caller, watermarked); + self.confirmed(invocation, review, self.wallet.sign_raw(invocation, request, watermarked)) + .await + } + + async fn serve_sign_raw_with_legacy_account( + &self, + cx: &SsoRequestContext, + request: SignRawWithLegacyAccountRequest, + watermarked: bool, + ) -> Result { + let public_request = api::HostSignRawWithLegacyAccountRequest { + signer: product_public_key_to_address(request.account), + payload: request.data, + }; + self.serve_sign_raw( + cx, + SignRawAuthorityRequest::IdentityAccount { + account: request.account, + request: public_request, + }, + watermarked, + ) + .await + } + + async fn serve_create_transaction( + &self, + cx: &SsoRequestContext, + request: CreateTransactionAuthorityRequest, + ) -> CreateTransactionResponse { + let invocation = cx.account_invocation(None); + let review = request.review(invocation.caller); + self.confirmed(invocation, review, self.wallet.create_transaction(invocation, request)) + .await + .map(|response| response.transaction) + .map_err(|error| error.to_string()) + } +} + +fn allocation_reply( + payload: Result, String>, + failures: Vec, +) -> SsoReply { + let mut outcome = resource_allocation_outcome(&payload); + if !failures.is_empty() { + let details = failures.join("; ").replace(['\r', '\n'], " "); + outcome.reason = Some(match outcome.reason { + Some(summary) => format!("{summary}: {details}"), + None => details, + }); + } + SsoReply::from(payload).with_outcome(outcome) +} + +/// Transcript outcome for an allocation batch: `ok` only when every requested +/// resource was allocated; otherwise `rejected`, `partial`, or `not_available` +/// with a count summary. +fn resource_allocation_outcome( + payload: &Result, String>, +) -> ResponseOutcome { + let outcomes = match payload { + Ok(outcomes) => outcomes, + Err(reason) => { + return ResponseOutcome { outcome: "error", reason: Some(reason.clone()) }; + }, + }; + let total = outcomes.len(); + let count = |wanted: fn(&SsoAllocationOutcome) -> bool| { + outcomes.iter().filter(|outcome| wanted(outcome)).count() + }; + let allocated = count(|outcome| matches!(outcome, SsoAllocationOutcome::Allocated(_))); + let rejected = count(|outcome| matches!(outcome, SsoAllocationOutcome::Rejected)); + let unavailable = count(|outcome| matches!(outcome, SsoAllocationOutcome::NotAvailable)); + if allocated == total { + return ResponseOutcome { outcome: "ok", reason: None }; + } + if allocated > 0 { + let mut reason = format!("{allocated} of {total} requested resources allocated"); + if rejected > 0 { + reason.push_str(&format!("; {rejected} rejected")); + } + if unavailable > 0 { + reason.push_str(&format!("; {unavailable} unavailable")); + } + return ResponseOutcome { outcome: "partial", reason: Some(reason) }; + } + if rejected > 0 { + let reason = if rejected == total { + if total == 1 { + "Requested resource was rejected".to_string() + } else { + format!("All {total} requested resources were rejected") + } + } else { + format!("No resources allocated; {rejected} rejected; {unavailable} unavailable") + }; + return ResponseOutcome { outcome: "rejected", reason: Some(reason) }; + } + ResponseOutcome { + outcome: "not_available", + reason: Some(if total == 1 { + "Requested resource is not available".to_string() + } else { + format!("None of the {total} requested resources are available") + }), + } +} + +#[truapi_macros::sso_service] +impl SsoAccountHolderService { + async fn sign(&self, cx: &SsoRequestContext, request: SignRequest) -> SignResponse { + let payload = self.serve_sign(cx, request).await; + if let Err(reason) = &payload { + warn!(%reason, "sign request failed"); + } + payload + } + + async fn get_account_alias( + &self, + cx: &SsoRequestContext, + request: ProductRequest, + ) -> GetAccountAliasResponse { + self.wallet + .account_alias( + cx.account_invocation(Some(&request.calling_product_id)), + request.payload, + ) + .await + } + + async fn resource_allocation( + &self, + cx: &SsoRequestContext, + request: ResourceAllocationRequest, + ) -> ResourceAllocationResponse { + let mut failures = Vec::new(); + let payload = async { + let count = request.resources.len(); + let invocation = cx.account_invocation(Some(&request.calling_product_id)); + let review = UserConfirmationReview::ResourceAllocation(ResourceAllocationReview { + calling_product_id: request.calling_product_id.clone(), + resources: request.resources.clone(), + }); + let allocation = self.wallet.allocate_grants( + invocation, + api::HostRequestResourceAllocationRequest { resources: request.resources }, + request.on_existing, + ); + let mut grants = match self.confirmed(invocation, review, allocation).await { + Ok(grants) => grants, + Err(AuthorityError::Rejected) => { + return Ok(vec![SsoAllocationOutcome::Rejected; count]); + }, + Err(error) => return Err(error.to_string()), + }; + let mut outcomes = Vec::with_capacity(count); + while let Some(grant) = grants.next().await { + outcomes.push(match grant { + Ok(AccountGrantOutcome::Allocated(grant)) => { + SsoAllocationOutcome::Allocated(match grant { + AccountGrant::StatementStore { key, .. } => { + SsoAllocatedResource::StatementStoreAllowance { + slot_account_key: key.as_secret_bytes().to_vec(), + } + }, + AccountGrant::Bulletin(key) => { + SsoAllocatedResource::BulletinAllowance { + slot_account_key: key.as_secret_bytes().to_vec(), + } + }, + AccountGrant::SmartContract => { + SsoAllocatedResource::SmartContractAllowance + }, + AccountGrant::AutoSigning(key) => SsoAllocatedResource::AutoSigning { + product_root_private_key: *key.as_secret_bytes(), + ring_vrf_domain_entropy: *key.ring_vrf_domain_entropy(), + }, + }) + }, + Ok(AccountGrantOutcome::Rejected) => { + failures.push(AuthorityError::Rejected.to_string()); + SsoAllocationOutcome::NotAvailable + }, + Ok(AccountGrantOutcome::NotAvailable { reason }) => { + if let Some(reason) = reason { + warn!(%reason, "resource allocation item failed"); + failures.push(reason); + } + SsoAllocationOutcome::NotAvailable + }, + Err(error) => return Err(error.to_string()), + }); + } + Ok(outcomes) + } + .await; + if let Err(reason) = &payload { + warn!(%reason, "resource allocation request failed"); + } + allocation_reply(payload, failures) + } + + async fn create_transaction( + &self, + cx: &SsoRequestContext, + request: CreateTransactionRequest, + ) -> CreateTransactionResponse { + let CreateTransactionPayload::V1(payload) = request.payload; + let payload = payload.into_product_payload(); + self.serve_create_transaction(cx, CreateTransactionAuthorityRequest::Product(payload)) + .await + } + + async fn create_transaction_with_legacy_account( + &self, + cx: &SsoRequestContext, + request: CreateTransactionWithLegacyAccountRequest, + ) -> CreateTransactionResponse { + let CreateTransactionLegacyPayload::V1(payload) = request.payload; + self.serve_create_transaction( + cx, + CreateTransactionAuthorityRequest::IdentityAccount(payload), + ) + .await + } + + async fn sign_raw_with_legacy_account( + &self, + cx: &SsoRequestContext, + request: SignRawWithLegacyAccountRequest, + ) -> SignRawWithLegacyAccountResponse { + self.serve_sign_raw_with_legacy_account(cx, request, true) + .await + .map(|response| response.signature) + .map_err(|error| error.to_string()) + } + + async fn create_account_proof( + &self, + cx: &SsoRequestContext, + request: ProductRequest, + ) -> CreateAccountProofResponse { + self.wallet + .create_proof(cx.account_invocation(Some(&request.calling_product_id)), request.payload) + .await + } + + async fn sign_vrf( + &self, + cx: &SsoRequestContext, + request: ProductRequest, + ) -> SignVrfResponse { + let invocation = cx.account_invocation(Some(&request.calling_product_id)); + async { + validate_vrf_transcript(&request.payload) + .map_err(|reason| AuthorityError::Unknown { reason })?; + normalize_product_identifier(&request.payload.account.dot_ns_identifier) + .map_err(|error| AuthorityError::Unavailable { reason: error.to_string() })?; + let review = UserConfirmationReview::SignVrf(SignVrfReview { + calling_product_id: request.calling_product_id.clone(), + request: request.payload.clone(), + }); + self.confirmed(invocation, review, self.wallet.sign_vrf(invocation, request.payload)) + .await + } + .await + .map_err(api::HostAccountSignVrfError::from) + } + + /// Consent-free product hard-subtree public key. + async fn product_subtree( + &self, + cx: &SsoRequestContext, + request: ProductSubtreeRequest, + ) -> ProductSubtreeResponse { + self.wallet + .product_subtree_public_key(cx.account_invocation(None), request.product_id) + .await + .map_err(|err| err.to_string())? + .resolve() + .await + .map_err(|err| err.to_string()) + } + + async fn register_ring_vrf_key( + &self, + cx: &SsoRequestContext, + request: ProductRequest, + ) -> RegisterRingVrfKeyResponse { + self.wallet + .register_ring_vrf_key( + cx.account_invocation(Some(&request.calling_product_id)), + request.payload, + ) + .await + } + + async fn list_ring_vrf_keys( + &self, + cx: &SsoRequestContext, + request: ProductRequest, + ) -> ListRingVrfKeysResponse { + self.wallet + .list_ring_vrf_keys( + cx.account_invocation(Some(&request.calling_product_id)), + request.payload, + ) + .await + } + + async fn ring_vrf_sign( + &self, + cx: &SsoRequestContext, + request: ProductRequest, + ) -> RingVrfSignResponse { + self.wallet + .ring_vrf_sign( + cx.account_invocation(Some(&request.calling_product_id)), + request.payload, + ) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resource_allocation_summary_reflects_per_resource_outcomes() { + let result = resource_allocation_outcome(&Ok(vec![SsoAllocationOutcome::Allocated( + SsoAllocatedResource::SmartContractAllowance, + )])); + assert_eq!((result.outcome, result.reason), ("ok", None)); + + let result = resource_allocation_outcome(&Ok(vec![SsoAllocationOutcome::Rejected])); + assert_eq!( + (result.outcome, result.reason.as_deref()), + ("rejected", Some("Requested resource was rejected")) + ); + + let result = resource_allocation_outcome(&Ok(vec![ + SsoAllocationOutcome::Allocated(SsoAllocatedResource::BulletinAllowance { + slot_account_key: vec![1; 64], + }), + SsoAllocationOutcome::Rejected, + SsoAllocationOutcome::NotAvailable, + ])); + assert_eq!( + (result.outcome, result.reason.as_deref()), + ("partial", Some("1 of 3 requested resources allocated; 1 rejected; 1 unavailable")) + ); + + let result = resource_allocation_outcome(&Ok(vec![SsoAllocationOutcome::NotAvailable])); + assert_eq!( + (result.outcome, result.reason.as_deref()), + ("not_available", Some("Requested resource is not available")) + ); + } + + #[test] + fn mixed_unallocated_resources_report_rejection() { + let result = resource_allocation_outcome(&Ok(vec![ + SsoAllocationOutcome::Rejected, + SsoAllocationOutcome::NotAvailable, + ])); + + assert_eq!( + (result.outcome, result.reason.as_deref()), + ("rejected", Some("No resources allocated; 1 rejected; 1 unavailable")) + ); + } + + #[test] + fn allocation_transcript_includes_single_line_item_failures() { + let answer = allocation_reply( + Ok(vec![SsoAllocationOutcome::NotAvailable]), + vec!["rpc\nfailed".to_string(), "provider\rdown".to_string()], + ) + .finish( + "allocation-1", + crate::host_internal::sso_messages::v1::RemoteMessage::ResourceAllocationResponse, + ); + + assert_eq!(answer.outcome.outcome, "not_available"); + assert_eq!( + answer.outcome.reason.as_deref(), + Some("Requested resource is not available: rpc failed; provider down") + ); + } +} diff --git a/rust/crates/truapi/src/runtime/sso_remote.rs b/rust/crates/truapi/src/runtime/sso_remote.rs index 65da5704ae..d059109d6d 100644 --- a/rust/crates/truapi/src/runtime/sso_remote.rs +++ b/rust/crates/truapi/src/runtime/sso_remote.rs @@ -64,6 +64,12 @@ impl SsoSessionKey { pub fn from_session(session: &SsoSessionInfo) -> Self { Self { own: session.session_id_own, peer: session.session_id_peer } } + /// Whether this channel is still selected by the canonical session. + pub fn matches(self, state: &crate::host_logic::session::SessionState) -> bool { + state.current().as_ref().is_some_and(|current| { + current.sso.as_ref().is_some_and(|sso| Self::from_session(sso) == self) + }) + } } /// Unregisters a disconnect waiter when the waiting call finishes. diff --git a/rust/crates/truapi/src/runtime/sso_request_service.rs b/rust/crates/truapi/src/runtime/sso_request_service.rs new file mode 100644 index 0000000000..707a9d7ac8 --- /dev/null +++ b/rust/crates/truapi/src/runtime/sso_request_service.rs @@ -0,0 +1,883 @@ +//! Paired session lifecycle and typed outbound SSO transport. + +mod channel; +mod pairing; +#[cfg(test)] +mod tests; + +use super::{ + HostSession, + auth_state::AuthStateMachine, + authority::{AuthoritySession, authority_session}, + connected_session_ui_info, + host_grants::GrantBarrier, + identity::resolve_session_identity_with_chain, + services::RuntimeServices, + sso_remote::{SSO_PEER_DISCONNECT_REASON, SessionDisconnects, SsoSessionKey}, + statement_store_rpc::StatementStoreRpc, + vrf, +}; +use crate::{ + chain_runtime::ChainRuntime, + host_logic::{ + session::{SessionInfo, SessionState, encode_persisted_session}, + session_store::SessionStoreChangeNotifier, + }, + platform::{CoreStorageKey, PairingHostConfig, Platform, ProductContext}, + session_usernames::SessionUsernames, + subscription::Spawner, +}; +use channel::SsoDisconnectMonitor; +use futures::{StreamExt, channel::oneshot}; +use pairing::{SsoPairingFlow, SsoPairingOutcome}; +#[cfg(test)] +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, Weak}; +use tracing::{instrument, warn}; +use truapi::{ + CallError, latest as api, + latest::{HostRequestLoginError, HostRequestLoginResponse}, +}; + +/// Keeps grants for the paired session and hears when that session changes. +#[async_trait::async_trait] +pub trait PairedSessionOwner: Send + Sync { + /// Hold back the owner's durable writes across a session change. + async fn write_barrier(&self) -> GrantBarrier; + + /// `previous` is no longer selected: `revoked` when it was cleared, not replaced. + /// + /// Runs while session selection is locked, so it must not call back into the service. + fn session_ended(&self, previous: Option<&SessionInfo>, revoked: bool); + + /// Drop grants loaded before a pending revocation; whether one is pending. + fn begin_cleanup(&self, barrier: &GrantBarrier) -> bool; + + /// Finish pending durable revocation; failures stay pending. + async fn finish_cleanup(&self, barrier: &GrantBarrier) -> Result<(), String>; + + /// Reconcile kept grants with `next` before it is published. + async fn prepare_session( + &self, + barrier: &GrantBarrier, + previous: Option<&SessionInfo>, + next: &SessionInfo, + ); +} + +/// The owner and its write barrier, held across one session change. +struct OwnerHold { + owner: Arc, + barrier: GrantBarrier, +} + +struct LoginInFlight { + waiters: Vec>>, +} + +struct LoginInFlightOwner<'a> { + host: &'a SsoRequestService, + active: bool, +} + +impl<'a> LoginInFlightOwner<'a> { + fn new(host: &'a SsoRequestService) -> Self { + Self { host, active: true } + } + + fn finish(&mut self, result: Result<(), String>) { + if self.active { + self.active = false; + self.host.finish_login_in_flight(result); + } + } +} + +impl Drop for LoginInFlightOwner<'_> { + fn drop(&mut self) { + if self.active { + self.host.finish_login_in_flight(Err("login request aborted".to_string())); + } + } +} + +#[derive(Default)] +struct Selection { + login_generation: u64, + session_epoch: u64, + external_session_active: bool, + pending_auth_deletion: bool, +} + +impl Selection { + fn advance_epoch(&mut self) -> u64 { + self.session_epoch = self.session_epoch.checked_add(1).expect("session epoch exhausted"); + self.session_epoch + } +} + +#[derive(Debug, derive_more::Display)] +enum StoredSessionActivationError { + #[display("stored auth session is absent")] + Missing, + #[display("invalid stored auth session: {_0}")] + Invalid(String), + #[display("failed to read stored auth session: {_0}")] + Read(String), + #[display("stored auth session changed during activation")] + Changed, + #[display("failed to clear previous session: {_0}")] + Cleanup(String), +} + +#[derive(Default)] +struct SessionStoreSync { + // Storage clears can notify this subscription; avoid repeating a failing clear. + cleared_after_read_error: bool, +} + +impl SessionStoreSync { + async fn reconcile(&mut self, service: &SsoRequestService) { + self.cleared_after_read_error = matches!( + service.reconcile_stored_session(!self.cleared_after_read_error, true).await, + Err(StoredSessionActivationError::Read(_)) + ); + } +} + +/// Canonical paired session selection, cleanup and request transport. +pub struct SsoRequestService { + platform: Arc, + host_config: PairingHostConfig, + chain: ChainRuntime, + session_state: Arc, + session_store_changes: Arc, + auth_state: AuthStateMachine, + statement_store: StatementStoreRpc, + session_disconnects: Arc, + newest_request: Mutex>, + disconnect_monitor: Mutex>, + login_in_flight: Mutex>, + selection: Mutex, + owner: Weak, + session_store_activation: futures::lock::Mutex<()>, + #[cfg(test)] + external_session_activation_pause: Mutex, oneshot::Receiver<()>)>>, + #[cfg(test)] + session_store_change_ticks: AtomicUsize, + weak_self: Weak, + spawner: Spawner, +} + +impl SsoRequestService { + /// Select paired sessions for `owner`, which keeps their grants. + pub fn new( + services: Arc, + host_config: PairingHostConfig, + owner: Weak, + ) -> Arc { + let platform = services.platform.clone(); + let auth_state = AuthStateMachine::new(platform.clone()); + Arc::new_cyclic(|weak_self| Self { + platform, + host_config, + chain: services.chain.clone(), + session_state: SessionState::new(), + session_store_changes: SessionStoreChangeNotifier::new(), + auth_state, + statement_store: services.statement_store.clone(), + session_disconnects: Arc::new(SessionDisconnects::default()), + newest_request: Mutex::new(None), + disconnect_monitor: Mutex::new(None), + login_in_flight: Mutex::new(None), + selection: Mutex::new(Selection::default()), + owner, + session_store_activation: futures::lock::Mutex::new(()), + #[cfg(test)] + external_session_activation_pause: Mutex::new(None), + #[cfg(test)] + session_store_change_ticks: AtomicUsize::new(0), + weak_self: weak_self.clone(), + spawner: services.spawner.clone(), + }) + } + + /// Invalidate stale work and request a persisted-session reread. + pub fn notify_session_store_changed(&self) { + self.advance_session_lifecycle(); + self.session_store_changes.notify(); + } + + fn advance_session_lifecycle(&self) -> u64 { + let mut selection = self.selection.lock().expect("session selection mutex poisoned"); + selection.external_session_active = false; + selection.advance_epoch() + } + + fn session_epoch(&self) -> u64 { + self.selection.lock().expect("session selection mutex poisoned").session_epoch + } + + /// The owner's barrier, or nothing to order once the owner is gone. + async fn hold_owner(&self) -> Option { + let owner = self.owner.upgrade()?; + let barrier = owner.write_barrier().await; + Some(OwnerHold { owner, barrier }) + } + + /// `message_id` of the request the session's request channel carries. + #[cfg(test)] + pub fn newest_request_for_tests(&self) -> Option { + self.newest_request.lock().expect("newest request mutex poisoned").clone() + } + + /// Change notifications the sync task has finished reconciling. + #[cfg(test)] + pub fn session_store_change_ticks_for_tests(&self) -> usize { + self.session_store_change_ticks.load(Ordering::SeqCst) + } + + /// Pause replacement before the guarded installation for race tests. + #[cfg(test)] + pub fn pause_external_session_activation_for_tests( + &self, + ) -> (oneshot::Receiver<()>, oneshot::Sender<()>) { + let (entered_tx, entered_rx) = oneshot::channel(); + let (resume_tx, resume_rx) = oneshot::channel(); + *self + .external_session_activation_pause + .lock() + .expect("external activation pause mutex poisoned") = Some((entered_tx, resume_rx)); + (entered_rx, resume_tx) + } + + #[cfg(test)] + async fn wait_at_external_session_activation_pause(&self) { + let pause = self + .external_session_activation_pause + .lock() + .expect("external activation pause mutex poisoned") + .take(); + if let Some((entered, resume)) = pause { + let _ = entered.send(()); + let _ = resume.await; + } + } + + /// Selected remote account identity. + pub fn current_session(&self) -> Option { + self.session_state.current().as_ref().map(authority_session) + } + + /// Start the disconnect monitor when a session is already active. + #[cfg(test)] + pub fn start_remote_monitor_for_current_session(&self) { + if let Some(session) = self.session_state.current() { + channel::start_disconnect_monitor(self, &session); + } + } + + /// Install an external canonical session without copying it into storage. + pub async fn activate_external_session(&self, blob: &[u8]) -> Result<(), String> { + let installed = self.install_external_session(blob).await; + self.auth_state.announce_current(); + installed + } + + async fn install_external_session(&self, blob: &[u8]) -> Result<(), String> { + let _activation = self.session_store_activation.lock().await; + let session = crate::host_logic::session::decode_persisted_session(blob)?; + self.invalidate_login_attempts(); + let activation_epoch = self.advance_session_lifecycle(); + let resolved = resolve_session_identity_with_chain( + &self.chain, + self.host_config.asset_hub_chain_genesis_hash, + session, + ) + .await; + #[cfg(test)] + self.wait_at_external_session_activation_pause().await; + self.set_connected_session_if_current(resolved, activation_epoch, true).await?; + Ok(()) + } + + /// Restore the persisted session and report the resulting connection state. + pub async fn activate_stored_session(&self) -> Result<(), String> { + let restored = self + .reconcile_stored_session(true, false) + .await + .map_err(|error| error.to_string()); + self.auth_state.announce_current(); + restored + } + + async fn reconcile_stored_session( + &self, + clear_after_read_error: bool, + preserve_external_session: bool, + ) -> Result<(), StoredSessionActivationError> { + let _activation = self.session_store_activation.lock().await; + if preserve_external_session && + self.selection + .lock() + .expect("session selection mutex poisoned") + .external_session_active + { + return Ok(()); + } + let (activation_epoch, read) = { + let hold = self.hold_owner().await; + self.drain_session_deletions(hold.as_ref()) + .await + .map_err(StoredSessionActivationError::Cleanup)?; + let epoch = self.advance_session_lifecycle(); + let read = self.platform.read_core_storage(CoreStorageKey::AuthSession).await; + (epoch, read) + }; + let blob = match read { + Ok(Some(blob)) => blob, + Ok(None) => { + self.clear_disconnected_session(false, Some(activation_epoch)).await; + return Err(StoredSessionActivationError::Missing); + }, + Err(error) => { + self.clear_disconnected_session(clear_after_read_error, Some(activation_epoch)) + .await; + return Err(StoredSessionActivationError::Read(error.reason)); + }, + }; + let session = match crate::host_logic::session::decode_persisted_session(&blob) { + Ok(session) => session, + Err(error) => { + self.clear_disconnected_session(true, Some(activation_epoch)).await; + return Err(StoredSessionActivationError::Invalid(error)); + }, + }; + let resolved = resolve_session_identity_with_chain( + &self.chain, + self.host_config.asset_hub_chain_genesis_hash, + session, + ) + .await; + let hold = self.hold_owner().await; + self.drain_session_deletions(hold.as_ref()) + .await + .map_err(StoredSessionActivationError::Cleanup)?; + if self.session_epoch() != activation_epoch { + return Err(StoredSessionActivationError::Changed); + } + let latest = self.platform.read_core_storage(CoreStorageKey::AuthSession).await; + let error = match latest { + Ok(latest) if latest.as_deref() == Some(blob.as_slice()) => None, + Ok(_) => Some((false, StoredSessionActivationError::Changed)), + Err(error) => { + Some((clear_after_read_error, StoredSessionActivationError::Read(error.reason))) + }, + }; + if let Some((clear_auth, error)) = error { + if self.begin_session_clear(clear_auth, Some(activation_epoch), None) && + let Err(reason) = self.drain_session_deletions(hold.as_ref()).await + { + warn!(%reason, "session cleanup remains pending"); + } + return Err(error); + } + self.prepare_session_installation(hold.as_ref(), &resolved) + .await + .map_err(StoredSessionActivationError::Cleanup)?; + if self.session_epoch() != activation_epoch { + return Err(StoredSessionActivationError::Changed); + } + let resolved_blob = encode_persisted_session(&resolved); + if !self.install_session_if_current(resolved, activation_epoch, false, None) { + return Err(StoredSessionActivationError::Changed); + } + if resolved_blob != blob && self.session_epoch() == activation_epoch { + let _ = self + .platform + .write_core_storage(CoreStorageKey::AuthSession, resolved_blob) + .await; + } + if let Err(reason) = self.drain_session_deletions(hold.as_ref()).await { + warn!(%reason, "session cleanup remains pending"); + } + Ok(()) + } + + /// Reconcile at boot and after storage notifications, reporting auth state. + #[instrument(skip_all, fields(runtime.method = "session_store.sync"))] + pub fn start_session_store_sync(self: Arc, spawner: Spawner) { + let service = Arc::downgrade(&self); + drop(self); + spawner(Box::pin(async move { + let Some(booting) = service.upgrade() else { + return; + }; + let mut ticks = booting.session_store_changes.subscribe(); + let mut sync = SessionStoreSync::default(); + sync.reconcile(&booting).await; + booting.auth_state.announce_current(); + drop(booting); + while ticks.next().await.is_some() { + let Some(service) = service.upgrade() else { + break; + }; + sync.reconcile(&service).await; + #[cfg(test)] + service.session_store_change_ticks.fetch_add(1, Ordering::SeqCst); + } + })); + } + + async fn commit_login_session( + &self, + session: &SessionInfo, + generation: u64, + ) -> Result { + let hold = self.hold_owner().await; + if !self.is_current_login_attempt(generation) { + return Ok(false); + } + self.prepare_session_installation(hold.as_ref(), session) + .await + .map_err(|reason| truapi::latest::GenericError { reason })?; + let mut epoch = { + let mut selection = self.selection.lock().expect("session selection mutex poisoned"); + if selection.login_generation != generation { + return Ok(false); + } + selection.pending_auth_deletion = true; + selection.external_session_active = false; + selection.advance_epoch() + }; + let blob = encode_persisted_session(session); + self.platform + .write_core_storage(CoreStorageKey::AuthSession, blob.clone()) + .await?; + while self.is_current_login_attempt(generation) { + let latest_epoch = self.session_epoch(); + if latest_epoch != epoch { + let latest = self.platform.read_core_storage(CoreStorageKey::AuthSession).await?; + let mut selection = + self.selection.lock().expect("session selection mutex poisoned"); + if selection.login_generation != generation { + break; + } + if selection.session_epoch != latest_epoch { + continue; + } + if latest.as_deref() != Some(blob.as_slice()) { + selection.pending_auth_deletion = false; + return Ok(false); + } + epoch = latest_epoch; + } + if self.install_session_if_current(session.clone(), epoch, false, Some(generation)) { + return Ok(true); + } + } + self.drain_session_deletions(hold.as_ref()) + .await + .map_err(|reason| truapi::latest::GenericError { reason })?; + Ok(false) + } + + async fn discard_login_session(&self) { + let hold = self.hold_owner().await; + if let Err(reason) = self.drain_session_deletions(hold.as_ref()).await { + warn!(%reason, "cancelled login cleanup remains pending"); + } + } + + /// Discard pairing bootstrap material so the next login generates a new + /// device keypair and topic. + pub async fn forget_pairing_identity(&self) -> Result<(), String> { + self.platform + .clear_core_storage(CoreStorageKey::PairingDeviceIdentity) + .await + .map_err(|error| { + format!("session disconnected, but pairing identity reset failed: {}", error.reason) + })?; + self.platform + .clear_core_storage(CoreStorageKey::LastProcessedPairingStatement) + .await + .map_err(|error| { + format!( + "session disconnected and pairing identity reset, but pairing history reset failed: {}", + error.reason + ) + }) + } + + /// Clear the paired session and capabilities without notifying the peer. + pub async fn reset_session_state(&self) { + self.cancel_login(); + self.clear_disconnected_session(true, None).await; + self.auth_state.announce_current(); + } + + /// Invalidate in-flight login attempts and emit the cancelled auth state. + #[instrument(skip_all, fields(runtime.method = "account.cancel_login"))] + pub fn cancel_login(&self) { + self.invalidate_login_attempts(); + self.auth_state.login_cancelled(); + } + + fn begin_login_attempt(&self) -> u64 { + let mut selection = self.selection.lock().expect("session selection mutex poisoned"); + selection.login_generation = selection.login_generation.wrapping_add(1); + selection.login_generation + } + + fn invalidate_login_attempts(&self) { + let mut selection = self.selection.lock().expect("session selection mutex poisoned"); + selection.login_generation = selection.login_generation.wrapping_add(1); + } + + fn is_current_login_attempt(&self, generation: u64) -> bool { + self.selection + .lock() + .expect("session selection mutex poisoned") + .login_generation == + generation + } + + fn login_waiter(&self) -> Option>> { + let mut in_flight = self.login_in_flight.lock().expect("login in-flight mutex poisoned"); + if let Some(in_flight) = in_flight.as_mut() { + let (tx, rx) = oneshot::channel(); + in_flight.waiters.push(tx); + Some(rx) + } else { + *in_flight = Some(LoginInFlight { waiters: Vec::new() }); + None + } + } + + fn finish_login_in_flight(&self, result: Result<(), String>) { + let waiters = self + .login_in_flight + .lock() + .expect("login in-flight mutex poisoned") + .take() + .map(|in_flight| in_flight.waiters) + .unwrap_or_default(); + for waiter in waiters { + let _ = waiter.send(result.clone()); + } + } + + fn begin_session_clear( + &self, + clear_auth_session: bool, + expected_epoch: Option, + expected_peer: Option, + ) -> bool { + let mut selection = self.selection.lock().expect("session selection mutex poisoned"); + if expected_epoch.is_some_and(|epoch| selection.session_epoch != epoch) || + expected_peer.is_some_and(|key| !self.current_sso_session_matches(key)) + { + return false; + } + let previous = self.session_state.current(); + selection.advance_epoch(); + if let Some(owner) = self.owner.upgrade() { + owner.session_ended(previous.as_ref(), true); + } + selection.pending_auth_deletion |= clear_auth_session; + selection.external_session_active = false; + self.session_state.clear_session(); + let monitor = channel::detach_session_channel(self); + drop(selection); + channel::stop_session_channel(self, previous.as_ref(), monitor); + true + } + + async fn drain_session_deletions(&self, hold: Option<&OwnerHold>) -> Result<(), String> { + let pending_auth_deletion = self + .selection + .lock() + .expect("session selection mutex poisoned") + .pending_auth_deletion; + let pending_grants = hold.is_some_and(|hold| hold.owner.begin_cleanup(&hold.barrier)); + if !pending_auth_deletion && !pending_grants { + return Ok(()); + } + self.auth_state.store_disconnected(); + let auth_result = if pending_auth_deletion { + self.platform + .clear_core_storage(CoreStorageKey::AuthSession) + .await + .map(|()| { + self.selection + .lock() + .expect("session selection mutex poisoned") + .pending_auth_deletion = false + }) + .map_err(|error| error.reason) + } else { + Ok(()) + }; + let grants_result = match hold { + Some(hold) => hold.owner.finish_cleanup(&hold.barrier).await, + None => Ok(()), + }; + auth_result.and(grants_result) + } + + #[instrument(skip_all, fields(runtime.method = "session_store.clear_disconnected"))] + async fn clear_disconnected_session( + &self, + clear_auth_session: bool, + expected_epoch: Option, + ) { + if !self.begin_session_clear(clear_auth_session, expected_epoch, None) { + return; + } + let hold = self.hold_owner().await; + if let Err(reason) = self.drain_session_deletions(hold.as_ref()).await { + warn!(%reason, "session cleanup remains pending"); + } + } + + async fn prepare_session_installation( + &self, + hold: Option<&OwnerHold>, + session: &SessionInfo, + ) -> Result<(), String> { + self.drain_session_deletions(hold).await?; + if let Some(hold) = hold { + hold.owner + .prepare_session(&hold.barrier, self.session_state.current().as_ref(), session) + .await; + } + Ok(()) + } + + async fn set_connected_session_if_current( + &self, + session: SessionInfo, + activation_epoch: u64, + external_session: bool, + ) -> Result { + let hold = self.hold_owner().await; + if self.session_epoch() != activation_epoch { + return Ok(false); + } + self.prepare_session_installation(hold.as_ref(), &session).await?; + Ok(self.install_session_if_current(session, activation_epoch, external_session, None)) + } + + fn install_session_if_current( + &self, + session: SessionInfo, + activation_epoch: u64, + external_session: bool, + expected_login_generation: Option, + ) -> bool { + let detached = { + let mut selection = self.selection.lock().expect("session selection mutex poisoned"); + if expected_login_generation + .is_some_and(|expected| expected != selection.login_generation) + { + return false; + } + if selection.session_epoch != activation_epoch { + return false; + } + if expected_login_generation.is_none() { + selection.login_generation = selection.login_generation.wrapping_add(1); + } + let previous = self.session_state.current(); + let detached = (previous.as_ref() != Some(&session)).then(|| { + if let Some(owner) = self.owner.upgrade() { + owner.session_ended(previous.as_ref(), false); + } + let monitor = channel::detach_session_channel(self); + (previous, monitor) + }); + selection.pending_auth_deletion = false; + self.session_state.set_session(session.clone()); + selection.external_session_active = external_session; + detached + }; + if let Some((previous, monitor)) = detached { + channel::stop_session_channel(self, previous.as_ref(), monitor); + } + channel::start_disconnect_monitor(self, &session); + vrf::prefetch(&self.spawner); + self.auth_state.connected(&connected_session_ui_info(&session)); + true + } + + /// Install a selected session through the real lifecycle. + #[cfg(test)] + pub async fn set_connected_session_for_tests(&self, session: SessionInfo) { + let epoch = self.advance_session_lifecycle(); + self.set_connected_session_if_current(session, epoch, false) + .await + .expect("test session installs"); + } + + // Stale peer notifications wake only their own waiters. + async fn handle_signing_host_disconnected(&self, key: SsoSessionKey) { + self.session_disconnects.notify_key(key, SSO_PEER_DISCONNECT_REASON); + if !self.begin_session_clear(true, None, Some(key)) { + return; + } + let hold = self.hold_owner().await; + if let Err(reason) = self.drain_session_deletions(hold.as_ref()).await { + warn!(%reason, "session cleanup remains pending"); + } + } + + fn current_sso_session_matches(&self, key: SsoSessionKey) -> bool { + key.matches(&self.session_state) + } + + /// Resolve missing identity information for the selected session. + async fn refresh_current_session_identity(&self) -> Option { + let (current, epoch) = { + let selection = self.selection.lock().expect("session selection mutex poisoned"); + (self.session_state.current()?, selection.session_epoch) + }; + if current.has_username() || self.host_config.asset_hub_chain_genesis_hash == [0; 32] { + return Some(authority_session(¤t)); + } + + let resolved = resolve_session_identity_with_chain( + &self.chain, + self.host_config.asset_hub_chain_genesis_hash, + current.clone(), + ) + .await; + if !resolved.has_username() || resolved == current { + return self.current_session(); + } + + let hold = self.hold_owner().await; + if let Err(reason) = self.drain_session_deletions(hold.as_ref()).await { + warn!(%reason, "session cleanup remains pending"); + return self.current_session(); + } + { + let selection = self.selection.lock().expect("session selection mutex poisoned"); + if selection.session_epoch != epoch || + !self.session_state.replace_session_if_current(¤t, resolved.clone()) + { + return self.current_session(); + } + } + self.auth_state.connected(&connected_session_ui_info(&resolved)); + if let Err(err) = self + .platform + .write_core_storage(CoreStorageKey::AuthSession, encode_persisted_session(&resolved)) + .await + { + warn!(reason = %err.reason, "refreshed session identity persist failed"); + } + if let Err(reason) = self.drain_session_deletions(hold.as_ref()).await { + warn!(%reason, "session cleanup remains pending"); + } + self.current_session() + } +} + +#[crate::platform::async_trait] +impl HostSession for SsoRequestService { + /// Shared session holder for connection-status subscriptions. + fn session_state(&self) -> Arc { + self.session_state.clone() + } + + /// Start or join the current pairing attempt. + #[instrument(skip_all, fields(runtime.method = "account.request_login", product = %product.product_id))] + async fn request_login( + &self, + product: &ProductContext, + ) -> Result> { + let _ = product; + if let Some(session) = self.session_state.current() { + self.auth_state.connected(&connected_session_ui_info(&session)); + return Ok(api::HostRequestLoginResponse::AlreadyConnected); + } + + if let Some(waiter) = self.login_waiter() { + match waiter.await { + Ok(Ok(())) => { + return Ok(if self.session_state.current().is_some() { + api::HostRequestLoginResponse::AlreadyConnected + } else { + api::HostRequestLoginResponse::Rejected + }); + }, + Ok(Err(reason)) => { + return Err(CallError::Domain(api::HostRequestLoginError::Unknown { reason })); + }, + Err(_) => { + return Err(CallError::Domain(api::HostRequestLoginError::Unknown { + reason: "login waiter dropped".to_string(), + })); + }, + } + } + + let mut login_owner = LoginInFlightOwner::new(self); + let login_generation = self.begin_login_attempt(); + let outcome = match SsoPairingFlow::new(self, login_generation).request_session().await { + Ok(outcome) => outcome, + Err(err) => { + login_owner.finish(Err(login_error_reason(&err))); + return Err(err); + }, + }; + match outcome { + SsoPairingOutcome::Cancelled => { + login_owner.finish(Ok(())); + if self.session_state.current().is_some() { + Ok(api::HostRequestLoginResponse::AlreadyConnected) + } else { + Ok(api::HostRequestLoginResponse::Rejected) + } + }, + SsoPairingOutcome::Success => { + login_owner.finish(Ok(())); + Ok(api::HostRequestLoginResponse::Success) + }, + } + } + + /// Revoke the paired session and notify its peer. + #[instrument(skip_all, fields(runtime.method = "account.disconnect"))] + async fn disconnect(&self) { + self.cancel_login(); + let session = self.session_state.current(); + self.clear_disconnected_session(true, None).await; + if let Some(session) = session { + let weak_self = self.weak_self.clone(); + (self.spawner)(Box::pin(async move { + if let Some(host) = weak_self.upgrade() { + let _ = channel::submit_disconnected_message(&host, &session).await; + } + })); + } + } + + async fn primary_username(&self) -> Option { + self.refresh_current_session_identity() + .await? + .primary_username() + .map(str::to_string) + } +} + +fn login_error_reason(err: &CallError) -> String { + match err { + CallError::Domain(api::HostRequestLoginError::Unknown { reason }) | + CallError::HostFailure { reason } => reason.clone(), + CallError::Unsupported => "login unsupported".to_string(), + CallError::Denied => "login denied".to_string(), + CallError::MalformedFrame { reason } => reason.clone(), + CallError::Cancelled => "login cancelled".to_string(), + } +} diff --git a/rust/crates/truapi/src/runtime/sso_request_service/channel.rs b/rust/crates/truapi/src/runtime/sso_request_service/channel.rs new file mode 100644 index 0000000000..87c591505a --- /dev/null +++ b/rust/crates/truapi/src/runtime/sso_request_service/channel.rs @@ -0,0 +1,383 @@ +//! SSO statement-store channel to the paired remote signing host. + +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; + +use super::{ + super::{ + sso_remote::{ + RemoteResponseWait, SSO_LOCAL_DISCONNECT_REASON, SsoRemoteResponseError, SsoSessionKey, + fresh_statement_expiry, reply_matcher, sso_message_id, statement_subscription_stream, + subscribe_statement_topic, wait_for_sso_remote_response, + }, + statement_store_rpc::{self, StatementStoreRpc}, + }, + SsoRequestService, +}; +use crate::{ + host_internal::{ + sso_messages::{ + RemoteMessage, RemoteMessageData, SsoSessionStatement, Withdrawal, + build_outgoing_request_statement, decode_sso_session_statement, v1, + }, + sso_wire::SsoRequest, + }, + host_logic::{ + session::{SessionInfo, SsoSessionInfo}, + statement_store::parse_new_statements_result, + }, +}; + +use futures::{ + FutureExt, + future::{AbortHandle, Abortable}, +}; +use tracing::{debug, instrument, warn}; +use truapi::{CallContext, CancellationReason}; + +/// Active peer-disconnect watcher for one SSO session; aborts on drop. +pub struct SsoDisconnectMonitor { + key: SsoSessionKey, + abort: AbortHandle, +} + +impl Drop for SsoDisconnectMonitor { + fn drop(&mut self) { + self.abort.abort(); + } +} + +struct RequestWithdrawal<'a> { + service: &'a SsoRequestService, + session: &'a SsoSessionInfo, + message_id: &'a str, + call: &'a CallContext, + submitting: &'a AtomicBool, + armed: bool, +} + +impl Drop for RequestWithdrawal<'_> { + fn drop(&mut self) { + if self.armed && + self.call.cancel().reason() == Some(CancellationReason::Cancelled) && + self.submitting.load(Ordering::Acquire) && + SsoSessionKey::from_session(self.session).matches(&self.service.session_state) + { + self.service.withdraw_request(self.session, self.message_id); + } + } +} + +/// Watch the session's topics for a peer disconnect statement, replacing +/// any monitor for a different session. No-op when one is already running +/// for this session. +pub fn start_disconnect_monitor(service: &SsoRequestService, session: &SessionInfo) { + let Some(sso) = session.sso.clone() else { + return; + }; + let key = SsoSessionKey::from_session(&sso); + + let (registration, spawner, previous) = { + let _selection = service.selection.lock().expect("session selection mutex poisoned"); + if !service.current_sso_session_matches(key) { + return; + } + let mut current = service + .disconnect_monitor + .lock() + .expect("SSO disconnect monitor mutex poisoned"); + if current.as_ref().is_some_and(|active| active.key == key) { + return; + } + let (abort, registration) = AbortHandle::new_pair(); + let previous = current.replace(SsoDisconnectMonitor { key, abort }); + (registration, service.spawner.clone(), previous) + }; + drop(previous); + + let statement_store = service.statement_store.clone(); + let service = service.weak_self.clone(); + let future = async move { + let result = wait_for_sso_peer_disconnect(statement_store, sso).await; + let Some(service) = service.upgrade() else { + return; + }; + { + let mut active = service + .disconnect_monitor + .lock() + .expect("SSO disconnect monitor mutex poisoned"); + if active.as_ref().is_some_and(|active| active.key == key) { + *active = None; + } + } + match result { + Ok(()) => { + service.handle_signing_host_disconnected(key).await; + }, + Err(reason) => { + warn!(%reason, "SSO peer disconnect monitor stopped"); + }, + } + }; + spawner(Box::pin(Abortable::new(future, registration).map(|_| ()))); +} + +/// Detach channel state while session selection is locked. +pub fn detach_session_channel(service: &SsoRequestService) -> Option { + *service.newest_request.lock().expect("newest request mutex poisoned") = None; + service + .disconnect_monitor + .lock() + .expect("SSO disconnect monitor mutex poisoned") + .take() +} + +/// Wake detached channel work after releasing the lifecycle lock. +pub fn stop_session_channel( + service: &SsoRequestService, + session: Option<&SessionInfo>, + monitor: Option, +) { + drop(monitor); + if let Some(sso) = session.and_then(|session| session.sso.as_ref()) { + service.session_disconnects.notify(sso, SSO_LOCAL_DISCONNECT_REASON); + } +} + +/// Best-effort `Disconnected` notification to the SSO peer. +#[instrument(skip_all, fields(runtime.method = "sso.disconnect.submit"))] +pub async fn submit_disconnected_message( + service: &SsoRequestService, + session: &SessionInfo, +) -> Result<(), String> { + let sso = session.sso.as_ref().ok_or_else(|| "No SSO session state".to_string())?; + let message_id = sso_message_id(); + let message = RemoteMessage { + message_id: message_id.clone(), + data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), + }; + let statement = service.build_request_channel_statement(sso, message_id, message, None)?; + service + .statement_store + .submit_fire_and_forget(statement, "SSO statement-store") + .await + .map_err(|err| format!("SSO statement submit failed: {err}"))?; + Ok(()) +} + +impl SsoRequestService { + /// Build a statement on the session's request channel, recording + /// `newest` as the request the channel now carries. + /// + /// The store keeps one statement per channel and the later build wins, so + /// the record and the build share one lock. + fn build_request_channel_statement( + &self, + sso: &SsoSessionInfo, + statement_request_id: String, + message: RemoteMessage, + newest: Option, + ) -> Result, String> { + let mut newest_request = self.newest_request.lock().expect("newest request mutex poisoned"); + let statement = build_outgoing_request_statement( + sso, + statement_request_id, + vec![message], + fresh_statement_expiry(), + )?; + *newest_request = newest; + Ok(statement) + } + + /// Withdraw the request sent as `message_id` from the paired host. + /// + /// A `Cancel` replaces the newest statement on the request channel, so it + /// is sent only while that is the request it names; otherwise it would + /// replace another request instead. Sent in the background, so it neither + /// holds up the withdrawn call's answer nor dies with its unwind grace. + fn withdraw_request(&self, sso: &SsoSessionInfo, message_id: &str) { + let withdrawal = { + let mut newest_request = + self.newest_request.lock().expect("newest request mutex poisoned"); + if newest_request.as_deref() != Some(message_id) { + return; + } + let cancel_id = sso_message_id(); + let message = RemoteMessage { + message_id: cancel_id.clone(), + data: RemoteMessageData::V1(v1::RemoteMessage::Cancel(Withdrawal { + message_id: message_id.to_string(), + })), + }; + *newest_request = None; + build_outgoing_request_statement( + sso, + cancel_id, + vec![message], + fresh_statement_expiry(), + ) + }; + let statement_store = self.statement_store.clone(); + let message_id = message_id.to_string(); + (self.spawner)(Box::pin(async move { + let submitted = match withdrawal { + Ok(statement) => statement_store + .submit_fire_and_forget(statement, "SSO statement-store") + .await + .map_err(|err| err.to_string()), + Err(reason) => Err(reason), + }; + if let Err(reason) = submitted { + warn!(%message_id, %reason, "could not withdraw the SSO request"); + } + })); + } + + /// Send `request` to the paired signing host and await its typed answer. + /// + /// The outer error is the transport's; the inner result is the peer's + /// payload for this request type. + #[instrument(skip_all, fields(runtime.method = "sso.remote_message.submit", action = R::NAME))] + pub async fn call( + &self, + cx: &CallContext, + session: &SessionInfo, + request: R, + ) -> Result { + let sso = session + .sso + .as_ref() + .ok_or_else(|| SsoRemoteResponseError::Failure("No SSO session state".to_string()))?; + let key = SsoSessionKey::from_session(sso); + let (_disconnect_guard, disconnect) = self.session_disconnects.subscribe(sso); + if !key.matches(&self.session_state) { + return Err(SsoRemoteResponseError::LocalDisconnected); + } + let message_id = sso_message_id(); + let statement = self + .build_request_channel_statement( + sso, + message_id.clone(), + RemoteMessage::request(message_id.clone(), request), + Some(message_id.clone()), + ) + .map_err(SsoRemoteResponseError::Failure)?; + let rpc_client = self + .statement_store + .client("SSO statement-store") + .await + .map_err(|err| SsoRemoteResponseError::Failure(err.to_string()))?; + let own_subscription = subscribe_statement_topic(&rpc_client, sso.session_id_own) + .await + .map_err(|err| { + SsoRemoteResponseError::Failure(format!( + "SSO own statement-store subscribe failed: {err}" + )) + })?; + let peer_subscription = subscribe_statement_topic(&rpc_client, sso.session_id_peer) + .await + .map_err(|err| { + SsoRemoteResponseError::Failure(format!( + "SSO peer statement-store subscribe failed: {err}" + )) + })?; + let submit_client = rpc_client.clone(); + let session_state = self.session_state.clone(); + let submitting = Arc::new(AtomicBool::new(false)); + let submit_started = submitting.clone(); + let submit = async move { + if !key.matches(&session_state) { + return Err(SsoRemoteResponseError::LocalDisconnected); + } + submit_started.store(true, Ordering::Release); + statement_store_rpc::submit_sso(&submit_client, statement, "pairing-host request") + .await + .map_err(|err| { + SsoRemoteResponseError::Failure(format!("SSO statement submit failed: {err}")) + }) + } + .boxed(); + let action = R::NAME; + debug!(action, %message_id, "submitted SSO remote message, awaiting response"); + let mut withdrawal = RequestWithdrawal { + service: self, + session: sso, + message_id: &message_id, + call: cx, + submitting: &submitting, + armed: true, + }; + let result = wait_for_sso_remote_response( + RemoteResponseWait { + own_statements: statement_subscription_stream(own_subscription, "own"), + peer_statements: statement_subscription_stream(peer_subscription, "peer"), + submit, + session: sso, + statement_request_id: &message_id, + remote_message_id: &message_id, + cancel: cx.cancel(), + disconnect: Some(disconnect), + }, + reply_matcher::(&message_id), + ) + .await; + withdrawal.armed = matches!( + &result, + Err(SsoRemoteResponseError::Cancelled(error)) + if error.reason() == CancellationReason::Cancelled + ); + let result = result.map_err(|reason| match reason { + SsoRemoteResponseError::Cancelled(err) if !cx.request_id().is_empty() => { + SsoRemoteResponseError::Cancelled(err.with_remote_message_id(cx.request_id())) + }, + reason => reason, + }); + match &result { + Ok(_) => debug!(action, %message_id, "SSO remote response received"), + Err(reason) => warn!(action, %message_id, %reason, "SSO remote message failed"), + } + if matches!(&result, Err(SsoRemoteResponseError::PeerDisconnected)) { + self.handle_signing_host_disconnected(key).await; + } + result.map(|response| response.payload) + } +} + +#[instrument(skip_all, fields(runtime.method = "sso.peer_disconnect.monitor"))] +async fn wait_for_sso_peer_disconnect( + statement_store: StatementStoreRpc, + session: SsoSessionInfo, +) -> Result<(), String> { + let rpc_client = statement_store + .client("SSO disconnect monitor") + .await + .map_err(|err| err.to_string())?; + let mut subscription = + statement_store_rpc::subscribe_match_all(&rpc_client, &[session.session_id_peer]) + .await + .map_err(|err| format!("SSO disconnect monitor subscribe failed: {err}"))?; + while let Some(item) = subscription.next().await { + let value = item.map_err(|err| format!("SSO disconnect monitor item failed: {err}"))?; + let page = parse_new_statements_result("sso-peer-disconnect-monitor".to_string(), &value) + .map_err(|err| err.to_string())?; + for statement in page.statements { + let Some(SsoSessionStatement::RemoteMessages(messages)) = decode_sso_session_statement( + &session, + &statement, + "truapi:sso-peer-disconnect-monitor", + )? + else { + continue; + }; + for message in messages { + if message == Ok(v1::RemoteMessage::Disconnected) { + return Ok(()); + } + } + } + } + Err("SSO disconnect monitor response stream ended".to_string()) +} diff --git a/rust/crates/truapi/src/runtime/sso_pairing.rs b/rust/crates/truapi/src/runtime/sso_request_service/pairing.rs similarity index 93% rename from rust/crates/truapi/src/runtime/sso_pairing.rs rename to rust/crates/truapi/src/runtime/sso_request_service/pairing.rs index 293ddde95f..5d1a22711e 100644 --- a/rust/crates/truapi/src/runtime/sso_pairing.rs +++ b/rust/crates/truapi/src/runtime/sso_request_service/pairing.rs @@ -15,12 +15,15 @@ use std::time::Duration; use web_time::Duration; use super::{ - auth_state::AuthStateMachine, identity::resolve_session_identity_with_chain, - pairing_host::PairingHost, statement_store_rpc, + super::{ + auth_state::AuthStateMachine, identity::resolve_session_identity_with_chain, + statement_store_rpc, + }, + SsoRequestService, }; use crate::{ host_logic::{ - session::{SessionInfo, encode_persisted_session}, + session::SessionInfo, sso::pairing::{ PairingBootstrap, PairingDeviceIdentity, VersionedHandshakeResponse, create_pairing_bootstrap_from_identity, decode_app_handshake_data, @@ -44,7 +47,7 @@ use subxt_rpcs::{RpcClient, client::RpcSubscription}; use tracing::{debug, info, instrument}; #[cfg(test)] use truapi::versioned::account::HostRequestLoginResponse; -use truapi::{CallError, latest as api, versioned::account::HostRequestLoginError}; +use truapi::{CallError, latest as api, latest::HostRequestLoginError}; #[cfg(not(test))] const PAIRING_QUERY_INTERVAL: Duration = Duration::from_secs(2); @@ -72,8 +75,8 @@ fn pairing_deadline_reason() -> String { pub enum SsoPairingOutcome { /// Pairing was cancelled or another activation won. Cancelled, - /// Wallet handshake completed; the session is resolved and persisted. - Success(Box), + /// The resolved wallet session is persisted and installed. + Success, } // Dropped requests must not leave their pairing UI active. @@ -97,18 +100,19 @@ impl Drop for AbandonedPairingGuard { } } -/// One pairing (login) attempt driven on behalf of a pairing host. +/// One selected outbound pairing attempt. pub struct SsoPairingFlow<'a> { - host: &'a PairingHost, + host: &'a SsoRequestService, + login_generation: u64, } impl<'a> SsoPairingFlow<'a> { - /// Bind a pairing attempt to its host. - pub fn new(host: &'a PairingHost) -> Self { - Self { host } + /// Bind the attempt to its service and login generation. + pub fn new(host: &'a SsoRequestService, login_generation: u64) -> Self { + Self { host, login_generation } } - /// Run pairing and persist the resolved wallet session. + /// Complete wallet pairing and commit the selected session. pub async fn request_session( &self, ) -> Result> { @@ -149,11 +153,9 @@ impl<'a> SsoPairingFlow<'a> { let Some((cancel_rx, pairing_epoch)) = self.host.auth_state.pairing_started(bootstrap.deeplink.clone()) else { - return Err(CallError::Domain(HostRequestLoginError::V1( - api::HostRequestLoginError::Unknown { - reason: "login already in progress".to_string(), - }, - ))); + return Err(CallError::Domain(api::HostRequestLoginError::Unknown { + reason: "login already in progress".to_string(), + })); }; info!("presenting pairing QR, waiting for wallet handshake"); let mut reset_guard = AbandonedPairingGuard { @@ -176,7 +178,7 @@ impl<'a> SsoPairingFlow<'a> { reset_guard.disarm(); Ok(outcome) }, - Ok(outcome @ SsoPairingOutcome::Success(_)) => { + Ok(outcome @ SsoPairingOutcome::Success) => { reset_guard.disarm(); Ok(outcome) }, @@ -189,7 +191,7 @@ impl<'a> SsoPairingFlow<'a> { fn fail_before_pairing(&self, reason: String) -> CallError { self.host.auth_state.login_failed_before_pairing(reason.clone()); - CallError::Domain(HostRequestLoginError::V1(api::HostRequestLoginError::Unknown { reason })) + CallError::Domain(api::HostRequestLoginError::Unknown { reason }) } async fn run_pairing_flow( @@ -267,28 +269,20 @@ impl<'a> SsoPairingFlow<'a> { _ = cancel => return Ok(SsoPairingOutcome::Cancelled), session = resolve_session => session, }; - let persist_session = self - .host - .platform - .write_core_storage(CoreStorageKey::AuthSession, encode_persisted_session(&session)) - .fuse(); - pin_mut!(persist_session); - futures::select! { - _ = cancel => { - clear_auth_session(self.host.platform.as_ref()).await; - return Ok(SsoPairingOutcome::Cancelled); - }, - persist_result = persist_session => persist_result - .map_err(|err| format!("session persist failed: {err:?}"))?, - }; - futures::select! { - _ = cancel => { - clear_auth_session(self.host.platform.as_ref()).await; - return Ok(SsoPairingOutcome::Cancelled); - }, - default => {} + let persisted = { + let persist_session = + self.host.commit_login_session(&session, self.login_generation).fuse(); + pin_mut!(persist_session); + futures::select! { + _ = cancel => false, + result = persist_session => result.map_err(|err| format!("session persist failed: {err:?}"))?, + } }; - Ok(SsoPairingOutcome::Success(Box::new(session))) + if !persisted { + self.host.discard_login_session().await; + return Ok(SsoPairingOutcome::Cancelled); + } + Ok(SsoPairingOutcome::Success) } } @@ -350,13 +344,6 @@ async fn write_last_processed_pairing_statement( } } -#[instrument(skip_all, fields(runtime.method = "sso.auth_session.clear"))] -async fn clear_auth_session(storage: &(impl CoreStorage + ?Sized)) { - if let Err(err) = storage.clear_core_storage(CoreStorageKey::AuthSession).await { - debug!("auth session clear failed: {err:?}"); - } -} - /// Decoded wallet handshake success plus the statement metadata needed to /// persist the authenticated session and remember the handled statement. struct PairingSuccess { @@ -545,13 +532,13 @@ fn handle_v2_pairing_result( #[cfg(test)] mod tests { - use super::{ - super::{PairingHostRole, ProductRuntimeHost, connected_session_ui_info}, - *, - }; + use super::*; use crate::{ host_rpc_client::HostRpcClient, platform::{AuthState, ChainProvider, CoreStorageKey}, + runtime::{ + ProductRuntimeHost, connected_session_ui_info, sso_request_service::SsoRequestService, + }, test_support::{ StubPlatform, core_storage_test_key, pairing_device_from_deeplink, peer_statement_keypair, runtime_config, session_info, signed_test_statement, @@ -568,11 +555,11 @@ mod tests { /// Cancel the login as soon as the host observes the `Pairing` state, /// mimicking a user dismissing the pairing UI immediately. - fn cancel_on_pairing(platform: &StubPlatform, pairing_host: Arc) { + fn cancel_on_pairing(platform: &StubPlatform, sso: Arc) { *platform.on_auth_state.lock().expect("auth state hook mutex poisoned") = Some(Arc::new(move |state| { if matches!(state, AuthState::Pairing { .. }) { - pairing_host.cancel_login(); + sso.cancel_login(); } })); } @@ -580,10 +567,11 @@ mod tests { #[test] fn request_login_presents_pairing_and_rejects_when_cancelled() { let platform = stub_platform(); - let (host, pairing_host) = + let (host, pairing) = ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let sso = pairing.session().clone(); let host = Arc::new(host); - cancel_on_pairing(&platform, pairing_host); + cancel_on_pairing(&platform, sso); let cx = CallContext::default(); let request = HostRequestLoginRequest::V1(api::HostRequestLoginRequest { reason: None }); let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); @@ -615,7 +603,7 @@ mod tests { fn request_login_gives_up_when_nothing_decryptable_answers() { let platform = Arc::new(StubPlatform { pairing_silent_after_subscribe: true, ..Default::default() }); - let (host, _pairing_host) = + let (host, _pairing) = ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); let host = Arc::new(host); let cx = CallContext::default(); @@ -636,7 +624,7 @@ mod tests { #[test] fn request_login_gives_up_when_the_pairing_topic_never_acks() { let platform = stub_platform(); - let (host, _pairing_host) = + let (host, _pairing) = ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); let host = Arc::new(host); let cx = CallContext::default(); @@ -678,7 +666,7 @@ mod tests { #[test] fn request_login_gives_up_when_the_statement_store_never_connects() { let platform = Arc::new(StubPlatform { chain_connect_pending: true, ..Default::default() }); - let (host, _pairing_host) = + let (host, _pairing) = ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); let host = Arc::new(host); let cx = CallContext::default(); @@ -696,10 +684,11 @@ mod tests { #[test] fn request_login_regenerates_unmarked_pairing_device_identity_between_attempts() { let platform = stub_platform(); - let (host, pairing_host) = + let (host, pairing) = ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let sso = pairing.session().clone(); let host = Arc::new(host); - cancel_on_pairing(&platform, pairing_host); + cancel_on_pairing(&platform, sso); let cx = CallContext::default(); let request = HostRequestLoginRequest::V1(api::HostRequestLoginRequest { reason: None }); @@ -745,10 +734,11 @@ mod tests { core_storage_test_key(CoreStorageKey::LastProcessedPairingStatement), vec![0xde, 0xad], ); - let (host, pairing_host) = + let (host, pairing) = ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let sso = pairing.session().clone(); let host = Arc::new(host); - cancel_on_pairing(&platform, pairing_host); + cancel_on_pairing(&platform, sso); let cx = CallContext::default(); let request = HostRequestLoginRequest::V1(api::HostRequestLoginRequest { reason: None }); @@ -919,9 +909,10 @@ mod tests { fn request_login_enters_authenticating_on_pending_wallet_response() { let platform = Arc::new(StubPlatform { pairing_pending_response: true, ..Default::default() }); - let (host, pairing_host) = + let (host, pairing) = ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); - let cancel_host = pairing_host.clone(); + let sso = pairing.session().clone(); + let cancel_host = sso.clone(); *platform.on_auth_state.lock().expect("auth state hook mutex poisoned") = Some(Arc::new(move |state| { if matches!(state, AuthState::Authenticating) { @@ -978,9 +969,10 @@ mod tests { session_clears: session_clears.clone(), ..Default::default() }); - let (host, pairing_host) = + let (host, pairing) = ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); - let cancel_host = pairing_host.clone(); + let sso = pairing.session().clone(); + let cancel_host = sso.clone(); *platform .on_auth_session_write .lock() @@ -1132,8 +1124,9 @@ mod tests { }; let platform = Arc::new(StubPlatform { chain_connect_pending: true, ..Default::default() }); - let (host, pairing_host) = + let (host, pairing) = ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let sso = pairing.session().clone(); let host = Arc::new(host); let request = HostRequestLoginRequest::V1(api::HostRequestLoginRequest { reason: None }); let cx = CallContext::default(); @@ -1162,7 +1155,7 @@ mod tests { "dropping the login future should drop the pending statement-store connect" ); - cancel_on_pairing(&platform, pairing_host); + cancel_on_pairing(&platform, sso); let second_cx = CallContext::default(); let mut second_login = Box::pin(host.request_login(&second_cx, request)); let second = match second_login.as_mut().poll(&mut task_cx) { @@ -1179,10 +1172,11 @@ mod tests { session_blob: Some(crate::host_logic::session::encode_persisted_session(&stored)), ..Default::default() }); - let (host, pairing_host) = + let (host, pairing) = ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let sso = pairing.session().clone(); let host = Arc::new(host); - cancel_on_pairing(&platform, pairing_host); + cancel_on_pairing(&platform, sso); let cx = CallContext::default(); let request = HostRequestLoginRequest::V1(api::HostRequestLoginRequest { reason: None }); let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); @@ -1199,10 +1193,11 @@ mod tests { session_clears: session_clears.clone(), ..Default::default() }); - let (host, pairing_host) = + let (host, pairing) = ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let sso = pairing.session().clone(); let host = Arc::new(host); - cancel_on_pairing(&platform, pairing_host); + cancel_on_pairing(&platform, sso); let cx = CallContext::default(); let request = HostRequestLoginRequest::V1(api::HostRequestLoginRequest { reason: None }); let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); @@ -1216,10 +1211,11 @@ mod tests { fn request_login_ignores_session_store_failure_before_pairing() { let platform = Arc::new(StubPlatform { session_error: Some("storage failed"), ..Default::default() }); - let (host, pairing_host) = + let (host, pairing) = ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let sso = pairing.session().clone(); let host = Arc::new(host); - cancel_on_pairing(&platform, pairing_host); + cancel_on_pairing(&platform, sso); let cx = CallContext::default(); let request = HostRequestLoginRequest::V1(api::HostRequestLoginRequest { reason: None }); let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); diff --git a/rust/crates/truapi/src/runtime/sso_request_service/tests.rs b/rust/crates/truapi/src/runtime/sso_request_service/tests.rs new file mode 100644 index 0000000000..597aadd90b --- /dev/null +++ b/rust/crates/truapi/src/runtime/sso_request_service/tests.rs @@ -0,0 +1,312 @@ +use super::*; +use crate::{ + platform::CoreStorage, + runtime::{ProductRuntimeHost, allowances, authority::AutoSigningKey}, + test_support::{StubPlatform, sso_session_info, test_spawner}, +}; +use futures::{FutureExt, executor::block_on}; +use parity_scale_codec::Encode; +use std::collections::BTreeMap; +use truapi::latest::GenericError; + +#[derive(Default)] +struct CleanupStorage { + values: Mutex, Vec>>, + pause: Mutex)>>, + failure: Mutex>, + write_pause: Mutex>>, + write_failure: bool, +} + +#[crate::platform::async_trait] +impl CoreStorage for CleanupStorage { + async fn read_core_storage( + &self, + key: CoreStorageKey, + ) -> Result>, GenericError> { + Ok(self.values.lock().unwrap().get(&key.encode()).cloned()) + } + + async fn write_core_storage( + &self, + key: CoreStorageKey, + value: Vec, + ) -> Result<(), GenericError> { + self.values.lock().unwrap().insert(key.encode(), value); + let pause = self.write_pause.lock().unwrap().take(); + if let Some(pause) = pause { + let _ = pause.await; + } + if self.write_failure { + return Err(GenericError { reason: "session write failed".to_string() }); + } + Ok(()) + } + + async fn clear_core_storage(&self, key: CoreStorageKey) -> Result<(), GenericError> { + let pause = { + let mut pause = self.pause.lock().unwrap(); + if pause.as_ref().is_some_and(|(paused, _)| *paused == key) { + pause.take().map(|(_, receiver)| receiver) + } else { + None + } + }; + if let Some(pause) = pause { + let _ = pause.await; + } + if self.failure.lock().unwrap().as_ref() == Some(&key) { + return Err(GenericError { reason: "storage deletion failed".to_string() }); + } + self.values.lock().unwrap().remove(&key.encode()); + Ok(()) + } +} + +#[test] +fn replacement_waits_for_old_session_cleanup() { + let storage = Arc::new(CleanupStorage::default()); + let platform = Arc::new(StubPlatform { + core_storage_override: Some(storage.clone()), + ..Default::default() + }); + let (_, pairing) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let host = pairing.session().clone(); + let session = sso_session_info(); + block_on(host.set_connected_session_for_tests(session.clone())); + let root = + crate::host_logic::product_account::derive_root_keypair_from_entropy(&[0xAB; 16]).unwrap(); + let subtree = + crate::host_logic::product_account::derive_product_subtree_keypair(&root, "myapp.dot") + .unwrap(); + let revision = pairing.accounts().grants_for_tests().lifecycle().revision(); + block_on(pairing.accounts().grants_for_tests().remember_auto_signing_key( + &host.session_state(), + &session, + revision, + "myapp.dot", + subtree.public.to_bytes(), + AutoSigningKey::from_parts(subtree.secret.to_bytes(), [0x42; 32]), + )) + .unwrap(); + let blob = encode_persisted_session(&session); + block_on(storage.write_core_storage(CoreStorageKey::AuthSession, blob.clone())).unwrap(); + let (release, pause) = oneshot::channel(); + *storage.pause.lock().unwrap() = Some((CoreStorageKey::AuthSession, pause)); + + let mut cleanup = Box::pin(host.clear_disconnected_session(true, None)); + assert!(cleanup.as_mut().now_or_never().is_none()); + assert_eq!(host.session_state.current(), None); + let mut activation = Box::pin(host.activate_external_session(&blob)); + assert!( + activation.as_mut().now_or_never().is_none(), + "replacement must wait for durable revocation" + ); + + release.send(()).unwrap(); + block_on(cleanup); + block_on(activation).unwrap(); + assert!( + !block_on(pairing.accounts().grants_for_tests().auto_signing_key(&session, "myapp.dot")) + .unwrap() + .is_some() + ); + let revision = pairing.accounts().grants_for_tests().lifecycle().revision(); + block_on(pairing.accounts().grants_for_tests().remember_auto_signing_key( + &host.session_state(), + &session, + revision, + "myapp.dot", + subtree.public.to_bytes(), + AutoSigningKey::from_parts(subtree.secret.to_bytes(), [0x55; 32]), + )) + .unwrap(); + let expected = vec![( + (session.public_key, session.sso.as_ref().map(|sso| sso.identity_account_id)), + "myapp.dot".to_string(), + subtree.public.to_bytes(), + subtree.secret.to_bytes(), + [0x55_u8; 32], + )] + .encode(); + assert_eq!( + ( + host.session_state.current(), + storage.values.lock().unwrap().clone(), + platform.auth_states.lock().unwrap().clone() + ), + ( + Some(session.clone()), + BTreeMap::from([(CoreStorageKey::AutoSigningKeys.encode(), expected)]), + vec![ + crate::platform::AuthState::Connected(connected_session_ui_info(&session)), + crate::platform::AuthState::Disconnected, + crate::platform::AuthState::Connected(connected_session_ui_info(&session)), + ] + ), + ); + let key = + block_on(pairing.accounts().grants_for_tests().auto_signing_key(&session, "myapp.dot")) + .unwrap() + .unwrap(); + assert_eq!( + (*key.as_secret_bytes(), *key.ring_vrf_domain_entropy()), + (subtree.secret.to_bytes(), [0x55; 32]) + ); +} + +#[test] +fn interrupted_cleanup_retains_its_scope_and_later_auth_deletion() { + for cancel in [false, true] { + let storage = Arc::new(CleanupStorage::default()); + let platform = Arc::new(StubPlatform { + core_storage_override: Some(storage.clone()), + ..Default::default() + }); + let (_, pairing) = ProductRuntimeHost::new_compat_with_pairing(platform, test_spawner()); + let host = pairing.session().clone(); + let session = sso_session_info(); + block_on(host.set_connected_session_for_tests(session.clone())); + let blob = encode_persisted_session(&session); + let session_id = allowances::session_storage_id(session.sso.as_ref().unwrap()); + for key in [ + CoreStorageKey::AuthSession, + CoreStorageKey::AutoSigningKeys, + CoreStorageKey::AllowanceKeys { session_id: session_id.clone() }, + ] { + block_on(storage.write_core_storage(key, blob.clone())).unwrap(); + } + let cache_key = (allowances::GrantScope::from_session(&session), "myapp.dot".to_string()); + let revision = pairing.accounts().grants_for_tests().lifecycle().revision(); + assert!(block_on( + pairing.accounts().grants_for_tests().persist_product_subtree_if_current( + &host.session_state(), + &session, + revision, + cache_key, + [0xAB; 32], + ) + )); + let (release, pause) = oneshot::channel(); + *storage.pause.lock().unwrap() = Some((CoreStorageKey::AutoSigningKeys, pause)); + *storage.failure.lock().unwrap() = Some(CoreStorageKey::AutoSigningKeys); + let mut initial = Box::pin(host.clear_disconnected_session(false, None)); + assert!(initial.as_mut().now_or_never().is_none()); + let mut repeated = Box::pin(host.clear_disconnected_session(true, None)); + assert!(repeated.as_mut().now_or_never().is_none()); + if cancel { + drop(initial); + } else { + release.send(()).unwrap(); + block_on(initial); + } + block_on(repeated); + assert_eq!( + storage.values.lock().unwrap().clone(), + BTreeMap::from([(CoreStorageKey::AutoSigningKeys.encode(), blob.clone())]) + ); + assert_eq!( + block_on(host.activate_external_session(&blob)), + Err("storage deletion failed".to_string()) + ); + assert_eq!(host.session_state.current(), None); + + *storage.failure.lock().unwrap() = None; + block_on(host.activate_external_session(&blob)).unwrap(); + assert_eq!( + (host.session_state.current(), storage.values.lock().unwrap().clone()), + (Some(session), BTreeMap::new()) + ); + } +} + +#[test] +fn superseded_login_cannot_leave_its_session_in_storage() { + for outcome in ["success", "cancelled", "failed"] { + let storage = + Arc::new(CleanupStorage { write_failure: outcome == "failed", ..Default::default() }); + let platform = Arc::new(StubPlatform { + core_storage_override: Some(storage.clone()), + ..Default::default() + }); + let (_, pairing) = ProductRuntimeHost::new_compat_with_pairing(platform, test_spawner()); + let host = pairing.session().clone(); + let session = sso_session_info(); + let generation = host.begin_login_attempt(); + let (release, pause) = oneshot::channel(); + *storage.write_pause.lock().unwrap() = Some(pause); + let mut login = Box::pin(host.commit_login_session(&session, generation)); + assert!(login.as_mut().now_or_never().is_none()); + assert_eq!( + storage.values.lock().unwrap().clone(), + BTreeMap::from([( + CoreStorageKey::AuthSession.encode(), + encode_persisted_session(&session) + )]), + ); + + let mut replacement = session.clone(); + replacement.public_key = [0x56; 32]; + let replacement_blob = encode_persisted_session(&replacement); + let mut activation = Box::pin(host.activate_external_session(&replacement_blob)); + assert!(activation.as_mut().now_or_never().is_none()); + if outcome == "cancelled" { + drop(login); + } else { + release.send(()).unwrap(); + let expected = if outcome == "failed" { + Err(GenericError { reason: "session write failed".to_string() }) + } else { + Ok(false) + }; + assert_eq!(block_on(login), expected); + } + block_on(activation).unwrap(); + assert_eq!( + (host.session_state.current(), storage.values.lock().unwrap().clone()), + (Some(replacement), BTreeMap::new()), + ); + } +} + +#[test] +fn login_store_notifications_follow_the_persisted_value() { + for changed in [false, true] { + let storage = Arc::new(CleanupStorage::default()); + let platform = Arc::new(StubPlatform { + core_storage_override: Some(storage.clone()), + ..Default::default() + }); + let (_, pairing) = ProductRuntimeHost::new_compat_with_pairing(platform, test_spawner()); + let host = pairing.session().clone(); + let session = sso_session_info(); + let generation = host.begin_login_attempt(); + let (release, pause) = oneshot::channel(); + *storage.write_pause.lock().unwrap() = Some(pause); + let mut login = Box::pin(host.commit_login_session(&session, generation)); + assert!(login.as_mut().now_or_never().is_none()); + let mut stored = session.clone(); + if changed { + stored.public_key = [0x56; 32]; + block_on(storage.write_core_storage( + CoreStorageKey::AuthSession, + encode_persisted_session(&stored), + )) + .unwrap(); + } + host.notify_session_store_changed(); + release.send(()).unwrap(); + assert_eq!( + (block_on(login), host.session_state.current(), storage.values.lock().unwrap().clone(),), + ( + Ok(!changed), + (!changed).then_some(session), + BTreeMap::from([( + CoreStorageKey::AuthSession.encode(), + encode_persisted_session(&stored) + )]), + ), + ); + } +} diff --git a/rust/crates/truapi/src/runtime/signing_host/sso_responder.rs b/rust/crates/truapi/src/runtime/sso_responder_service.rs similarity index 60% rename from rust/crates/truapi/src/runtime/signing_host/sso_responder.rs rename to rust/crates/truapi/src/runtime/sso_responder_service.rs index 8aa2c18adc..0817a9cf90 100644 --- a/rust/crates/truapi/src/runtime/signing_host/sso_responder.rs +++ b/rust/crates/truapi/src/runtime/sso_responder_service.rs @@ -11,6 +11,12 @@ //! same seam browser hosts use for their confirmation modals; a headless host //! implements it with its approval policy. +mod replay; + +use crate::runtime::{ + AccountHolder, SsoAccountHolderService, authority::AuthoritySession, + product_consent::ProductConsent, signing_host::WalletAccountHolder, +}; use std::{ collections::{HashSet, VecDeque}, sync::Arc, @@ -24,79 +30,38 @@ use futures::{ future::{Fuse, FusedFuture}, pin_mut, }; -use parity_scale_codec::Encode; use tracing::{debug, instrument, warn}; -use truapi::latest as api; -use super::{ - SigningHost, SigningHostSsoService, - sso_replay::{ReplayExecution, SsoReplayScope, execute_once}, -}; use crate::{ - chain_runtime::RuntimeFailure, host_internal::{ sso_messages::{ - IncomingSsoRequest, OnExistingAllowancePolicy, RemoteMessage, RemoteMessageData, - SsoResponseCode, build_outgoing_request_statement, - build_signed_session_response_statement, decode_incoming_sso_request, v1, + IncomingSsoRequest, RemoteMessage, RemoteMessageData, SsoResponseCode, + build_outgoing_request_statement, build_signed_session_response_statement, + decode_incoming_sso_request, v1, }, sso_wire::ResponseOutcome, }, host_logic::{ - entropy::root_entropy_source, - product_account::{ - ProductAccountError, derive_identity_keypair, derive_root_keypair_from_entropy, - derive_sr25519_hard_path, - }, session::SsoSessionInfo, sso::pairing::{ - ResponderIdentity, VersionedHandshakeProposal, bootstrap_topic, - decode_pairing_deeplink, derive_identity_chat_private_key, - derive_x25519_keypair_from_entropy, encrypt_v2_handshake_response, - establish_responder_session_info, v2, x25519_public_key, + VersionedHandshakeProposal, decode_pairing_deeplink, v2, x25519_public_key, }, - statement_store::{build_signed_statement, parse_new_statements_result}, + statement_store::parse_new_statements_result, }, runtime::{ - authority::{AuthorityError, AuthoritySession}, + authority::AuthorityError, services::RuntimeServices, sso_remote::{fresh_statement_expiry, sso_message_id}, - sso_service::{Dispatch, SsoWithdrawals}, - statement_allowance::StatementAllowanceError, + sso_service::Dispatch, statement_store_rpc, - statement_store_rpc::StatementStoreRpcClientError, }, unix_time::current_unix_secs as statement_current_unix_secs, }; - -/// RFC-0022 domain for the responder's persistent SSO X25519 key. -const SSO_ENCRYPTION_DOMAIN: &[u8] = b"sso"; -/// Leave the product runtime one minute to receive and process the SSO response -/// before its 300-second remote-authority deadline expires. -const BULLETIN_AUTHORIZATION_WAIT: std::time::Duration = std::time::Duration::from_secs(240); +use replay::{ReplayExecution, SsoReplayLocks, SsoReplayScope, execute_once}; /// Upper bound on undecodable request ids acknowledged within one serve loop. const MAX_DECODE_FAILURE_REQUEST_IDS: usize = 1024; -fn derive_responder_identity( - entropy: &[u8], - network_suffix: &str, -) -> Result<(ResponderIdentity, [u8; 32]), ProductAccountError> { - let statement = derive_identity_keypair(entropy, network_suffix)?; - let (encryption_secret_key, encryption_public_key) = - derive_x25519_keypair_from_entropy(entropy, SSO_ENCRYPTION_DOMAIN); - let identity_chat_private_key = derive_identity_chat_private_key(entropy); - Ok(( - ResponderIdentity { - statement_secret: statement.secret.to_bytes(), - statement_public_key: statement.public.to_bytes(), - encryption_secret_key, - encryption_public_key, - }, - identity_chat_private_key, - )) -} - /// Bounded set of undecodable request ids acknowledged within one serve loop. struct DecodeFailureRequestIds { seen: HashSet, @@ -196,6 +161,7 @@ pub trait DevicePairingObserver: Send + Sync { } struct EstablishedPairing { + wallet_session: AuthoritySession, session: SsoSessionInfo, replay_scope: SsoReplayScope, } @@ -260,286 +226,231 @@ fn sanitize_pairing_metadata(value: String) -> Option { (!value.is_empty()).then_some(value) } -/// Failure while deriving or allocating a Statement Store/Bulletin allowance. -#[derive(Debug, thiserror::Error)] -pub enum AllowanceAllocationError { - /// Signing host session or authority state was unavailable. - #[error("{0}")] - Authority(#[from] AuthorityError), - /// The host serves no chain for this role, so there is nothing to claim on. - #[error("host serves no {chain} chain")] - ChainNotServed { - /// Role that could not be resolved. - chain: &'static str, - }, - /// Reading the host's chain set failed. - #[error("supported chains: {0}")] - SupportedChains(String), - /// Product-account key derivation failed. - #[error("{0}")] - ProductAccount(#[from] ProductAccountError), - /// Chain state, metadata, ring, slot, proof, or extrinsic allocation failed. - #[error("{0}")] - StatementAllowance(#[from] StatementAllowanceError), - /// Runtime service could not open the required Statement Store RPC client. - #[error("{0}")] - StatementStoreRpcClient(#[from] StatementStoreRpcClientError), - /// Runtime service could not open the required Bulletin RPC client. - #[error("{context}: {source}")] - ChainRpcClient { - /// Client context, naming which chain failed. - context: &'static str, - /// Chain runtime failure. - #[source] - source: RuntimeFailure, - }, - /// System time cannot be converted into a UNIX timestamp. - #[error("system clock before UNIX epoch")] - SystemClockBeforeUnixEpoch, - /// The signing account is not in any personhood ring. - #[error("signing account is not a personhood ring member; cannot grant {resource} allowance")] - MissingPersonhoodMembership { - /// Resource name. - resource: &'static str, - }, +/// Incoming SSO for one signing host: pairing answers, served sessions and +/// duplicate detection, each peer answered by its own [`SsoAccountHolderService`]. +pub struct SsoResponderService { + services: Arc, + wallet: Arc, + consent: Arc, + /// Serializes replay-ledger updates within each wallet and peer scope. + replay_locks: SsoReplayLocks, } -impl AllowanceAllocationError { - pub fn into_authority_error(self) -> AuthorityError { - match self { - Self::Authority(err) => err, - other => AuthorityError::Unavailable { reason: other.to_string() }, - } +impl SsoResponderService { + /// Answer peers of `wallet` over the runtime's statement store, confirming their + /// requests through `consent`. + pub fn new( + services: Arc, + wallet: Arc, + consent: Arc, + ) -> Arc { + Arc::new(Self { services, wallet, consent, replay_locks: SsoReplayLocks::default() }) } -} -/// Answer `deeplink` and serve the resulting SSO session until it ends. -#[instrument(skip_all, fields(runtime.method = "sso_responder.respond_to_pairing"))] -pub async fn respond_to_pairing( - services: Arc, - signing_host: Arc, - deeplink: &str, -) -> Result { - let established = establish_pairing_session(&services, &signing_host, deeplink).await?; - serve_session(services, signing_host, established.session, established.replay_scope).await -} + fn account_holder_service(&self, session: AuthoritySession) -> SsoAccountHolderService { + SsoAccountHolderService::new(self.wallet.clone(), self.consent.clone(), session) + } -/// Answer a pairing host's handshake without entering its long-lived serve loop. -pub async fn establish_pairing( - services: Arc, - signing_host: Arc, - deeplink: &str, -) -> Result<(), String> { - establish_pairing_session(&services, &signing_host, deeplink).await?; - Ok(()) -} + /// Serve the active wallet's requests as a paired host's peer. + #[cfg(test)] + pub fn service_for_tests(&self) -> SsoAccountHolderService { + self.account_holder_service(self.wallet.current_session().expect("an active wallet")) + } -async fn establish_pairing_session( - services: &RuntimeServices, - signing_host: &SigningHost, - deeplink: &str, -) -> Result { - let peer = PairedSsoPeer::from_deeplink(deeplink)?; - let entropy = signing_host - .root_entropy() - .map_err(|err| format!("signing host has no active local session: {err}"))?; - // Product accounts and the SSO statement identity derive from the - // canonical root key; the identity is the RFC-0022 `uid.` default - // account of the network this host is configured for. - let root = derive_root_keypair_from_entropy(&entropy) - .map_err(|err| format!("root account derivation failed: {err}"))?; - let (identity, identity_chat_private_key) = - derive_responder_identity(&entropy, signing_host.network_suffix()) - .map_err(|err| format!("responder identity derivation failed: {err}"))?; - let device_enc_pub_key = x25519_public_key(services.device_encryption_secret().await?); - let session = responder_session_from_identity(&identity, peer)?; - - let success = v2::EncryptedResponse::Success(Box::new(v2::Success { - identity_account_id: identity.statement_public_key, - root_account_id: root.public.to_bytes(), - identity_chat_private_key, - sso_enc_pub_key: identity.encryption_public_key, - device_enc_pub_key, - root_entropy_source: root_entropy_source(&entropy), - })); - submit_handshake_answer(services, &session, peer, &success, "sso-responder handshake").await?; - debug!("answered pairing handshake"); - // The submit is the earliest point the peer could read the answer. - if let Some(observer) = services.device_pairing_observer() { - observer.device_paired(peer); + /// Answer `deeplink` and serve the resulting SSO session until it ends. + #[instrument(skip_all, fields(runtime.method = "sso_responder.respond_to_pairing"))] + pub async fn respond_to_pairing(&self, deeplink: &str) -> Result { + let established = self.establish_pairing_session(deeplink).await?; + serve_session( + self, + established.session, + established.replay_scope, + established.wallet_session, + ) + .await } - Ok(EstablishedPairing { - session, - replay_scope: SsoReplayScope { - root_public_key: root.public.to_bytes(), - peer_statement_account_id: peer.statement_account_id, - peer_encryption_public_key: peer.encryption_public_key, - }, - }) -} + /// Answer a pairing host's handshake without entering its long-lived serve loop. + pub async fn establish_pairing(&self, deeplink: &str) -> Result<(), String> { + self.establish_pairing_session(deeplink).await?; + Ok(()) + } -/// Resume a previously paired SSO session from its persisted public peer keys. -pub async fn resume_pairing( - services: Arc, - signing_host: Arc, - peer: PairedSsoPeer, -) -> Result { - let entropy = signing_host - .root_entropy() - .map_err(|err| format!("signing host has no active local session: {err}"))?; - let root = derive_root_keypair_from_entropy(&entropy) - .map_err(|err| format!("root account derivation failed: {err}"))?; - let session = responder_session(&entropy, signing_host.network_suffix(), peer)?; - serve_session( - services, - signing_host, - session, - SsoReplayScope { - root_public_key: root.public.to_bytes(), - peer_statement_account_id: peer.statement_account_id, - peer_encryption_public_key: peer.encryption_public_key, - }, - ) - .await -} + async fn establish_pairing_session( + &self, + deeplink: &str, + ) -> Result { + let peer = PairedSsoPeer::from_deeplink(deeplink)?; + let wallet_session = self + .wallet + .current_session() + .ok_or_else(|| "signing host has no active local session".to_string())?; + let device_enc_pub_key = x25519_public_key(self.services.device_encryption_secret().await?); + let (session, statement) = self + .wallet + .pairing_answer(&wallet_session, peer, device_enc_pub_key) + .map_err(|error| error.to_string())?; + self.services + .statement_store + .submit(statement, "sso-responder handshake") + .await?; + self.wallet + .require_current_session(&wallet_session) + .map_err(|error| error.to_string())?; + debug!("answered pairing handshake"); + // The submit is the earliest point the peer could read the answer. + if let Some(observer) = self.services.device_pairing_observer() { + observer.device_paired(peer); + } -/// Notify a paired host that this signing host is ending their SSO session. -pub async fn disconnect_paired_host( - services: Arc, - signing_host: Arc, - peer: PairedSsoPeer, -) -> Result<(), String> { - let entropy = signing_host - .root_entropy() - .map_err(|err| format!("signing host has no active local session: {err}"))?; - let session = responder_session(&entropy, signing_host.network_suffix(), peer)?; - let message_id = sso_message_id(); - let message = RemoteMessage { - message_id: message_id.clone(), - data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), - }; - let statement = build_outgoing_request_statement( - &session, - message_id, - vec![message], - fresh_statement_expiry(), - )?; - services.statement_store.submit_sso(statement, "sso-responder disconnect").await -} + Ok(EstablishedPairing { + wallet_session: wallet_session.clone(), + session, + replay_scope: SsoReplayScope { + root_public_key: wallet_session.public_key, + peer_statement_account_id: peer.statement_account_id, + peer_encryption_public_key: peer.encryption_public_key, + }, + }) + } -fn responder_session( - entropy: &[u8], - network_suffix: &str, - peer: PairedSsoPeer, -) -> Result { - let (identity, _) = derive_responder_identity(entropy, network_suffix) - .map_err(|err| format!("responder identity derivation failed: {err}"))?; - responder_session_from_identity(&identity, peer) -} + /// Resume a previously paired SSO session from its persisted public peer keys. + pub async fn resume_pairing(&self, peer: PairedSsoPeer) -> Result { + let wallet_session = self + .wallet + .current_session() + .ok_or_else(|| "signing host has no active local session".to_string())?; + let session = self + .wallet + .sso_session(&wallet_session, peer) + .map_err(|error| error.to_string())?; + serve_session( + self, + session, + SsoReplayScope { + root_public_key: wallet_session.public_key, + peer_statement_account_id: peer.statement_account_id, + peer_encryption_public_key: peer.encryption_public_key, + }, + wallet_session, + ) + .await + } -fn responder_session_from_identity( - identity: &ResponderIdentity, - peer: PairedSsoPeer, -) -> Result { - establish_responder_session_info( - identity, - peer.statement_account_id, - peer.encryption_public_key, - ) -} + /// Notify a paired host that this signing host is ending their SSO session. + pub async fn disconnect_paired_host(&self, peer: PairedSsoPeer) -> Result<(), String> { + let wallet_session = self + .wallet + .current_session() + .ok_or_else(|| "signing host has no active local session".to_string())?; + let session = self + .wallet + .sso_session(&wallet_session, peer) + .map_err(|error| error.to_string())?; + let message_id = sso_message_id(); + let message = RemoteMessage { + message_id: message_id.clone(), + data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), + }; + let statement = build_outgoing_request_statement( + &session, + message_id, + vec![message], + fresh_statement_expiry(), + )?; + self.services + .statement_store + .submit_sso(statement, "sso-responder disconnect") + .await + } -/// Encrypt `response` to the pairing host and post it on the handshake topic. -async fn submit_handshake_answer( - services: &RuntimeServices, - session: &SsoSessionInfo, - peer: PairedSsoPeer, - response: &v2::EncryptedResponse, - context: &'static str, -) -> Result<(), String> { - let handshake = encrypt_v2_handshake_response(peer.encryption_public_key, response)?; - let topic = bootstrap_topic(peer.statement_account_id, peer.encryption_public_key); - let statement = build_signed_statement( - session, - topic, - topic, - handshake.encode(), - fresh_statement_expiry(), - )?; - services.statement_store.submit(statement, context).await -} + /// Tell the pairing host that allowance allocation is under way, moving it off + /// its QR screen for however long the allocation takes. + /// + /// Answering at all needs this host's own statement-store allowance, which is + /// the `WalletSso` renewal target, so callers register that before calling. + /// Callers own the matching [`Self::notify_pairing_failed`]: the host has + /// dropped its QR and waits without a deadline, so an allocation that then + /// fails leaves it waiting forever unless it is told. + pub async fn notify_pairing_allowance_allocation( + &self, + deeplink: &str, + ) -> Result { + let peer = PairedSsoPeer::from_deeplink(deeplink)?; + let wallet_session = self + .wallet + .current_session() + .ok_or_else(|| "signing host has no active local session".to_string())?; + let pending = v2::EncryptedResponse::Pending(v2::Status::AllowanceAllocation); + let statement = self + .wallet + .pairing_notice(&wallet_session, peer, &pending) + .map_err(|error| error.to_string())?; + self.services + .statement_store + .submit(statement, "sso-responder allowance allocation") + .await?; + debug!("told pairing host that allowance allocation started"); + Ok(AnnouncedPairing { wallet: self.wallet.clone(), session: wallet_session, peer }) + } -/// Keep the responder identity and peer for both pairing notices. -// No `Debug`: it holds the responder statement secret. -pub struct AnnouncedPairing { - identity: ResponderIdentity, - peer: PairedSsoPeer, -} + /// Tell the pairing host that pairing failed, so it reports `reason` and offers + /// a retry rather than waiting on an answer that is never coming. + pub async fn notify_pairing_failed( + &self, + announced: &AnnouncedPairing, + reason: String, + ) -> Result<(), String> { + let statement = announced + .wallet + .pairing_notice( + &announced.session, + announced.peer, + &v2::EncryptedResponse::Failed(reason), + ) + .map_err(|error| error.to_string())?; + self.services + .statement_store + .submit(statement, "sso-responder pairing failure") + .await?; + debug!("told pairing host that pairing failed"); + Ok(()) + } -/// Tell the pairing host that allowance allocation is under way, moving it off -/// its QR screen for however long the allocation takes. -/// -/// Answering at all needs this host's own statement-store allowance, which is -/// the `WalletSso` renewal target, so callers register that before calling. -/// Callers own the matching [`notify_pairing_failed`]: the host has dropped its -/// QR and waits without a deadline, so an allocation that then fails leaves it -/// waiting forever unless it is told. -pub async fn notify_pairing_allowance_allocation( - services: Arc, - signing_host: Arc, - deeplink: &str, -) -> Result { - let peer = PairedSsoPeer::from_deeplink(deeplink)?; - let entropy = signing_host - .root_entropy() - .map_err(|err| format!("signing host has no active local session: {err}"))?; - let (identity, _) = derive_responder_identity(&entropy, signing_host.network_suffix()) - .map_err(|err| format!("responder identity derivation failed: {err}"))?; - let session = responder_session_from_identity(&identity, peer)?; - - let pending = v2::EncryptedResponse::Pending(v2::Status::AllowanceAllocation); - submit_handshake_answer( - &services, - &session, - peer, - &pending, - "sso-responder allowance allocation", - ) - .await?; - debug!("told pairing host that allowance allocation started"); - Ok(AnnouncedPairing { identity, peer }) + /// Bind an external SSO transport to the wallet matching both of its keys. + pub fn open_service( + &self, + own_statement_account_id: [u8; 32], + own_encryption_public_key: [u8; 32], + ) -> Result { + let session = self.wallet.current_session().ok_or(AuthorityError::Disconnected)?; + self.wallet.require_sso_identity( + &session, + own_statement_account_id, + own_encryption_public_key, + )?; + Ok(self.account_holder_service(session)) + } } -/// Tell the pairing host that pairing failed, so it reports `reason` and offers -/// a retry rather than waiting on an answer that is never coming. -pub async fn notify_pairing_failed( - services: Arc, - announced: &AnnouncedPairing, - reason: String, -) -> Result<(), String> { - let session = responder_session_from_identity(&announced.identity, announced.peer)?; - let failed = v2::EncryptedResponse::Failed(reason); - submit_handshake_answer( - &services, - &session, - announced.peer, - &failed, - "sso-responder pairing failure", - ) - .await?; - debug!("told pairing host that pairing failed"); - Ok(()) +/// Wallet activation and peer bound to an in-progress pairing notice. +pub struct AnnouncedPairing { + wallet: Arc, + session: AuthoritySession, + peer: PairedSsoPeer, } /// Serve inbound session statements until the session ends. #[instrument(skip_all, fields(runtime.method = "sso_responder.serve_session"))] async fn serve_session( - services: Arc, - signing_host: Arc, + responder: &SsoResponderService, session: SsoSessionInfo, replay_scope: SsoReplayScope, + wallet_session: AuthoritySession, ) -> Result { - let service = SigningHostSsoService::new(signing_host.clone()); + let service = responder.account_holder_service(wallet_session); + service.require_current_session().map_err(|error| error.to_string())?; + let services = &responder.services; let rpc_client = services .statement_store .client("sso-responder session") @@ -549,7 +460,7 @@ async fn serve_session( statement_store_rpc::subscribe_match_all(&rpc_client, &[session.session_id_peer]) .await .map_err(|err| format!("sso-responder subscribe failed: {err}"))?; - let (services, session) = (&services, &session); + let session = &session; let pages = futures::stream::unfold( (subscription, DecodeFailureRequestIds::new()), move |(mut subscription, mut decode_failure_request_ids)| async move { @@ -566,9 +477,13 @@ async fn serve_session( ); // Boxed as a trait object so the hosts that await a session need not // lay out this future or prove it `Send`. - serve_pages(pages, signing_host.sso_withdrawals(), |incoming| { - serve_statement(services, &signing_host, &service, session, replay_scope, incoming) - }) + serve_pages( + pages, + |message| { + service.handle_control(message); + }, + |incoming| serve_statement(responder, &service, session, replay_scope, incoming), + ) .boxed() .await } @@ -581,7 +496,7 @@ const MAX_QUEUED_REQUESTS: usize = 64; /// so a `Cancel` reaches the request it withdraws. async fn serve_pages( pages: impl Stream, String>>, - withdrawals: &SsoWithdrawals, + handle_control: impl Fn(&RemoteMessage), mut serve: impl FnMut(IncomingSsoRequest) -> Fut, ) -> Result where @@ -621,9 +536,9 @@ where None => break, }; for incoming in page { - if let Some(targets) = withdrawn_targets(&incoming) { - for target in targets { - withdrawals.withdraw(target); + if incoming.messages.iter().all(|message| message.withdrawn_request_id().is_some()) { + for message in &incoming.messages { + handle_control(message); } continue; } @@ -720,25 +635,10 @@ fn log_request_received(incoming: &IncomingSsoRequest) { } } -/// The requests a statement withdraws, when it carries nothing but `Cancel`s. -fn withdrawn_targets(incoming: &IncomingSsoRequest) -> Option> { - incoming - .messages - .iter() - .map(|message| match &message.data { - RemoteMessageData::V1(v1::RemoteMessage::Cancel(withdrawal)) => { - Some(withdrawal.message_id.as_str()) - }, - _ => None, - }) - .collect() -} - /// Serve one inbound request statement exactly once across redeliveries. async fn serve_statement( - services: &RuntimeServices, - signing_host: &SigningHost, - service: &SigningHostSsoService, + responder: &SsoResponderService, + service: &SsoAccountHolderService, session: &SsoSessionInfo, replay_scope: SsoReplayScope, incoming: IncomingSsoRequest, @@ -746,9 +646,10 @@ async fn serve_statement( let request_id = incoming.request_id.clone(); let expires_at_unix_secs = incoming.expires_at_unix_secs; let duplicate_exit = duplicate_request_exit(&incoming); + let services = &responder.services; let execution = execute_once( services.platform.as_ref(), - signing_host.sso_replay_locks(), + &responder.replay_locks, replay_scope, &request_id, expires_at_unix_secs, @@ -768,7 +669,7 @@ async fn serve_statement( /// Ack one inbound request statement and answer its batched messages. async fn serve_request( services: &RuntimeServices, - service: &SigningHostSsoService, + service: &SsoAccountHolderService, session: &SsoSessionInfo, incoming: IncomingSsoRequest, ) -> Result, String> { @@ -778,22 +679,23 @@ async fn serve_request( let request_name = message.name(); let responding_to = message.message_id.clone(); let started = Instant::now(); - let (response, outcome) = match service.answer(message).await { - Dispatch::Response(answer) => (answer.message, answer.outcome), - Dispatch::Disconnected => { - debug!("pairing host disconnected the SSO session"); - return Ok(Some(ResponderExit::PeerDisconnected)); - }, - Dispatch::NotARequest(name) => { - warn!(name, "peer sent a response variant as a request"); - continue; - }, - Dispatch::Withdraw(_) => continue, - Dispatch::Withdrawn => { - debug!(%responding_to, "pairing host withdrew the SSO request"); - continue; - }, - }; + let (response, outcome) = + match service.answer(message).await.map_err(|error| error.to_string())? { + Dispatch::Response(answer) => (answer.message, answer.outcome), + Dispatch::Disconnected => { + debug!("pairing host disconnected the SSO session"); + return Ok(Some(ResponderExit::PeerDisconnected)); + }, + Dispatch::NotARequest(name) => { + warn!(name, "peer sent a response variant as a request"); + continue; + }, + Dispatch::Withdraw(_) => continue, + Dispatch::Withdrawn => { + debug!(%responding_to, "pairing host withdrew the SSO request"); + continue; + }, + }; let response_message_id = response.message_id.clone(); let statement_request_id = format!("resp:{response_message_id}"); let statement = build_outgoing_request_statement( @@ -906,380 +808,33 @@ fn response_cli_summary( summary } -/// A product's statement-store allowance key, and the period it holds a slot in. -pub struct StatementStoreAllocation { - /// sr25519 secret of the product's allowance account. - pub secret: Vec, - /// Allowance period the slot was found or claimed in. - pub period: u32, -} - -/// Issue a statement allowance with the period actually found or registered. -pub async fn allocate_statement_store_allowance( - services: &RuntimeServices, - signing_host: &SigningHost, - session: &AuthoritySession, - product_id: &str, - policy: OnExistingAllowancePolicy, -) -> Result { - use super::allowance_renewal::{self, StatementRenewalTarget}; - use crate::runtime::statement_allowance::{ - self, PooledRegistrationParams, allocated_in, find_including_rings, - register_statement_account_pooled, scan_collections, - }; - - signing_host.require_current_session(session)?; - let entropy = signing_host.root_entropy()?; - let allowance = - derive_sr25519_hard_path(&entropy, &["allowance", "statement-store", product_id])?; - // Test signatures use real keys without claiming on-chain registration. - #[cfg(feature = "test-host")] - if signing_host.grants_allowances_unchecked() { - return Ok(StatementStoreAllocation { - secret: allowance.secret.to_bytes().to_vec(), - period: statement_allowance::slot::current_period(current_unix_secs()?), - }); - } - let target = allowance.public.to_bytes(); - let candidates = signing_host.reserved_person_collection_candidates(session)?; - let client = services.statement_store.chain_client("statement-store allowance").await?; - let rpc = client.rpc(); - let chain = services.chain_context.get(&client).await?; - let network_suffix = statement_allowance::slot::read_network_suffix(rpc).await?; - let period = statement_allowance::slot::current_period(current_unix_secs()?); - let reuse_existing = matches!(policy, OnExistingAllowancePolicy::Ignore); - - // Hold through scan and submission so renewal cannot select the same slot. - let _registration = signing_host.renewal.registration_lock().lock().await; - - // Existing allocations need no proof or costly ring snapshot. - let signer = statement_allowance::FixedPersonhoodSigner::new(&candidates).await?; - let collections: Vec<_> = candidates.iter().map(|candidate| candidate.collection).collect(); - let scans = scan_collections( - rpc, - &chain.metadata, - &signer, - &collections, - statement_allowance::CollectionScanParams { - network_suffix: &network_suffix, - period, - target: &target, - reuse_existing, - }, - ) - .await?; - if let Some((collection, seq)) = allocated_in(&scans) { - debug!( - %product_id, - period, - seq, - %collection, - "statement-store allowance already allocated" - ); - signing_host.require_current_session(session)?; - return Ok(StatementStoreAllocation { - secret: allowance.secret.to_bytes().to_vec(), - period, - }); - } - - // Every ring back to index 0, because a membership that stopped being - // re-included still proves against the ring that holds it. - let memberships = - find_including_rings(rpc, &chain.metadata, &signer, &collections, u32::MAX).await?; - if memberships.is_empty() { - return Err(AllowanceAllocationError::MissingPersonhoodMembership { - resource: "statement-store", - }); - } - signing_host.require_current_session(session)?; - let outcome = register_statement_account_pooled( - rpc, - &chain.metadata, - &chain.state, - &signer, - &scans, - &memberships, - PooledRegistrationParams { - target: &target, - period, - network_suffix: &network_suffix, - reuse_existing, - // Only renewal may reclaim slots belonging to its own ledger. - allow_eviction: false, - protected: &[], - }, - ) - .await?; - match outcome { - statement_allowance::RegistrationOutcome::Registered { - block_hash, - seq, - ring_index, - collection, - } => { - debug!( - %product_id, - %block_hash, - seq, - ring_index, - %collection, - "registered statement-store allowance" - ); - }, - statement_allowance::RegistrationOutcome::AlreadyAllocated { seq, collection } => { - debug!( - %product_id, - seq, - %collection, - "statement-store allowance already allocated" - ); - }, - } - signing_host.require_current_session(session)?; - if let Err(reason) = allowance_renewal::track( - signing_host, - vec![StatementRenewalTarget::ProductStatementAllowance { - product_id: product_id.to_string(), - }], - ) - .await - { - warn!(%product_id, %reason, "failed to record statement-store renewal target"); - } - signing_host.require_current_session(session)?; - Ok(StatementStoreAllocation { secret: allowance.secret.to_bytes().to_vec(), period }) -} - -/// Issue the product's Bulletin allowance key. -pub async fn allocate_bulletin_allowance( - services: &RuntimeServices, - signing_host: &SigningHost, - session: &AuthoritySession, - product_id: &str, - policy: OnExistingAllowancePolicy, -) -> Result, AllowanceAllocationError> { - use crate::runtime::statement_allowance::{ - self, claim_long_term_storage, collection::PersonhoodCollection, fetch_bulletin_allowance, - find_including_rings, wait_bulletin_authorization, - }; - - signing_host.require_current_session(session)?; - let entropy = signing_host.root_entropy()?; - let allowance = derive_sr25519_hard_path(&entropy, &["allowance", "bulletin", product_id])?; - #[cfg(feature = "test-host")] - if signing_host.grants_allowances_unchecked() { - return Ok(allowance.secret.to_bytes().to_vec()); - } - let target = allowance.public.to_bytes(); - - let bulletin_rpc = statement_allowance::rpc::RpcClient::new( - services.bulletin.client("bulletin allowance").await.map_err(|source| { - AllowanceAllocationError::ChainRpcClient { - context: "bulletin allowance client", - source, - } - })?, - ); - let current_allowance = fetch_bulletin_allowance(&bulletin_rpc, &target).await?; - if matches!(policy, OnExistingAllowancePolicy::Ignore) && - current_allowance.is_some_and(|allowance| allowance.available()) - { - signing_host.require_current_session(session)?; - return Ok(allowance.secret.to_bytes().to_vec()); - } - - let people_client = services.statement_store.chain_client("bulletin allowance claim").await?; - let people_rpc = people_client.rpc(); - let chain = services.chain_context.get(&people_client).await?; - let network_suffix = statement_allowance::slot::read_network_suffix(people_rpc).await?; - let candidates = signing_host.reserved_person_collection_candidates(session)?; - // Prefer light membership so switching collections cannot reset the person's budget. - let signer = statement_allowance::FixedPersonhoodSigner::new(&candidates).await?; - let collections: Vec<_> = candidates.iter().map(|candidate| candidate.collection).collect(); - let memberships = - find_including_rings(people_rpc, &chain.metadata, &signer, &collections, u32::MAX).await?; - let membership = memberships - .iter() - .find(|membership| membership.collection == PersonhoodCollection::LitePeople) - .or_else(|| memberships.first()) - .ok_or(AllowanceAllocationError::MissingPersonhoodMembership { resource: "Bulletin" })?; - let period_duration = - statement_allowance::slot::long_term_storage_period_duration(&chain.metadata)?; - let period = statement_allowance::slot::current_long_term_storage_period( - current_unix_secs()?, - period_duration, - )?; - signing_host.require_current_session(session)?; - let outcome = claim_long_term_storage(statement_allowance::LongTermStorageClaim { - rpc: people_rpc, - metadata: &chain.metadata, - chain_state: &chain.state, - signer: &signer, - network_suffix: &network_suffix, - target: &target, - period, - ring: membership, - }) - .await?; - let statement_allowance::LongTermStorageOutcome::Claimed { block_hash, counter, ring_index } = - outcome; - debug!( - %product_id, - %block_hash, - counter, - ring_index, - "claimed Bulletin long-term storage allowance" - ); - - let authorization = wait_bulletin_authorization( - &bulletin_rpc, - &target, - current_allowance, - BULLETIN_AUTHORIZATION_WAIT, - ) - .await?; - debug!( - %product_id, - remained_size = authorization.remained_size, - remained_transactions = authorization.remained_transactions, - "Bulletin authorization visible" - ); - signing_host.require_current_session(session)?; - Ok(allowance.secret.to_bytes().to_vec()) -} - -/// Claim an Asset Hub PGAS allowance for the product account `derivation_index` -/// selects. -/// -/// Unlike the statement-store and Bulletin allowances, this credits the product -/// account itself rather than a dedicated `//allowance//…` account, and returns -/// nothing: PGAS pre-warms a balance on an account the host already controls, so -/// there is no key to hand back. -/// -/// Asset Hub is resolved through the host's chain set rather than a configured -/// hash, so a host that does not serve it says so instead of claiming against -/// whatever chain a stale hash happens to reach. -pub async fn allocate_smart_contract_allowance( - services: &RuntimeServices, - signing_host: &SigningHost, - session: &AuthoritySession, - product_id: &str, - derivation_index: api::DerivationIndex, - policy: OnExistingAllowancePolicy, -) -> Result<(), AllowanceAllocationError> { - use truapi::latest::ChainIdentifier; - - use crate::{ - host_logic::features, - runtime::statement_allowance::{self, ChainClient, find_including_rings, pgas}, - }; - - signing_host.require_current_session(session)?; - - // PGAS credits the product account the caller named. - let target = signing_host - .product_keypair(&api::ProductAccountId { - dot_ns_identifier: product_id.to_string(), - derivation_index, - })? - .public - .to_bytes(); - - let chains = features::supported_chains(services.platform.as_ref()) - .await - .map_err(|err| AllowanceAllocationError::SupportedChains(err.reason))?; - let asset_hub_genesis = features::genesis_for(&chains, ChainIdentifier::AssetHub) - .ok_or(AllowanceAllocationError::ChainNotServed { chain: "Asset Hub" })?; - let asset_hub_client = ChainClient::new( - statement_allowance::rpc::RpcClient::new(subxt_rpcs::RpcClient::new( - services.chain.rpc_client("PGAS allowance", &asset_hub_genesis).await.map_err( - |source| AllowanceAllocationError::ChainRpcClient { - context: "Asset Hub PGAS client", - source, - }, - )?, - )), - asset_hub_genesis, - ); - let asset_hub = services.chain_context.get(&asset_hub_client).await?; - - // Reusing a funded account preserves the person's daily claim slots. - if matches!(policy, OnExistingAllowancePolicy::Ignore) && - pgas::holds_a_full_claim(asset_hub_client.rpc(), &asset_hub.metadata, &target).await? - { - debug!(%product_id, "PGAS allowance already funded; leaving it alone"); - signing_host.require_current_session(session)?; - return Ok(()); - } - let network_suffix = - statement_allowance::slot::read_network_suffix(asset_hub_client.rpc()).await?; - - let people_client = services.statement_store.chain_client("PGAS allowance ring").await?; - let people_rpc = people_client.rpc(); - let people = services.chain_context.get(&people_client).await?; - - let candidates = signing_host.reserved_person_collection_candidates(session)?; - // A PGAS claim uses the strongest available membership. - let signer = statement_allowance::FixedPersonhoodSigner::new(&candidates).await?; - let collections: Vec<_> = candidates.iter().map(|candidate| candidate.collection).collect(); - let membership = - find_including_rings(people_rpc, &people.metadata, &signer, &collections, u32::MAX) - .await? - .into_iter() - .next() - .ok_or(AllowanceAllocationError::MissingPersonhoodMembership { resource: "PGAS" })?; - - signing_host.require_current_session(session)?; - let outcome = pgas::claim_pgas(pgas::PgasClaim { - asset_hub_rpc: asset_hub_client.rpc(), - asset_hub: &asset_hub, - people_rpc, - people_metadata: &people.metadata, - signer: &signer, - network_suffix: &network_suffix, - target: &target, - ring: &membership, - }) - .await?; - debug!( - %product_id, - day = outcome.day, - slot_index = outcome.slot_index, - ring_index = outcome.ring_index, - block = %outcome.block_hash, - "claimed PGAS allowance" - ); - signing_host.require_current_session(session)?; - Ok(()) -} - -/// Allowance period clock, using web-time on browsers to avoid SystemTime panics. -pub fn current_unix_secs() -> Result { - #[cfg(not(target_arch = "wasm32"))] - use std::time::{SystemTime, UNIX_EPOCH}; - #[cfg(target_arch = "wasm32")] - use web_time::{SystemTime, UNIX_EPOCH}; - - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .map_err(|_| AllowanceAllocationError::SystemClockBeforeUnixEpoch) -} - #[cfg(test)] mod tests { use super::{super::LocalActivation, *}; use crate::{ host_internal::{ sso_messages::{ - self, GetAccountAliasResponse, RemoteMessage, RingVrfError, SsoAllocatedResource, - SsoAllocationOutcome, + self, GetAccountAliasResponse, OnExistingAllowancePolicy, RemoteMessage, + RingVrfError, SsoAllocatedResource, SsoAllocationOutcome, }, sso_wire::ResponseOutcome, }, - host_logic::product_account::derive_ring_vrf_domain_entropy, + host_logic::{ + product_account::{ + derive_identity_keypair, derive_product_subtree_keypair, + derive_ring_vrf_domain_entropy, derive_root_keypair_from_entropy, + }, + sso::pairing::{ + ResponderIdentity, VersionedHandshakeResponse, decode_app_handshake_data, + decrypt_v2_handshake_response, derive_x25519_keypair_from_entropy, + encrypt_v2_handshake_response, establish_responder_session_info, + generate_pairing_device_identity, + }, + statement_store::build_signed_statement, + }, + runtime::{signing_host::SigningHost, sso_service::SsoWithdrawals}, }; + use parity_scale_codec::Encode; /// The key a host advertises on chain must be the one it serves over /// pairing. These derive independently, so a test that asks only one of @@ -1289,8 +844,27 @@ mod tests { const CHAT_ENTROPY: [u8; 16] = [0xAB; 16]; const SUFFIX: &str = "paseo"; - let (_, served) = - derive_responder_identity(&CHAT_ENTROPY, SUFFIX).expect("responder identity derives"); + let (_, signing_host) = signing_fixture(Arc::new(StubPlatform::default())); + let session = signing_host.wallet_for_tests().current_session().unwrap(); + let host = generate_pairing_device_identity().unwrap(); + let peer = PairedSsoPeer { + statement_account_id: host.statement_store_public_key, + encryption_public_key: host.encryption_public_key, + }; + let (_, answer) = + signing_host.wallet_for_tests().pairing_answer(&session, peer, [0; 32]).unwrap(); + let verified = + decode_verified_statement_data(&answer, session.identity_account_id).unwrap(); + let VersionedHandshakeResponse::V2 { public_key, encrypted_message } = + decode_app_handshake_data(&verified.data).unwrap(); + let v2::EncryptedResponse::Success(served) = decrypt_v2_handshake_response( + host.encryption_secret_key, + public_key, + &encrypted_message, + ) + .unwrap() else { + panic!("expected pairing success") + }; let registration = crate::host_logic::attestation::build_lite_registration( &CHAT_ENTROPY, SUFFIX, @@ -1303,14 +877,14 @@ mod tests { assert_eq!( ®istration.identifier_key[1..33], - &crate::host_logic::sso::pairing::x25519_public_key(served), + &crate::host_logic::sso::pairing::x25519_public_key(served.identity_chat_private_key), "the advertised chat key is not the one served over pairing" ); } use crate::{ host_logic::statement_store::decode_verified_statement_data, platform::{HostInfo, Platform, PlatformInfo, SigningHostConfig}, - runtime::{HostSession, authority::ProductAuthority, services::RuntimeServices}, + runtime::{HostSession, services::RuntimeServices}, test_support::{StubPlatform, test_spawner}, }; use std::sync::Arc; @@ -1354,7 +928,7 @@ mod tests { /// Metadata for the People chain the signing fixture is configured for. #[cfg(not(target_arch = "wasm32"))] const PEOPLE_METADATA: &[u8] = - include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata-v16.scale"); + include_bytes!("../../tests/fixtures/paseo-next-v2-metadata-v16.scale"); /// An existing statement-store allowance must be served without resolving a /// ring or submitting anything. The cache and the scan are covered on their @@ -1382,7 +956,7 @@ mod tests { let people_row = slot::testing::slot_row( derive_full_person_ring_vrf_entropy(&ENTROPY, NETWORK_SUFFIX), NETWORK_SUFFIX.as_bytes(), - slot::current_period(current_unix_secs().unwrap()), + slot::current_period(statement_current_unix_secs()), &[Some(format!(r#""0x{}""#, hex::encode(&slot_entry)))], ); @@ -1417,18 +991,16 @@ mod tests { ], ..Default::default() }); - let (services, signing_host) = signing_fixture(platform.clone()); + let (_services, signing_host) = signing_fixture(platform.clone()); // Bounded, because the failure mode of losing the early return is a // wait on a chain read the stub deliberately does not answer — an // unbounded test would hang instead of reporting. The bound is generous // because it is catching a hang, not asserting latency. let allocation = futures::executor::block_on(async { - let session = signing_host.current_session().unwrap(); + let session = signing_host.wallet_for_tests().current_session().unwrap(); futures::select! { - result = allocate_statement_store_allowance( - &services, - &signing_host, + result = signing_host.wallet_for_tests().allocate_statement_store_allowance( &session, product_id, OnExistingAllowancePolicy::Ignore, @@ -1468,22 +1040,32 @@ mod tests { #[test] fn responder_advertises_and_signs_with_the_local_uid_identity() { let (_services, signing_host) = signing_fixture(Arc::new(StubPlatform::default())); - let local_identity = signing_host.current_session().unwrap().identity_account_id.unwrap(); - let (identity, _) = derive_responder_identity(&ENTROPY, NETWORK_SUFFIX).unwrap(); - assert_eq!(identity.statement_public_key, local_identity); + let local_identity = signing_host + .wallet_for_tests() + .current_session() + .unwrap() + .identity_account_id + .unwrap(); // The statement identity is the network's `uid.` account, the // one the pairing host resolves a username for; a `.dot` account has // no lite record on a test network. assert_ne!( - derive_responder_identity(&ENTROPY, "dot").unwrap().0.statement_public_key, + derive_identity_keypair(&ENTROPY, "dot").unwrap().public.to_bytes(), local_identity ); let (_, host_encryption_public_key) = derive_x25519_keypair_from_entropy(&[0x42; 16], b"sso"); - let session = - establish_responder_session_info(&identity, [0x55; 32], host_encryption_public_key) - .unwrap(); + let session = signing_host + .wallet_for_tests() + .sso_session( + &signing_host.wallet_for_tests().current_session().unwrap(), + PairedSsoPeer { + statement_account_id: [0x55; 32], + encryption_public_key: host_encryption_public_key, + }, + ) + .unwrap(); let statement = build_signed_statement( &session, [0x66; 32], @@ -1492,9 +1074,7 @@ mod tests { fresh_statement_expiry(), ) .unwrap(); - let verified = - decode_verified_statement_data(&statement, Some(identity.statement_public_key)) - .unwrap(); + let verified = decode_verified_statement_data(&statement, Some(local_identity)).unwrap(); assert_eq!(verified.signer, local_identity); } @@ -1508,7 +1088,7 @@ mod tests { // The regression this guards: advertising the SSO channel key as the // device key makes every device sharing an identity indistinguishable. - let (_, sso_public) = derive_x25519_keypair_from_entropy(&ENTROPY, SSO_ENCRYPTION_DOMAIN); + let (_, sso_public) = derive_x25519_keypair_from_entropy(&ENTROPY, b"sso"); assert_ne!(advertised, sso_public); } @@ -1588,6 +1168,37 @@ mod tests { })) } + #[test] + fn a_wallet_change_during_device_key_read_never_answers_pairing() { + let (release, gate) = futures::channel::oneshot::channel(); + let platform = Arc::new(StubPlatform { + core_storage_read_gate: std::sync::Mutex::new(Some(gate)), + rpc_method_responses: vec![("statement_submit", r#"{"status":"new"}"#.to_string())], + ..StubPlatform::default() + }); + let (services, signing_host) = signing_fixture(platform.clone()); + let observer = Arc::new(RecordingPairingObserver::default()); + services.install_device_pairing_observer(observer.clone()); + let deeplink = pairing_deeplink(PairedSsoPeer { + statement_account_id: [0x31; 32], + encryption_public_key: x25519_public_key([0x42; 32]), + }); + let responder = signing_host.sso_responder(); + let pairing = responder.establish_pairing(&deeplink); + futures::pin_mut!(pairing); + assert!(pairing.as_mut().now_or_never().is_none()); + futures::executor::block_on(signing_host.activate_local_session(vec![0xcd; 16])).unwrap(); + release.send(()).unwrap(); + assert_eq!( + ( + futures::executor::block_on(pairing), + platform.sent_rpc.lock().unwrap().len(), + observer.paired() + ), + (Err(crate::runtime::AuthorityError::Disconnected.to_string()), 0, Vec::new()), + ); + } + /// Without this the host never learns a device paired, so no contact is /// told a new device joined. #[test] @@ -1600,11 +1211,9 @@ mod tests { let observer = Arc::new(RecordingPairingObserver::default()); assert!(services.install_device_pairing_observer(observer.clone())); - futures::executor::block_on(establish_pairing( - services, - signing_host, - &pairing_deeplink(peer), - )) + futures::executor::block_on( + signing_host.sso_responder().establish_pairing(&pairing_deeplink(peer)), + ) .expect("the handshake is answered"); assert_eq!(observer.paired(), vec![peer]); @@ -1623,11 +1232,9 @@ mod tests { let observer = Arc::new(RecordingPairingObserver::default()); assert!(services.install_device_pairing_observer(observer.clone())); - let failure = futures::executor::block_on(establish_pairing( - services, - signing_host, - &pairing_deeplink(peer), - )) + let failure = futures::executor::block_on( + signing_host.sso_responder().establish_pairing(&pairing_deeplink(peer)), + ) .expect_err("a rejected handshake submit fails the pairing"); // Without this the test would also pass on a failure from earlier, // leaving the rule unexercised. @@ -1645,13 +1252,11 @@ mod tests { statement_account_id: [0x31; 32], encryption_public_key: x25519_public_key([0x42; 32]), }; - let (services, signing_host) = pairing_fixture("new"); + let (_, signing_host) = pairing_fixture("new"); - futures::executor::block_on(establish_pairing( - services, - signing_host, - &pairing_deeplink(peer), - )) + futures::executor::block_on( + signing_host.sso_responder().establish_pairing(&pairing_deeplink(peer)), + ) .expect("the handshake is answered without an observer"); } @@ -1720,14 +1325,24 @@ mod tests { statement_account_id: [0x53; 32], encryption_public_key: x25519_public_key([0x64; 32]), }; - let (identity, _) = derive_responder_identity(&ENTROPY, NETWORK_SUFFIX).unwrap(); + let statement = derive_identity_keypair(&ENTROPY, NETWORK_SUFFIX).unwrap(); + let (encryption_secret_key, encryption_public_key) = + derive_x25519_keypair_from_entropy(&ENTROPY, b"sso"); + let identity = ResponderIdentity { + statement_secret: statement.secret.to_bytes(), + statement_public_key: statement.public.to_bytes(), + encryption_secret_key, + encryption_public_key, + }; let mut expected = establish_responder_session_info( &identity, peer.statement_account_id, peer.encryption_public_key, ) .unwrap(); - let resumed = responder_session(&ENTROPY, NETWORK_SUFFIX, peer).unwrap(); + let (_, signing_host) = signing_fixture(Arc::new(StubPlatform::default())); + let selected = signing_host.wallet_for_tests().current_session().unwrap(); + let resumed = signing_host.wallet_for_tests().sso_session(&selected, peer).unwrap(); assert_eq!( crate::host_logic::statement_store::statement_public_key_from_secret(resumed.ss_secret) @@ -1766,12 +1381,12 @@ mod tests { message_id: &str, request: v1::RemoteMessage, ) -> v1::RemoteMessage { - let service = SigningHostSsoService::new(signing_host.clone()); + let service = signing_host.sso_responder().service_for_tests(); let message = RemoteMessage { message_id: message_id.to_string(), data: RemoteMessageData::V1(request), }; - let Dispatch::Response(answer) = futures::executor::block_on(service.answer(message)) + let Ok(Dispatch::Response(answer)) = futures::executor::block_on(service.answer(message)) else { panic!("expected a response"); }; @@ -1779,39 +1394,6 @@ mod tests { data } - #[test] - fn dispatch_without_session_distinguishes_requests_responses_and_disconnects() { - let (_, signing_host) = signing_fixture(Arc::new(StubPlatform::default())); - let service = SigningHostSsoService::new(signing_host); - let dispatch = |data| { - futures::executor::block_on(service.dispatch( - None, - RemoteMessage { message_id: "m-1".to_string(), data: RemoteMessageData::V1(data) }, - )) - }; - - assert_eq!(dispatch(v1::RemoteMessage::Disconnected), Dispatch::Disconnected); - assert_eq!( - dispatch(v1::RemoteMessage::ProductSubtreeResponse(sso_messages::Response { - responding_to: "m-1".to_string(), - payload: Ok([7; 32]), - })), - Dispatch::NotARequest("ProductSubtreeResponse"), - ); - let Dispatch::Response(answer) = dispatch(v1::RemoteMessage::ProductSubtreeRequest( - sso_messages::ProductSubtreeRequest { product_id: "myapp.dot".to_string() }, - )) else { - panic!("expected a disconnected error response"); - }; - let RemoteMessageData::V1(v1::RemoteMessage::ProductSubtreeResponse(response)) = - answer.message.data - else { - panic!("expected the request's response variant"); - }; - assert_eq!(response.responding_to, "m-1"); - assert_eq!(response.payload, Err("signing host session is not active".to_string())); - } - #[test] fn response_summary_reports_protocol_errors_without_multiline_output() { let payload: GetAccountAliasResponse = @@ -1848,7 +1430,7 @@ mod tests { chain_connect_error: Some("allocation node unavailable"), ..StubPlatform::default() })); - let service = SigningHostSsoService::new(signing_host); + let service = signing_host.sso_responder().service_for_tests(); let request = RemoteMessage::request( "allocation-1".to_string(), sso_messages::ResourceAllocationRequest { @@ -1861,7 +1443,7 @@ mod tests { on_existing: OnExistingAllowancePolicy::Ignore, }, ); - let Dispatch::Response(answer) = futures::executor::block_on(service.answer(request)) + let Ok(Dispatch::Response(answer)) = futures::executor::block_on(service.answer(request)) else { panic!("expected an allocation response"); }; @@ -1955,38 +1537,51 @@ mod tests { } #[test] - fn auto_signing_allocation_returns_the_product_subtree_secret() { + fn native_product_reset_does_not_invalidate_remote_allocation_review() { + let (release, gate) = futures::channel::oneshot::channel(); let platform = Arc::new(StubPlatform { resource_allocation_confirmed: true, + resource_allocation_confirmation_gate: std::sync::Mutex::new(Some(gate)), ..StubPlatform::default() }); - let (_, signing_host) = signing_fixture(platform); - let expected_secret = signing_host - .product_subtree_secret("myapp.dot") - .expect("product subtree secret derives"); - let expected_ring_vrf_domain_entropy = - derive_ring_vrf_domain_entropy(&ENTROPY, "myapp.dot") - .expect("ring-VRF domain entropy derives"); - - let response = answer( - &signing_host, - "alloc-auto-signing", - v1::RemoteMessage::ResourceAllocationRequest(sso_messages::ResourceAllocationRequest { - calling_product_id: "myapp.dot".to_string(), - resources: vec![api::AllocatableResource::AutoSigning], - on_existing: sso_messages::OnExistingAllowancePolicy::Ignore, - }), - ); - - let v1::RemoteMessage::ResourceAllocationResponse(response) = response else { - panic!("expected resource allocation response"); + let (_, signing_host) = signing_fixture(platform.clone()); + let session = signing_host.wallet_for_tests().current_session(); + let expected_secret = derive_product_subtree_keypair( + &derive_root_keypair_from_entropy(&ENTROPY).unwrap(), + "myapp.dot", + ) + .unwrap() + .secret + .to_bytes(); + let expected_domain = derive_ring_vrf_domain_entropy(&ENTROPY, "myapp.dot").unwrap(); + let service = signing_host.sso_responder().service_for_tests(); + let answer = service.answer(allocation_request("remote-reset")); + futures::pin_mut!(answer); + assert!(answer.as_mut().now_or_never().is_none()); + futures::executor::block_on(signing_host.clear_product_state("myapp.dot")).unwrap(); + release.send(()).unwrap(); + let Ok(Dispatch::Response(answer)) = futures::executor::block_on(answer) else { + panic!("expected an allocation response") + }; + let RemoteMessageData::V1(v1::RemoteMessage::ResourceAllocationResponse(response)) = + answer.message.data + else { + panic!("expected an allocation response") }; assert_eq!( - response.payload.unwrap(), - vec![SsoAllocationOutcome::Allocated(SsoAllocatedResource::AutoSigning { - product_root_private_key: expected_secret, - ring_vrf_domain_entropy: expected_ring_vrf_domain_entropy, - })] + ( + response.payload, + signing_host.wallet_for_tests().current_session(), + platform.resource_allocation_reviews.lock().unwrap().len() + ), + ( + Ok(vec![SsoAllocationOutcome::Allocated(SsoAllocatedResource::AutoSigning { + product_root_private_key: expected_secret, + ring_vrf_domain_entropy: expected_domain + })]), + session, + 1 + ), ); } @@ -2000,7 +1595,7 @@ mod tests { ..StubPlatform::default() }); let (_, signing_host) = signing_fixture(platform.clone()); - let service = SigningHostSsoService::new(signing_host.clone()); + let service = signing_host.sso_responder().service_for_tests(); let message = RemoteMessage::request( "alloc-stale".to_string(), sso_messages::ResourceAllocationRequest { @@ -2022,20 +1617,46 @@ mod tests { } release.send(()).unwrap(); - let Dispatch::Response(answer) = answer.await else { - panic!("expected an allocation response"); + let Ok(Dispatch::Response(answer)) = answer.await else { + panic!("expected not-connected response") }; let RemoteMessageData::V1(v1::RemoteMessage::ResourceAllocationResponse(response)) = answer.message.data else { - panic!("expected an allocation response"); + panic!("expected allocation response") }; - assert_eq!(response.responding_to, "alloc-stale"); - assert!(response.payload.is_err(), "stale consent must not release keys"); + assert_eq!(response.payload, Err("signing host session is not active".to_string())); assert!(platform.sent_rpc.lock().unwrap().is_empty()); }); } + #[test] + fn a_bound_service_cannot_follow_a_replaced_wallet() { + for entropy in [vec![0xcd; 16], ENTROPY.to_vec()] { + let platform = Arc::new(StubPlatform { + resource_allocation_confirmed: true, + ..StubPlatform::default() + }); + let (_, signing_host) = signing_fixture(platform.clone()); + let service = signing_host.sso_responder().service_for_tests(); + futures::executor::block_on(signing_host.activate_local_session(entropy)).unwrap(); + let Ok(Dispatch::Response(answer)) = + futures::executor::block_on(service.answer(allocation_request("stale-service"))) + else { + panic!("expected a disconnected response") + }; + let RemoteMessageData::V1(v1::RemoteMessage::ResourceAllocationResponse(response)) = + answer.message.data + else { + panic!("expected an allocation response") + }; + assert_eq!( + (response.payload, platform.resource_allocation_reviews.lock().unwrap().len()), + (Err("signing host session is not active".to_string()), 0), + ); + } + } + fn allocation_request(message_id: &str) -> RemoteMessage { RemoteMessage::request( message_id.to_string(), @@ -2068,7 +1689,7 @@ mod tests { ..StubPlatform::default() }); let (_, signing_host) = signing_fixture(platform.clone()); - let service = SigningHostSsoService::new(signing_host); + let service = signing_host.sso_responder().service_for_tests(); let allocation = service.answer(allocation_request("alloc-1")); futures::pin_mut!(allocation); assert!(allocation.as_mut().now_or_never().is_none()); @@ -2084,7 +1705,7 @@ mod tests { assert_eq!( (withdrawal, allocation, platform.sent_rpc.lock().unwrap().len()), - (Dispatch::Withdraw("alloc-1".to_string()), Dispatch::Withdrawn, 0) + (Ok(Dispatch::Withdraw("alloc-1".to_string())), Ok(Dispatch::Withdrawn), 0) ); } @@ -2097,12 +1718,12 @@ mod tests { ..StubPlatform::default() }); let (_, signing_host) = signing_fixture(platform.clone()); - let service = SigningHostSsoService::new(signing_host); + let service = signing_host.sso_responder().service_for_tests(); - futures::executor::block_on(service.answer(cancel("cancel-1", "alloc-1"))); + futures::executor::block_on(service.answer(cancel("cancel-1", "alloc-1"))).unwrap(); let allocation = futures::executor::block_on(service.answer(allocation_request("alloc-1"))); - assert_eq!(allocation, Dispatch::Withdrawn); + assert_eq!(allocation, Ok(Dispatch::Withdrawn)); assert!(platform.resource_allocation_reviews.lock().unwrap().is_empty()); } @@ -2123,15 +1744,19 @@ mod tests { pages .unbounded_send(Ok(statement("stmt-1", allocation_request("alloc-1")))) .unwrap(); - let serving = serve_pages(statements, &withdrawals, |incoming| { - let request = withdrawals.begin(&incoming.messages[0].message_id); - async move { - if let Some(request) = request { - request.cancel.cancelled().await; + let serving = serve_pages( + statements, + |message| withdrawals.withdraw(message.withdrawn_request_id().unwrap()), + |incoming| { + let request = withdrawals.begin(&incoming.messages[0].message_id); + async move { + if let Some(request) = request { + request.cancel.cancelled().await; + } + Ok(Some(ResponderExit::PeerDisconnected)) } - Ok(Some(ResponderExit::PeerDisconnected)) - } - }); + }, + ); futures::pin_mut!(serving); assert!(serving.as_mut().now_or_never().is_none()); @@ -2154,15 +1779,19 @@ mod tests { pages .unbounded_send(Ok(statement("stmt-1", allocation_request("alloc-1")))) .unwrap(); - let serving = serve_pages(statements, &withdrawals, |_| { - let gate = gate.take(); - async move { - if let Some(gate) = gate { - let _ = gate.await; + let serving = serve_pages( + statements, + |message| withdrawals.withdraw(message.withdrawn_request_id().unwrap()), + |_| { + let gate = gate.take(); + async move { + if let Some(gate) = gate { + let _ = gate.await; + } + Ok(None) } - Ok(None) - } - }); + }, + ); futures::pin_mut!(serving); assert!(serving.as_mut().now_or_never().is_none()); diff --git a/rust/crates/truapi/src/runtime/signing_host/sso_replay.rs b/rust/crates/truapi/src/runtime/sso_responder_service/replay.rs similarity index 100% rename from rust/crates/truapi/src/runtime/signing_host/sso_replay.rs rename to rust/crates/truapi/src/runtime/sso_responder_service/replay.rs diff --git a/rust/crates/truapi/src/runtime/sso_service.rs b/rust/crates/truapi/src/runtime/sso_service.rs index 35684758c9..75b00b51dd 100644 --- a/rust/crates/truapi/src/runtime/sso_service.rs +++ b/rust/crates/truapi/src/runtime/sso_service.rs @@ -8,7 +8,7 @@ use std::{ use truapi::{CallContext, CancellationToken}; -use super::authority::AuthoritySession; +use super::authority::{AccountCaller, AccountInvocation, AuthoritySession}; use crate::host_internal::{ sso_messages::{RemoteMessage, RemoteMessageData, Response, v1}, sso_wire::ResponseOutcome, @@ -28,6 +28,16 @@ impl SsoRequestContext { pub fn new(message_id: &str, session: AuthoritySession, cancel: CancellationToken) -> Self { Self { call: CallContext::with_parts(String::from(message_id), cancel), session } } + + /// Bind the product the peer reported. Stored permission decisions apply to it as to a + /// local caller, but a paired caller never skips a review. + pub fn account_invocation<'a>(&'a self, product_id: Option<&'a str>) -> AccountInvocation<'a> { + AccountInvocation { + call: &self.call, + session: &self.session, + caller: AccountCaller::Remote { product_id }, + } + } } /// How many withdrawals naming a request not yet seen are remembered. diff --git a/rust/crates/truapi/src/runtime/statement_allowance.rs b/rust/crates/truapi/src/runtime/statement_allowance.rs index 3a054a15bf..168080a9e1 100644 --- a/rust/crates/truapi/src/runtime/statement_allowance.rs +++ b/rust/crates/truapi/src/runtime/statement_allowance.rs @@ -55,6 +55,9 @@ use slot::{SlotError, SlotSelection}; /// for allowance authorization. #[derive(Debug, Error)] pub enum StatementAllowanceError { + /// The selected wallet can no longer authorize this operation. + #[error(transparent)] + Authority(#[from] crate::runtime::authority::AuthorityError), /// JSON-RPC transport, request, subscription, or storage hex failure. #[error(transparent)] Rpc(#[from] rpc::RpcError), @@ -471,7 +474,12 @@ enum BulletinAuthorizationScope { impl BulletinAllowanceInfo { /// Returns whether the snapshot still permits at least one submission. pub fn available(self) -> bool { - self.remained_size > 0 && + self.can_store(1) + } + + /// Require enough bytes and a live transaction allowance for this payload. + pub fn can_store(self, size: u64) -> bool { + self.remained_size >= size && self.remained_transactions > 0 && self.fetched_at < self.expires_in } @@ -546,6 +554,7 @@ pub async fn find_including_rings( // One collection's failure must not take down the others. A device // that can only prove light personhood should still get its // allowance when the full-personhood storage is unreadable. + Err(err @ StatementAllowanceError::Authority(_)) => return Err(err), Err(err) => { warn!(%collection, %err, "could not resolve this collection"); if first_error.is_none() { @@ -774,6 +783,10 @@ pub async fn scan_collections( .await; let mut scans = Vec::new(); for (collection, selection) in selections { + let selection = match selection { + Err(err @ StatementAllowanceError::Authority(_)) => return Err(err), + other => other, + }; if allocated_in(&scans).is_some() { continue; } @@ -1064,6 +1077,7 @@ pub async fn wait_bulletin_authorization( rpc: &RpcClient, target: &[u8; 32], current: Option, + size: u64, timeout: Duration, ) -> Result { let started = Instant::now(); @@ -1073,7 +1087,7 @@ pub async fn wait_bulletin_authorization( wait_before_next_bulletin_authorization_poll(started, timeout).await?; continue; }; - if authorization_refreshed(info, baseline) { + if info.can_store(size) && authorization_refreshed(info, baseline) { return Ok(info); } wait_before_next_bulletin_authorization_poll(started, timeout).await?; @@ -1193,6 +1207,18 @@ mod tests { BulletinAllowanceInfo { remained_size, remained_transactions, expires_in, fetched_at: 10 } } + #[test] + fn bulletin_authorization_covers_the_whole_submission() { + let results = [ + allowance(128, 1, 11), + allowance(127, 1, 11), + allowance(128, 0, 11), + allowance(128, 1, 10), + ] + .map(|info| info.can_store(128)); + assert_eq!(results, [true, false, false, false]); + } + #[test] fn bulletin_refresh_accepts_available_state_when_baseline_was_unusable() { let exhausted_by_size = allowance(0, 4, 100); @@ -1984,6 +2010,78 @@ mod tests { assert_eq!(memberships[0].members, vec![proof::member_key_now(lite_entropy)]); } + /// A wallet that stops signing is not one broken collection. Skipping it and + /// carrying on with the next collection would build an allocation for a wallet + /// that is gone, so the whole resolution stops with the wallet's error. + #[test] + fn a_wallet_change_stops_ring_resolution_instead_of_skipping_a_collection() { + use crate::runtime::authority::AuthorityError; + + /// Signs with fixed keys, except that the wallet is gone for People. + struct WalletGoneForPeople<'a>(FixedPersonhoodSigner<'a>); + + impl PersonhoodSigner for WalletGoneForPeople<'_> { + fn member( + &self, + collection: PersonhoodCollection, + ) -> Result<[u8; 32], StatementAllowanceError> { + if collection == PersonhoodCollection::People { + return Err(AuthorityError::Disconnected.into()); + } + self.0.member(collection) + } + + fn alias( + &self, + collection: PersonhoodCollection, + context: &[u8], + ) -> Result<[u8; 32], StatementAllowanceError> { + self.0.alias(collection, context) + } + + fn prove( + &self, + ring: &RingParams, + context: &[u8], + message: &[u8], + ) -> Result, StatementAllowanceError> { + self.0.prove(ring, context, message) + } + } + + let metadata = test_fixtures::people(); + let candidates = pooled_candidates(); + let signer = WalletGoneForPeople( + futures::executor::block_on(FixedPersonhoodSigner::new(&candidates)).unwrap(), + ); + let lite_entropy = candidates[1].entropy; + let page = format!(r#""0x04{}""#, hex::encode(proof::member_key_now(lite_entropy))); + // LitePeople would resolve if the resolution kept going after People. + let responses = [ + r#""0xfinal""#.to_string(), + LIVE_LITE_COLLECTION.to_string(), + r#""0x00000000""#.to_string(), + page, + "null".to_string(), + "null".to_string(), + ]; + let scripted = ScriptedRpc::new(responses.iter().map(String::as_str).collect::>()); + let rpc = RpcClient::new(HostRpcClient::new(scripted)); + + let err = futures::executor::block_on(find_including_rings( + &rpc, + metadata, + &signer, + &PersonhoodCollection::ALL, + u32::MAX, + )) + .expect_err("a wallet change must stop the resolution"); + assert!( + matches!(err, StatementAllowanceError::Authority(AuthorityError::Disconnected)), + "unexpected error: {err:?}" + ); + } + /// Every candidate failing is an outage, not an answer. Reporting it as "no /// membership" would let a caller conclude the person has no personhood. #[test] diff --git a/rust/crates/truapi/src/runtime/statement_allowance/renewal.rs b/rust/crates/truapi/src/runtime/statement_allowance/renewal.rs index c8382e8909..6469776fc5 100644 --- a/rust/crates/truapi/src/runtime/statement_allowance/renewal.rs +++ b/rust/crates/truapi/src/runtime/statement_allowance/renewal.rs @@ -55,7 +55,8 @@ const MAX_TICK_INTERVAL: Duration = Duration::from_secs(3_600); /// chain has rotated to the new period by the time we scan slots. const PERIOD_BOUNDARY_MARGIN: Duration = Duration::from_secs(120); -/// Why one target's renewal failed, and whether the host had no slot left. +/// Why one target's renewal failed, whether the host had no slot left, and +/// whether the wallet that started the pass can no longer sign. /// /// The distinction drives the rest of the pass, so it is read off the typed /// error rather than its rendered text. @@ -63,6 +64,7 @@ const PERIOD_BOUNDARY_MARGIN: Duration = Duration::from_secs(120); struct RenewalFailure { reason: String, slots_exhausted: bool, + wallet_changed: bool, } impl From for RenewalFailure { @@ -72,6 +74,7 @@ impl From for RenewalFailure { err, StatementAllowanceError::Slot(SlotError::NoFreeStatementStoreSlot { .. }) ), + wallet_changed: matches!(err, StatementAllowanceError::Authority(_)), reason: err.to_string(), } } @@ -166,7 +169,8 @@ pub struct RenewalChainContext<'a> { /// Register every target for `period`, continuing past per-target failures /// and stopping early once the host's slots for the period are exhausted -/// (remaining targets are reported as skipped). +/// (remaining targets are reported as skipped) or once the wallet that started +/// the pass can no longer sign (remaining targets are reported as failed). /// /// Slots already held by any target, plus slots claimed earlier in the pass, /// are protected from replacement. Without that, a new target early in the @@ -203,6 +207,11 @@ pub async fn renew_targets( .await .map_err(RenewalFailure::from) }; + if let Err(failure) = &scans && + failure.wallet_changed + { + return stopped_by_wallet_change(period, targets, Vec::new(), failure.clone()); + } target_scans.push(scans); } @@ -278,7 +287,14 @@ pub async fn renew_targets( } } let exhausted = matches!(&result, Err(failure) if failure.slots_exhausted); + let wallet_change = match &result { + Err(failure) if failure.wallet_changed => Some(failure.clone()), + _ => None, + }; results.push(result); + if let Some(failure) = wallet_change { + return stopped_by_wallet_change(period, targets, results, failure); + } if exhausted { break; } @@ -323,6 +339,18 @@ fn log_target_result( } } +/// Keep every result of a pass whose wallet can no longer sign, and report the +/// targets it did not reach as failed for the same reason. +fn stopped_by_wallet_change( + period: u32, + targets: &[ResolvedRenewalTarget], + mut results: Vec>, + failure: RenewalFailure, +) -> StatementRenewalReport { + results.resize_with(targets.len(), || Err(failure.clone())); + fold_outcomes(period, targets, results) +} + /// Pair each target with its registration result; targets past the end of /// `results` were never attempted (the pass stopped on slot exhaustion). fn fold_outcomes( @@ -564,6 +592,149 @@ mod tests { ); } + /// A wallet that stops signing mid-pass must end the pass without losing what + /// it already renewed. The host records the first target's registration, and + /// every target the pass did not reach is reported as failed rather than left + /// for another proof against a wallet that is gone. + #[test] + fn a_wallet_change_keeps_finished_targets_and_fails_the_rest() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use parity_scale_codec::Encode; + use subxt_rpcs::RpcClient as HostRpcClient; + + use crate::runtime::{ + authority::AuthorityError, + statement_allowance::{ + FixedPersonhoodSigner, + extension::ChainState, + proof, + ring::RingParams, + rpc::{RpcClient, testing::ScriptedRpc}, + slot::testing::lite_people, + test_fixtures, + }, + }; + + const NOW: u64 = 10_000_000; + + /// Signs with fixed keys until the wallet changes before its second proof. + struct WalletChangesBeforeSecondProof<'a> { + keys: FixedPersonhoodSigner<'a>, + proofs: AtomicUsize, + } + + impl PersonhoodSigner for WalletChangesBeforeSecondProof<'_> { + fn member( + &self, + collection: PersonhoodCollection, + ) -> Result<[u8; 32], StatementAllowanceError> { + self.keys.member(collection) + } + + fn alias( + &self, + collection: PersonhoodCollection, + context: &[u8], + ) -> Result<[u8; 32], StatementAllowanceError> { + self.keys.alias(collection, context) + } + + fn prove( + &self, + ring: &RingParams, + context: &[u8], + message: &[u8], + ) -> Result, StatementAllowanceError> { + if self.proofs.fetch_add(1, Ordering::SeqCst) == 1 { + return Err(AuthorityError::Disconnected.into()); + } + self.keys.prove(ring, context, message) + } + } + + fn entry(account: [u8; 32]) -> String { + format!(r#""0x{}""#, hex::encode((account, 0u32, NOW).encode())) + } + + let metadata = test_fixtures::people(); + let chain_state = ChainState { + spec_version: 1_000_000, + transaction_version: 1, + genesis_hash: [0xab; 32], + nonce: 0, + restrict_origins: false, + }; + let entropy = [0x11; 32]; + let memberships = [RingParams { + collection: PersonhoodCollection::LitePeople, + members: vec![proof::member_key_now(entropy)], + exponent: 9, + ring_index: 0, + block_hash: "0xfinal".to_string(), + }]; + let targets = [ + target_with("first", [0xa1; 32]), + target_with("second", [0xa2; 32]), + target_with("third", [0xa3; 32]), + ]; + + // Every target's table is scanned first, then the first target is rescanned, + // registered and verified, and the second is rescanned before its proof. + // Nothing is scripted after that, so the pass cannot reach the third target. + let mut responses = vec!["null".to_string(); 30]; + responses.extend(vec!["null".to_string(); 10]); + responses.push("null".to_string()); + responses.push(entry(targets[0].account_id)); + responses.push(entry(targets[0].account_id)); + responses.extend(vec!["null".to_string(); 9]); + responses.push("null".to_string()); + let scripted = ScriptedRpc::new(responses.iter().map(String::as_str).collect::>()); + scripted.script_subscription([r#"{"inBlock":"0xb10c"}"#]); + let rpc = RpcClient::new(HostRpcClient::new(scripted)); + + let candidates = lite_people(entropy); + let signer = WalletChangesBeforeSecondProof { + keys: futures::executor::block_on(FixedPersonhoodSigner::new(&candidates)).unwrap(), + proofs: AtomicUsize::new(0), + }; + let context = RenewalChainContext { + rpc: &rpc, + metadata, + chain_state: &chain_state, + network_suffix: b"paseo", + signer: &signer, + collections: &[PersonhoodCollection::LitePeople], + memberships: &memberships, + }; + let report = + futures::executor::block_on(renew_targets(&context, 7, &targets, &Mutex::new(()))); + + let wallet_changed = TargetRenewalStatus::Failed { reason: "Disconnected".to_string() }; + assert_eq!( + report, + StatementRenewalReport { + period: 7, + outcomes: vec![ + StatementRenewalOutcome { + label: "first".to_string(), + status: TargetRenewalStatus::Registered { + seq: 0, + block_hash: "0xb10c".to_string(), + }, + }, + StatementRenewalOutcome { + label: "second".to_string(), + status: wallet_changed.clone(), + }, + StatementRenewalOutcome { label: "third".to_string(), status: wallet_changed }, + ], + pruned: Vec::new(), + slots_exhausted: false, + } + ); + } + #[test] fn a_new_target_cannot_replace_an_existing_later_target() { use parity_scale_codec::Encode; @@ -723,7 +894,11 @@ mod tests { seq: 1, collection: PersonhoodCollection::LitePeople, }), - Err(RenewalFailure { reason: "rpc timeout".to_string(), slots_exhausted: false }), + Err(RenewalFailure { + reason: "rpc timeout".to_string(), + slots_exhausted: false, + wallet_changed: false, + }), Ok(RegistrationOutcome::Registered { block_hash: "0xabc".to_string(), seq: 2, diff --git a/rust/crates/truapi/src/runtime/statement_allowance/rpc.rs b/rust/crates/truapi/src/runtime/statement_allowance/rpc.rs index cdaf8ca0a7..0e5a06346a 100644 --- a/rust/crates/truapi/src/runtime/statement_allowance/rpc.rs +++ b/rust/crates/truapi/src/runtime/statement_allowance/rpc.rs @@ -293,6 +293,7 @@ pub mod testing { struct Inner { calls: Mutex>, responses: Mutex>, + response_gate: Mutex)>>, subscription_batches: Mutex>>, subscription_errors: Mutex>, } @@ -306,6 +307,13 @@ pub mod testing { scripted } + /// Hold one response after recording its request; indices include subscriptions. + pub fn pause_response(&self, index: usize) -> futures::channel::oneshot::Sender<()> { + let (release, gate) = futures::channel::oneshot::channel(); + *self.0.response_gate.lock().unwrap() = Some((index, gate)); + release + } + /// Queue the notification items for one subscription. Call once per /// expected submission; batches are replayed in order. pub fn script_subscription<'a>(&self, items: impl IntoIterator) { @@ -358,7 +366,20 @@ pub mod testing { params: Option>, ) -> RawRpcFuture<'a, Box> { let params = params_json(params); - self.0.calls.lock().unwrap().push((method.to_owned(), params.clone())); + let index = { + let mut calls = self.0.calls.lock().unwrap(); + let index = calls.len(); + calls.push((method.to_owned(), params.clone())); + index + }; + let gate = { + let mut gate = self.0.response_gate.lock().unwrap(); + if gate.as_ref().is_some_and(|(paused, _)| *paused == index) { + gate.take().map(|(_, gate)| gate) + } else { + None + } + }; let mut responses = self.0.responses.lock().unwrap(); assert!(!responses.is_empty(), "unscripted request `{method}`"); let response = if method == "state_queryStorageAt" { @@ -367,6 +388,9 @@ pub mod testing { responses.remove(0) }; Box::pin(async move { + if let Some(gate) = gate { + gate.await.expect("scripted response released"); + } Ok(RawValue::from_string(response).expect("scripted response is valid JSON")) }) } diff --git a/rust/crates/truapi/src/runtime/statement_store.rs b/rust/crates/truapi/src/runtime/statement_store.rs index d2265bed98..c6e50c14b8 100644 --- a/rust/crates/truapi/src/runtime/statement_store.rs +++ b/rust/crates/truapi/src/runtime/statement_store.rs @@ -9,9 +9,9 @@ use core::{ use futures::StreamExt as _; use super::{ - PERMISSION_DENIED_REASON, ProductRuntimeHost, - authority::{AuthorityError, StatementStoreAllowanceKey}, - remote_authority_call, remote_authority_context, + PERMISSION_DENIED_REASON, ProductConnection, ProductRuntimeHost, + authority::{AuthorityError, AuthoritySession, StatementStoreAllowanceKey}, + remote_authority_context, statement_store_rpc::{self, StatementStoreRpc}, }; use crate::host_logic::statement_store::{ @@ -20,9 +20,6 @@ use crate::host_logic::statement_store::{ statement_fields_from_v01, statement_proof_to_v01, unsigned_statement_signing_payload, }; -use crate::platform::{ - CrossProductSignatureKind, StatementStoreProductSignReview, UserConfirmationReview, -}; use serde_json::Value; use subxt_rpcs::client::RpcSubscription; use tracing::instrument; @@ -42,7 +39,7 @@ use truapi::{ }; #[truapi::async_trait] -impl StatementStore for ProductRuntimeHost { +impl StatementStore for ProductRuntimeHost { #[instrument(skip_all, fields(runtime.method = "statement_store.subscribe"))] async fn subscribe( &self, @@ -52,7 +49,7 @@ impl StatementStore for ProductRuntimeHost { RemoteStatementStoreSubscribeItem, CallError, > { - match self.open_statement_subscription(request).await { + match self.connection.open_statement_subscription(request).await { Ok(subscription) => subscription, Err(error) => Subscription::interrupted(error), } @@ -74,7 +71,9 @@ impl StatementStore for ProductRuntimeHost { latest::RemoteStatementStoreCreateProofError::UnknownAccount, )) })?; + let authority_session = self.accounts.current_session(); let Some(owner) = self + .accounts .authorized_product_account(&inner.product_account_id.dot_ns_identifier, cx) .await else { @@ -83,8 +82,16 @@ impl StatementStore for ProductRuntimeHost { ))); }; inner.product_account_id.dot_ns_identifier = owner; + let authority_session = authority_session + .ok_or(StatementProofFailure::NoSession) + .map_err(statement_proof_error)?; let proof = self - .create_product_statement_proof(cx, inner.product_account_id, inner.statement) + .create_product_statement_proof( + cx, + &authority_session, + inner.product_account_id, + inner.statement, + ) .await .map_err(statement_proof_error)?; Ok(RemoteStatementStoreCreateProofResponse::V1( @@ -134,15 +141,24 @@ impl StatementStore for ProductRuntimeHost { let encoded = signed_statement_to_scale(statement.clone()).map_err(|reason| { CallError::Domain(RemoteStatementStoreSubmitError::V1(latest::GenericError { reason })) })?; - self.statement_store_rpc() + if let latest::StatementProof::Sr25519 { signer, .. } = &statement.proof { + let renewal = super::remote_authority_context(cx); + super::remote_authority_call( + &renewal, + self.accounts.renew_statement_sponsorship(&renewal, *signer), + ) + .await + .map_err(|error| { + CallError::Domain(RemoteStatementStoreSubmitError::V1(latest::GenericError { + reason: error.to_string(), + })) + })?; + } + self.connection + .statement_store_rpc() .submit_sso(encoded, "statement-store") .await .map_err(|reason| { - if let latest::StatementProof::Sr25519 { signer, .. } = statement.proof && - statement_store_rpc::is_no_allowance_rejection(&reason) - { - self.accounts.forget_statement_store_allowance_key(signer); - } CallError::Domain(RemoteStatementStoreSubmitError::V1(latest::GenericError { reason: format!("statement-store submit failed: {reason}"), })) @@ -239,7 +255,7 @@ impl futures::Stream for StatementStoreSubscriptionStream { } } -impl ProductRuntimeHost { +impl ProductConnection { /// Open the remote statement-store subscription, reporting a failure /// before its first item as the interrupt the subscription ends with. async fn open_statement_subscription( @@ -283,14 +299,14 @@ impl ProductRuntimeHost { }; let remote_stream = statement_store_subscription_stream(subscription, remote_subscription_id); - let cached = self.connection.services.cached_statements(kind, &topics); + let cached = self.services.cached_statements(kind, &topics); let cached_page = (!cached.is_empty()).then(|| { RemoteStatementStoreSubscribeItem::V1(latest::RemoteStatementStoreSubscribeItem { statements: cached.clone(), is_complete: false, }) }); - let services = self.connection.services.clone(); + let services = self.services.clone(); let mut pending_local = cached; let remote_stream = remote_stream.filter_map(move |item| { let RemoteStatementStoreSubscribeItem::V1(mut page) = item; @@ -316,58 +332,38 @@ impl ProductRuntimeHost { /// `StatementStoreRpc` bound to this runtime's people chain. pub fn statement_store_rpc(&self) -> StatementStoreRpc { - self.connection.services.statement_store.clone() + self.services.statement_store.clone() } +} +impl ProductRuntimeHost { async fn create_product_statement_proof( &self, cx: &CallContext, + authority_session: &AuthoritySession, product_account_id: latest::ProductAccountId, statement: latest::Statement, ) -> Result { - let session = self.accounts.current_session().ok_or(StatementProofFailure::NoSession)?; let signer = self - .product_account_public_key(cx, &session, &product_account_id) + .accounts + .product_account_public_key(cx, authority_session, &product_account_id) .await .map_err(|err| StatementProofFailure::UnableToSign(err.to_string()))?; let fields = statement_fields_from_v01(statement) .map_err(StatementProofFailure::InvalidStatement)?; let payload = unsigned_statement_signing_payload(fields) .map_err(StatementProofFailure::UnableToSign)?; - if product_account_id.dot_ns_identifier != self.connection.product_id() { - let confirmed = self - .connection - .confirm_signature( - CrossProductSignatureKind::StatementProof, - &product_account_id, - || { - UserConfirmationReview::StatementStoreProductSign( - StatementStoreProductSignReview { - calling_product_id: Some(self.connection.product_id()), - account: product_account_id.clone(), - payload: payload.clone(), - }, - ) - }, - ) - .await - .map_err(|err| StatementProofFailure::UnableToSign(err.reason))?; - if !confirmed { - return Err(StatementProofFailure::Refused); - } - } - let cx = remote_authority_context(cx); - let signature = remote_authority_call( - &cx, - self.accounts.sign_statement_store_product_payload( - &session, - &cx, + let signature = self + .accounts + .sign_statement_store_product_payload( + authority_session, + cx, + &self.connection.temporary_permissions, product_account_id, payload, - ), - ) - .await - .map_err(statement_authority_failure)?; + ) + .await + .map_err(statement_authority_failure)?; Ok(latest::StatementProof::Sr25519 { signature, signer }) } @@ -376,23 +372,26 @@ impl ProductRuntimeHost { cx: &CallContext, statement: latest::Statement, ) -> Result { - let session = self.accounts.current_session().ok_or(StatementProofFailure::NoSession)?; + let authority_session = + self.accounts.current_session().ok_or(StatementProofFailure::NoSession)?; let cx = remote_authority_context(cx); - let allowance = - remote_authority_call(&cx, self.accounts.statement_store_allowance_key(&cx, &session)) - .await - .map_err(statement_authority_failure)?; - create_statement_proof_with_key(statement, &allowance) + super::remote_authority_call( + &cx, + self.accounts + .create_authorized_statement_proof(&cx, &authority_session, statement), + ) + .await } } -fn create_statement_proof_with_key( +/// Encode the proof produced by an already-authorized retained key. +pub fn create_statement_proof_with_key( statement: latest::Statement, key: &StatementStoreAllowanceKey, ) -> Result { let fields = statement_fields_from_v01(statement).map_err(StatementProofFailure::InvalidStatement)?; - let signed = sign_statement_fields(key.secret, key.public_key, fields) + let signed = sign_statement_fields(*key.as_secret_bytes(), key.public_key, fields) .map_err(StatementProofFailure::UnableToSign)?; signed .into_iter() @@ -405,7 +404,8 @@ fn create_statement_proof_with_key( .ok_or_else(|| StatementProofFailure::UnableToSign("missing proof".to_string())) } -enum StatementProofFailure { +/// Failure while authorizing or constructing a statement proof. +pub enum StatementProofFailure { NoSession, /// The user refused a signature made with another product's account. Refused, @@ -413,9 +413,20 @@ enum StatementProofFailure { UnableToSign(String), } -fn statement_authority_failure(err: AuthorityError) -> StatementProofFailure { +impl From for StatementProofFailure { + fn from(error: AuthorityError) -> Self { + statement_authority_failure(error) + } +} + +/// Preserve consent and session failures at the statement boundary. +pub fn statement_authority_failure(err: AuthorityError) -> StatementProofFailure { match err { AuthorityError::Disconnected => StatementProofFailure::NoSession, + AuthorityError::Rejected => StatementProofFailure::Refused, + AuthorityError::ConfirmationFailed(error) => { + StatementProofFailure::UnableToSign(error.reason) + }, err => StatementProofFailure::UnableToSign(err.to_string()), } } @@ -455,7 +466,9 @@ fn statement_proof_authorized_error( #[cfg(test)] mod tests { use super::{ - super::{LocalActivation, RuntimeServices, SigningHostRole}, + super::{ + LocalActivation, RuntimeServices, SigningHostRole, signing_host::WalletAccountHolder, + }, *, }; use crate::{ @@ -508,14 +521,16 @@ mod tests { .expect("stub core storage accepts the entry"); } - fn signing_host_runtime(product_id: &str) -> (ProductRuntimeHost, Arc) { + fn signing_host_runtime( + product_id: &str, + ) -> (ProductRuntimeHost, Arc) { signing_host_runtime_on(product_id, Arc::new(StubPlatform::default())) } fn signing_host_runtime_on( product_id: &str, platform: Arc, - ) -> (ProductRuntimeHost, Arc) { + ) -> (ProductRuntimeHost, Arc) { let platform: Arc = platform; let services = RuntimeServices::new( platform.clone(), @@ -536,7 +551,7 @@ mod tests { let host = ProductRuntimeHost::from_services( services.clone(), crate::host_core::ConnectionAdapters::from_services(&services), - signing_host.clone(), + signing_host.accounts().clone(), signing_host.clone(), ProductContext::new(product_id.to_string()).expect("valid product id"), ); @@ -670,7 +685,7 @@ mod tests { } fn create_proof_as( - host: &ProductRuntimeHost, + host: &ProductRuntimeHost, account: latest::ProductAccountId, ) -> Result< RemoteStatementStoreCreateProofResponse, @@ -824,6 +839,79 @@ mod tests { ); } + /// The phone renews the sponsorships it issued, so submitting a sponsored statement + /// sends the phone nothing and needs no answer from it. + #[test] + fn a_sponsored_statement_is_submitted_without_asking_the_phone() { + let session = sso_session_info(); + let (allowance_secret, signer) = allowance_key(11); + let platform = Arc::new(StubPlatform { + sso_response_script: Some(sso_success_response_script( + &session, + crate::host_internal::sso_messages::RemoteMessage { + message_id: "wallet-proof-auth-1".to_string(), + data: crate::host_internal::sso_messages::RemoteMessageData::V1( + crate::host_internal::sso_messages::v1::RemoteMessage::ResourceAllocationResponse( + crate::host_internal::sso_messages::Response { + responding_to: "proof-auth-1".to_string(), + payload: Ok(vec![ + crate::host_internal::sso_messages::SsoAllocationOutcome::Allocated( + crate::host_internal::sso_messages::SsoAllocatedResource::StatementStoreAllowance { + slot_account_key: allowance_secret.to_vec(), + }, + ), + ]), + }, + ), + ), + }, + )), + ..Default::default() + }); + let host = + ProductRuntimeHost::new(platform.clone(), runtime_config("myapp.dot"), test_spawner()); + host.test_session_state().set_session(session); + futures::executor::block_on(StatementStore::create_proof_authorized( + &host, + &CallContext::with_request_id("proof-auth-1".to_string()), + RemoteStatementStoreCreateProofAuthorizedRequest::V1(statement()), + )) + .unwrap(); + let published_before = published_statements(&platform).len(); + let sponsored = latest::SignedStatement { + proof: latest::StatementProof::Sr25519 { signature: [9; 64], signer }, + ..signed_statement([7; 32]) + }; + let mut cx = CallContext::with_request_id("submit-sponsored".to_string()); + cx.set_timeout(core::time::Duration::from_millis(50)); + + let _ = futures::executor::block_on(StatementStore::submit( + &host, + &cx, + RemoteStatementStoreSubmitRequest::V1(sponsored.clone()), + )); + + assert_eq!(published_statements(&platform)[published_before..], [sponsored]); + } + + /// Every statement this host published, SSO requests included, oldest first. + fn published_statements(platform: &StubPlatform) -> Vec { + platform + .sent_rpc + .lock() + .unwrap() + .iter() + .filter(|request| request.contains("\"statement_submit\"")) + .map(|request| { + let request: serde_json::Value = serde_json::from_str(request).unwrap(); + let statement_hex = request["params"][0].as_str().unwrap(); + let statement = + hex::decode(statement_hex.strip_prefix("0x").unwrap_or(statement_hex)).unwrap(); + crate::host_logic::statement_store::decode_signed_statement(&statement).unwrap() + }) + .collect() + } + #[test] fn statement_store_submit_posts_signed_statement_and_waits_for_ack() { let platform = Arc::new(StubPlatform { diff --git a/rust/crates/truapi/src/runtime/tests.rs b/rust/crates/truapi/src/runtime/tests.rs index 72659291b5..0a5f95256f 100644 --- a/rust/crates/truapi/src/runtime/tests.rs +++ b/rust/crates/truapi/src/runtime/tests.rs @@ -1,12 +1,16 @@ //! Shared runtime fixtures and cross-capability integration tests. +use super::{ + allowances::AllowanceResource, + authority::{AccountGrant, AutoSigningKey, StatementStoreAllowanceKey}, +}; use std::sync::{ Mutex, atomic::{AtomicBool, Ordering}, }; use crate::platform::{ - AuthState, CoreStorage as PlatformCoreStorage, CoreStorageKey, HostScan, + AccountAccessReview, AuthState, CoreStorage as PlatformCoreStorage, CoreStorageKey, HostScan, PermissionAuthorizationRequest, ProductExecutionKind, }; use parity_scale_codec::Encode; @@ -93,7 +97,7 @@ use crate::{ product_manifest::test_manifest_json, sso_messages::{RemoteMessage, RemoteMessageData, Response, v1}, }, - host_logic::product_account::index_bytes, + host_logic::product_account::{derive_product_public_key, index_bytes}, test_support::*, unix_time::current_unix_secs, }; @@ -112,7 +116,7 @@ fn test_product_account_public(product_id: &str, index: u32) -> [u8; 32] { .expect("test subtree derives an account") } -fn install_pairing_session(host: &ProductRuntimeHost, session: SessionInfo) { +fn install_pairing_session(host: &ProductRuntimeHost, session: SessionInfo) { let product_id = normalize_product_identifier(&host.connection.product_id()) .expect("test product identifier is valid"); if session.sso.is_some() { @@ -121,18 +125,14 @@ fn install_pairing_session(host: &ProductRuntimeHost, session: SessionInfo) { host.test_session_state().set_session(session); } -fn cache_test_product_subtree(host: &ProductRuntimeHost, session: &SessionInfo, product_id: &str) { +fn cache_test_product_subtree( + host: &ProductRuntimeHost, + session: &SessionInfo, + product_id: &str, +) { host.test_cache_product_subtree(session, product_id, test_product_subtree(product_id)); } -#[test] -fn preimage_reports_bulletin_allocation_rejection_with_context() { - assert_eq!( - bulletin_allowance_error_reason(AuthorityError::Rejected), - "Bulletin allowance allocation was rejected by the signing host" - ); -} - fn recorded_rpc_methods(sent_rpc: &Mutex>) -> Vec { sent_rpc .lock() @@ -279,7 +279,7 @@ fn local_storage_read_platform_error_preserves_the_request_version() { } fn read_storage( - host: &ProductRuntimeHost, + host: &ProductRuntimeHost, product: Option<&str>, key: &str, ) -> Result> { @@ -354,7 +354,7 @@ fn access_not_granted() -> CallError { } /// Reads `owner`'s stored value at `k`, requiring the grant to admit it. -fn granted_value(host: &ProductRuntimeHost, owner: &str) -> Option> { +fn granted_value(host: &ProductRuntimeHost, owner: &str) -> Option> { let HostLocalStorageReadResponse::V2(v01::HostLocalStorageReadResponse { value }) = read_storage(host, Some(owner), "k").expect("the grant admits the read") else { @@ -436,8 +436,10 @@ fn a_grant_to_another_product_does_not_admit_this_caller() { /// The account gate, which decides whether a signature may be made with /// another product's account. The caller is `unknown.dot` throughout, so a /// manifest names the bare label `unknown`. -fn account_target(host: &ProductRuntimeHost, target: &str) -> Option { - futures::executor::block_on(host.authorized_product_account(target, &CallContext::default())) +fn account_target(host: &ProductRuntimeHost, target: &str) -> Option { + futures::executor::block_on( + host.accounts.authorized_product_account(target, &CallContext::default()), + ) } #[test] @@ -501,7 +503,9 @@ fn a_subname_of_the_granting_product_is_admitted_under_its_base_grant() { } /// What `sign_payload` answers for an account owned by `wallet.dot`. -fn sign_with_wallets_account(host: &ProductRuntimeHost) -> CallError { +fn sign_with_wallets_account( + host: &ProductRuntimeHost, +) -> CallError { futures::executor::block_on(host.sign_payload( &CallContext::default(), HostSignPayloadRequest::V1(v01::HostSignPayloadRequest { @@ -632,7 +636,7 @@ fn with_no_session_a_proof_refusal_never_discloses_whether_a_grant_exists() { } fn proof_refusal( - host: &ProductRuntimeHost, + host: &ProductRuntimeHost, product: &str, ) -> Option> { futures::executor::block_on( @@ -784,7 +788,7 @@ fn contacts_host( platform: Arc, contacts: Option>, connected: bool, -) -> ProductRuntimeHost { +) -> ProductRuntimeHost { let (host_config, product) = runtime_config(product_id); let services = RuntimeServices::with_chat_platform( platform as Arc, @@ -798,13 +802,15 @@ fn contacts_host( if let Some(contacts) = contacts { services.install_contacts_platform(contacts); } - let pairing_host = PairingHost::new(services.clone(), host_config); + let pairing = PairingHost::new(services.clone(), host_config); + let accounts = pairing.accounts().clone(); + let sso = pairing.session().clone(); let adapters = crate::host_core::ConnectionAdapters::from_services(&services); let host = ProductRuntimeHost::from_services( services, adapters, - pairing_host.clone(), - pairing_host, + accounts.clone(), + sso.clone(), product, ); if connected { @@ -814,7 +820,7 @@ fn contacts_host( } fn pick( - host: &ProductRuntimeHost, + host: &ProductRuntimeHost, ) -> Result> { futures::executor::block_on(Contacts::pick( host, @@ -868,13 +874,15 @@ fn a_host_that_only_resolves_contacts_reports_unsupported() { None, ); services.install_contacts_platform(Arc::new(LookupOnlyContactsPlatform)); - let pairing_host = PairingHost::new(services.clone(), host_config); + let pairing = PairingHost::new(services.clone(), host_config); + let accounts = pairing.accounts().clone(); + let sso = pairing.session().clone(); let adapters = crate::host_core::ConnectionAdapters::from_services(&services); let host = ProductRuntimeHost::from_services( services, adapters, - pairing_host.clone(), - pairing_host, + accounts.clone(), + sso.clone(), product, ); install_pairing_session(&host, session_info()); @@ -967,19 +975,15 @@ fn the_handle_is_derived_from_the_sessions_secret_entropy_source() { let source = session_info() .root_entropy_source .expect("the test session carries an entropy source"); - let expected = crate::runtime::contacts::contact_handle( - &crate::runtime::contacts::handle_key_from_root_source(&source), - &account, - ); + let expected = + crate::runtime::contacts::ContactHandles::from_root_entropy_source(&source).mint(&account); assert_eq!(handle, expected, "the handle must key on the session's root entropy source"); // And is not what the public alternatives would have produced. for public in [session_info().public_key, [0u8; 32]] { assert_ne!( handle, - crate::runtime::contacts::contact_handle( - &crate::runtime::contacts::handle_key_from_root_source(&public), - &account, - ), + crate::runtime::contacts::ContactHandles::from_root_entropy_source(&public) + .mint(&account), "the handle must not be derivable from public material" ); } @@ -1178,7 +1182,9 @@ fn a_handle_no_contact_matches_refuses_the_call() { } /// A connected runtime whose contacts adapter is `contacts`. -fn host_with_contacts(contacts: Arc) -> ProductRuntimeHost { +fn host_with_contacts( + contacts: Arc, +) -> ProductRuntimeHost { let (host_config, product) = runtime_config("voting.dot"); let services = RuntimeServices::with_chat_platform( stub_platform() as Arc, @@ -1190,13 +1196,15 @@ fn host_with_contacts(contacts: Arc) -> P None, ); services.install_contacts_platform(contacts); - let pairing_host = PairingHost::new(services.clone(), host_config); + let pairing = PairingHost::new(services.clone(), host_config); + let accounts = pairing.accounts().clone(); + let sso = pairing.session().clone(); let adapters = crate::host_core::ConnectionAdapters::from_services(&services); let host = ProductRuntimeHost::from_services( services, adapters, - pairing_host.clone(), - pairing_host, + accounts.clone(), + sso.clone(), product, ); install_pairing_session(&host, session_info()); @@ -1272,10 +1280,8 @@ fn uncached_handles_are_resolved_in_one_lookup() { let source = session_info() .root_entropy_source .expect("the test session carries an entropy source"); - let key = crate::runtime::contacts::handle_key_from_root_source(&source); - let handles = [alice, bob].map(|account| v01::ContactHandle { - bytes: crate::runtime::contacts::contact_handle(&key, &account), - }); + let key = crate::runtime::contacts::ContactHandles::from_root_entropy_source(&source); + let handles = [alice, bob].map(|account| v01::ContactHandle { bytes: key.mint(&account) }); let call: Vec = handles.iter().flat_map(|handle| handle.bytes).collect(); let substituted = @@ -1453,13 +1459,15 @@ fn a_withdrawn_request_already_published_is_cancelled_on_the_phone() { host_config.asset_hub_chain_genesis_hash, test_spawner(), ); - let pairing_host = PairingHost::new(services.clone(), host_config); + let pairing = PairingHost::new(services.clone(), host_config); + let accounts = pairing.accounts().clone(); + let sso = pairing.session().clone(); let adapters = crate::host_core::ConnectionAdapters::from_services(&services); let host = ProductRuntimeHost::from_services( services, adapters, - pairing_host.clone(), - pairing_host.clone(), + accounts.clone(), + sso.clone(), product, ); install_pairing_session(&host, session.clone()); @@ -1474,7 +1482,7 @@ fn a_withdrawn_request_already_published_is_cancelled_on_the_phone() { }); let published = submitted_remote_message(&platform, &session).message_id; wait_until( - || pairing_host.newest_request_for_tests().as_deref() == Some(&published), + || sso.newest_request_for_tests().as_deref() == Some(&published), "the request was not published", ); @@ -1541,13 +1549,15 @@ fn a_request_that_times_out_is_not_withdrawn_from_the_phone() { host_config.asset_hub_chain_genesis_hash, test_spawner(), ); - let pairing_host = PairingHost::new(services.clone(), host_config); + let pairing = PairingHost::new(services.clone(), host_config); + let accounts = pairing.accounts().clone(); + let sso = pairing.session().clone(); let adapters = crate::host_core::ConnectionAdapters::from_services(&services); let host = ProductRuntimeHost::from_services( services, adapters, - pairing_host.clone(), - pairing_host.clone(), + accounts.clone(), + sso.clone(), product, ); install_pairing_session(&host, session.clone()); @@ -1561,7 +1571,7 @@ fn a_request_that_times_out_is_not_withdrawn_from_the_phone() { futures::executor::block_on(host.sign_raw(&cx, request)).unwrap_err(); let published = submitted_remote_message(&platform, &session).message_id; - assert_eq!(pairing_host.newest_request_for_tests(), Some(published)); + assert_eq!(sso.newest_request_for_tests(), Some(published)); } /// `message_id`s every `Cancel` this host has published names, oldest first. @@ -1604,13 +1614,15 @@ fn a_withdrawn_request_with_a_newer_one_behind_it_sends_no_cancel() { host_config.asset_hub_chain_genesis_hash, test_spawner(), ); - let pairing_host = PairingHost::new(services.clone(), host_config); + let pairing = PairingHost::new(services.clone(), host_config); + let accounts = pairing.accounts().clone(); + let sso = pairing.session().clone(); let adapters = crate::host_core::ConnectionAdapters::from_services(&services); let host = Arc::new(ProductRuntimeHost::from_services( services, adapters, - pairing_host.clone(), - pairing_host.clone(), + accounts.clone(), + sso.clone(), product, )); install_pairing_session(&host, session.clone()); @@ -1632,7 +1644,7 @@ fn a_withdrawn_request_with_a_newer_one_behind_it_sends_no_cancel() { || { let published = submitted_remote_messages(&platform, &session); published.len() == count && - pairing_host.newest_request_for_tests().as_deref() == + sso.newest_request_for_tests().as_deref() == published.last().map(|message| message.message_id.as_str()) }, "the request was not published", @@ -1677,14 +1689,16 @@ fn chat_post_message_screens_content_before_it_reaches_a_host() { spawner.clone(), ); let chat_platform = Arc::new(RecordingChatPlatform::default()); - let pairing_host = PairingHost::new(services.clone(), host_config); + let pairing = PairingHost::new(services.clone(), host_config); + let accounts = pairing.accounts().clone(); + let sso = pairing.session().clone(); let mut adapters = crate::host_core::ConnectionAdapters::from_services(&services); adapters.chat_platform = Some(chat_platform.clone()); let host = ProductRuntimeHost::from_services( services, adapters, - pairing_host.clone(), - pairing_host, + accounts.clone(), + sso.clone(), product, ); install_pairing_session(&host, session_info()); @@ -1828,16 +1842,12 @@ fn a_room_footer_reaches_the_host_for_the_room_the_product_created() { bot_rooms: vec!["guest".to_string()], ..Default::default() }); - let pairing_host = PairingHost::new(services.clone(), host_config); + let pairing = PairingHost::new(services.clone(), host_config); + let accounts = pairing.accounts().clone(); + let sso = pairing.session().clone(); let mut adapters = crate::host_core::ConnectionAdapters::from_services(&services); adapters.chat_platform = Some(chat_platform.clone()); - let host = ProductRuntimeHost::from_services( - services, - adapters, - pairing_host.clone(), - pairing_host, - product, - ); + let host = ProductRuntimeHost::from_services(services, adapters, accounts, sso, product); install_pairing_session(&host, session_info()); futures::executor::block_on(Chat::create_room( @@ -1935,16 +1945,12 @@ fn chat_post_message_screens_the_alt_before_it_reaches_a_host() { spawner.clone(), ); let chat_platform = Arc::new(RecordingChatPlatform::default()); - let pairing_host = PairingHost::new(services.clone(), host_config); + let pairing = PairingHost::new(services.clone(), host_config); + let accounts = pairing.accounts().clone(); + let sso = pairing.session().clone(); let mut adapters = crate::host_core::ConnectionAdapters::from_services(&services); adapters.chat_platform = Some(chat_platform.clone()); - let host = ProductRuntimeHost::from_services( - services, - adapters, - pairing_host.clone(), - pairing_host, - product, - ); + let host = ProductRuntimeHost::from_services(services, adapters, accounts, sso, product); install_pairing_session(&host, session_info()); let card = || { @@ -2018,14 +2024,16 @@ fn chat_room_ids_agree_across_create_and_post() { spawner.clone(), ); let chat_platform = Arc::new(RecordingChatPlatform::default()); - let pairing_host = PairingHost::new(services.clone(), host_config); + let pairing = PairingHost::new(services.clone(), host_config); + let accounts = pairing.accounts().clone(); + let sso = pairing.session().clone(); let mut adapters = crate::host_core::ConnectionAdapters::from_services(&services); adapters.chat_platform = Some(chat_platform.clone()); let host = ProductRuntimeHost::from_services( services, adapters, - pairing_host.clone(), - pairing_host, + accounts.clone(), + sso.clone(), product, ); install_pairing_session(&host, session_info()); @@ -2103,14 +2111,16 @@ fn chat_register_bot_rejects_unsafe_product_fields() { spawner.clone(), ); let chat_platform = Arc::new(RecordingChatPlatform::default()); - let pairing_host = PairingHost::new(services.clone(), host_config); + let pairing = PairingHost::new(services.clone(), host_config); + let accounts = pairing.accounts().clone(); + let sso = pairing.session().clone(); let mut adapters = crate::host_core::ConnectionAdapters::from_services(&services); adapters.chat_platform = Some(chat_platform.clone()); let host = ProductRuntimeHost::from_services( services, adapters, - pairing_host.clone(), - pairing_host, + accounts.clone(), + sso.clone(), product.clone(), ); install_pairing_session(&host, session_info()); @@ -2193,14 +2203,16 @@ fn chat_register_bot_reaches_the_installed_adapter() { spawner.clone(), ); let chat_platform = Arc::new(RecordingChatPlatform::default()); - let pairing_host = PairingHost::new(services.clone(), host_config); + let pairing = PairingHost::new(services.clone(), host_config); + let accounts = pairing.accounts().clone(); + let sso = pairing.session().clone(); let mut adapters = crate::host_core::ConnectionAdapters::from_services(&services); adapters.chat_platform = Some(chat_platform.clone()); let host = ProductRuntimeHost::from_services( services.clone(), adapters, - pairing_host.clone(), - pairing_host, + accounts.clone(), + sso.clone(), product.clone(), ); install_pairing_session(&host, session_info()); @@ -2282,7 +2294,7 @@ impl crate::platform::PocketPlatform for RecordingPocketPlatform { fn pocket_dot_host( kind: crate::platform::ProductExecutionKind, configure: impl FnOnce(&mut crate::host_core::ConnectionAdapters), -) -> ProductRuntimeHost { +) -> ProductRuntimeHost { let (host_config, _) = runtime_config("pocket.dot"); let product = ProductContext::new_with_execution("pocket.dot".to_string(), kind) .expect("test pocket product context is valid"); @@ -2295,23 +2307,19 @@ fn pocket_dot_host( host_config.asset_hub_chain_genesis_hash, test_spawner(), ); - let pairing_host = PairingHost::new(services.clone(), host_config); + let pairing = PairingHost::new(services.clone(), host_config); + let accounts = pairing.accounts().clone(); + let sso = pairing.session().clone(); let mut adapters = crate::host_core::ConnectionAdapters::from_services(&services); configure(&mut adapters); - ProductRuntimeHost::from_services( - services, - adapters, - pairing_host.clone(), - pairing_host, - product, - ) + ProductRuntimeHost::from_services(services, adapters, accounts, sso, product) } fn pocket_host( kind: crate::platform::ProductExecutionKind, pocket: Option>, with_session: bool, -) -> ProductRuntimeHost { +) -> ProductRuntimeHost { let host = pocket_dot_host(kind, |adapters| { adapters.pocket_platform = pocket.map(|pocket| pocket as Arc); @@ -2323,7 +2331,7 @@ fn pocket_host( } fn remove_card( - host: &ProductRuntimeHost, + host: &ProductRuntimeHost, card_id: &str, ) -> Result> { futures::executor::block_on(Pocket::remove_card( @@ -2338,7 +2346,7 @@ fn remove_card( /// First thing a Pocket subscription yields: an item, or the interrupt that /// ended it before any item arrived. fn first_pocket_item( - host: &ProductRuntimeHost, + host: &ProductRuntimeHost, ) -> Option< Result< HostPocketListSubscribeItem, @@ -2413,7 +2421,7 @@ impl crate::platform::ExpandedCardHost for RecordingExpandedCardHost { fn expanded_card_host( kind: crate::platform::ProductExecutionKind, expanded_card: Option>, -) -> ProductRuntimeHost { +) -> ProductRuntimeHost { pocket_dot_host(kind, |adapters| { adapters.expanded_card = expanded_card.map(|card| card as Arc); @@ -2421,7 +2429,7 @@ fn expanded_card_host( } fn set_face_shown( - host: &ProductRuntimeHost, + host: &ProductRuntimeHost, shown: bool, ) -> Result< truapi::versioned::expanded_card::HostExpandedCardSetFaceShownResponse, @@ -2622,7 +2630,7 @@ fn game_host( kind: crate::platform::ProductExecutionKind, platform: Arc, game: Option>, -) -> ProductRuntimeHost { +) -> ProductRuntimeHost { game_host_for(GAME_PRODUCT, kind, platform, game) } @@ -2632,7 +2640,7 @@ fn game_host_for( kind: crate::platform::ProductExecutionKind, platform: Arc, game: Option>, -) -> ProductRuntimeHost { +) -> ProductRuntimeHost { let (host_config, _) = runtime_config(product_id); let product = ProductContext::new_with_execution(product_id.to_string(), kind) .expect("test game product context is valid"); @@ -2645,27 +2653,23 @@ fn game_host_for( host_config.asset_hub_chain_genesis_hash, test_spawner(), ); - let pairing_host = PairingHost::new(services.clone(), host_config); + let pairing = PairingHost::new(services.clone(), host_config); + let accounts = pairing.accounts().clone(); + let sso = pairing.session().clone(); let mut adapters = crate::host_core::ConnectionAdapters::from_services(&services); adapters.game_platform = game.map(|game| game as Arc); - ProductRuntimeHost::from_services( - services, - adapters, - pairing_host.clone(), - pairing_host, - product, - ) + ProductRuntimeHost::from_services(services, adapters, accounts, sso, product) } fn remind( - host: &ProductRuntimeHost, + host: &ProductRuntimeHost, starts_at: u64, ) -> Result> { remind_with(host, &CallContext::default(), starts_at) } fn remind_with( - host: &ProductRuntimeHost, + host: &ProductRuntimeHost, cx: &CallContext, starts_at: u64, ) -> Result> { @@ -2677,7 +2681,7 @@ fn remind_with( } fn cancel( - host: &ProductRuntimeHost, + host: &ProductRuntimeHost, ) -> Result> { futures::executor::block_on(Game::cancel_next_game( host, @@ -2862,19 +2866,21 @@ fn chain_follow_ids_are_scoped_per_product_core() { host_config.asset_hub_chain_genesis_hash, spawner.clone(), ); - let pairing_host = PairingHost::new(services.clone(), host_config); + let pairing = PairingHost::new(services.clone(), host_config); + let accounts = pairing.accounts().clone(); + let sso = pairing.session().clone(); let first = ProductRuntimeHost::from_services( services.clone(), crate::host_core::ConnectionAdapters::from_services(&services), - pairing_host.clone(), - pairing_host.clone(), + accounts.clone(), + sso.clone(), product.clone(), ); let second = ProductRuntimeHost::from_services( services.clone(), crate::host_core::ConnectionAdapters::from_services(&services), - pairing_host.clone(), - pairing_host, + accounts.clone(), + sso.clone(), product, ); @@ -2889,7 +2895,7 @@ fn bare_localhost_product_allows_dev_product_accounts() { assert_eq!( futures::executor::block_on( - host.authorized_product_account("myapp.dot", &CallContext::default()) + host.accounts.authorized_product_account("myapp.dot", &CallContext::default(),) ) .as_deref(), Some("myapp.dot") @@ -2978,12 +2984,14 @@ fn permission_prompts_name_the_requesting_product_and_execution_kind() { spawner, ); let adapters = crate::host_core::ConnectionAdapters::from_services(&services); - let pairing_host = PairingHost::new(services.clone(), host_config); + let pairing = PairingHost::new(services.clone(), host_config); + let accounts = pairing.accounts().clone(); + let sso = pairing.session().clone(); let host = ProductRuntimeHost::from_services( services, adapters, - pairing_host.clone(), - pairing_host, + accounts.clone(), + sso.clone(), product.clone(), ); let cx = CallContext::default(); @@ -3734,26 +3742,44 @@ fn get_account_other_product_maps_confirmation_failure_to_host_failure() { assert!(matches!(err, CallError::HostFailure { reason } if reason.contains("modal failed"))); } +/// Account access asks through the runtime's one prompt channel, so every +/// connection shares its decisions and its one-prompt-at-a-time rule. #[test] -fn get_account_other_product_accepts_confirmation_then_derives_key() { +fn get_account_asks_through_the_runtime_channel_not_the_connection() { + let runtime_platform = + Arc::new(StubPlatform { account_access_confirmed: true, ..Default::default() }); + let connection_platform = stub_platform(); let host = ProductRuntimeHost::new( - Arc::new(StubPlatform { account_access_confirmed: true, ..Default::default() }), + runtime_platform.clone(), runtime_config("myapp.dot"), test_spawner(), ); + let mut adapters = + crate::host_core::ConnectionAdapters::from_services(&host.connection.services); + adapters.platform = connection_platform.clone(); + let host = ProductRuntimeHost::from_services( + host.connection.services.clone(), + adapters, + host.accounts.host_accounts_for_tests().clone(), + host.host_session.clone(), + host.connection.product.clone(), + ); let session = sso_session_info(); install_pairing_session(&host, session.clone()); cache_test_product_subtree(&host, &session, "other.dot"); - let cx = CallContext::default(); let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { - product_account_id: v01::ProductAccountId { - dot_ns_identifier: "other.dot".to_string(), - derivation_index: v01::DerivationIndex::Index(0), - }, + product_account_id: account_id("other.dot", 0), }); - let response = futures::executor::block_on(host.get_account(&cx, request)).unwrap(); - let HostAccountGetResponse::V1(inner) = response; - assert_eq!(inner.account.public_key, test_product_account_public("other.dot", 0).to_vec()); + let HostAccountGetResponse::V1(inner) = + futures::executor::block_on(host.get_account(&CallContext::default(), request)).unwrap(); + assert_eq!( + ( + inner.account.public_key, + runtime_platform.account_access_reviews.lock().unwrap().len(), + connection_platform.account_access_reviews.lock().unwrap().len(), + ), + (test_product_account_public("other.dot", 0).to_vec(), 1, 0) + ); } #[test] @@ -3775,8 +3801,9 @@ fn get_account_other_product_skips_confirmation_for_a_blessed_product() { ); } +/// Allow once covers the product's later disclosures without writing a decision. #[test] -fn get_account_allow_once_does_not_authorize_the_next_disclosure() { +fn get_account_allow_once_answers_later_disclosures_without_saving() { futures::executor::block_on(async { let platform = Arc::new(StubPlatform { permission_confirmation_decisions: Mutex::new( @@ -3796,27 +3823,19 @@ fn get_account_allow_once_does_not_authorize_the_next_disclosure() { let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { product_account_id: account_id("other.dot", 0), }); - let response = host.get_account(&CallContext::default(), request.clone()).await.unwrap(); - let HostAccountGetResponse::V1(response) = response; + let mut disclosed = Vec::new(); + for _ in 0..3 { + let HostAccountGetResponse::V1(response) = + host.get_account(&CallContext::default(), request.clone()).await.unwrap(); + disclosed.push(response.account.public_key); + } let saved = platform .read_core_storage(CoreStorageKey::account_access_authorization("myapp", "other")) .await .unwrap(); - let mut rejected = Vec::new(); - for _ in 0..2 { - rejected.push(matches!( - host.get_account(&CallContext::default(), request.clone()).await, - Err(CallError::Domain(HostAccountGetError::V1(v01::HostAccountGetError::Rejected))) - )); - } assert_eq!( - ( - response.account.public_key, - saved, - rejected, - platform.account_access_reviews.lock().unwrap().len(), - ), - (test_product_account_public("other.dot", 0).to_vec(), None, vec![true, true], 2,), + (disclosed, saved, platform.account_access_reviews.lock().unwrap().len(),), + (vec![test_product_account_public("other.dot", 0).to_vec(); 3], None, 1,), ); }); } @@ -3841,6 +3860,46 @@ fn get_account_derives_rfc0022_product_key() { ); } +/// A cold subtree is confirmed on the paired host once, then fetched from the phone. +#[test] +fn get_account_own_product_confirms_a_cold_subtree_then_asks_the_phone() { + let session = sso_session_info(); + let platform = Arc::new(StubPlatform { + sso_response_script: Some(sso_success_response_script( + &session, + RemoteMessage { + message_id: "wallet-subtree-1".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::ProductSubtreeResponse( + crate::host_internal::sso_messages::Response { + responding_to: "subtree-1".to_string(), + payload: Ok(test_product_subtree("myapp.dot")), + }, + )), + }, + )), + ..Default::default() + }); + let host = + ProductRuntimeHost::new(platform.clone(), runtime_config("myapp.dot"), test_spawner()); + host.test_session_state().set_session(session); + let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { + product_account_id: account_id("myapp.dot", 0), + }); + + let HostAccountGetResponse::V1(inner) = futures::executor::block_on( + host.get_account(&CallContext::with_request_id("subtree-1".to_string()), request), + ) + .unwrap(); + + assert_eq!( + (inner.account.public_key, platform.product_subtree_reviews.lock().unwrap().clone()), + ( + test_product_account_public("myapp.dot", 0).to_vec(), + vec![crate::platform::ProductSubtreeReview { product_id: "myapp.dot".to_string() }] + ) + ); +} + #[test] fn get_account_own_product_prompts_and_rejects_on_a_cold_subtree() { let host = ProductRuntimeHost::new( @@ -4448,7 +4507,7 @@ fn a_preimage_grant_covers_uploads_without_a_per_upload_prompt() { // The stub has no signing host, so an authorized upload fails at the // allowance step. Getting that error means nothing between the permission // check and the allowance refused the upload. - let allowance_error = bulletin_allowance_error_reason(AuthorityError::Disconnected); + let allowance_error = AuthorityError::Disconnected.to_string(); let outcomes = [(vec![AllowAlways], 3), (vec![AllowOnce, AllowOnce], 2)].map( |(remote_permission_decisions, uploads)| { @@ -4705,7 +4764,7 @@ fn storage_item( } fn subscribe_storage_key( - host: &ProductRuntimeHost, + host: &ProductRuntimeHost, key: &str, ) -> Subscription> { futures::executor::block_on(LocalStorage::subscribe( @@ -4717,7 +4776,7 @@ fn subscribe_storage_key( )) } -fn write_storage_key(host: &ProductRuntimeHost, key: &str, value: &[u8]) { +fn write_storage_key(host: &ProductRuntimeHost, key: &str, value: &[u8]) { futures::executor::block_on(host.write( &CallContext::default(), HostLocalStorageWriteRequest::V1(v01::HostLocalStorageWriteRequest { @@ -5134,12 +5193,13 @@ fn idle_peer_disconnect_monitor_clears_session_store_and_broadcasts() { ..Default::default() }); let (host_config, product) = runtime_config("myapp.dot"); - let (host, pairing_host) = ProductRuntimeHost::new_pairing_for_tests( + let (host, pairing) = ProductRuntimeHost::new_pairing_for_tests( platform.clone(), host_config, product, test_spawner(), ); + let sso = pairing.session().clone(); install_pairing_session(&host, session.clone()); let mut statuses = host.test_session_state().subscribe(); assert_eq!( @@ -5149,7 +5209,7 @@ fn idle_peer_disconnect_monitor_clears_session_store_and_broadcasts() { ) ); - pairing_host.start_session_supervision_for_current_session(); + sso.start_remote_monitor_for_current_session(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); let disconnected = loop { @@ -5331,18 +5391,18 @@ fn resource_allocation_rejects_when_user_declines() { let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); install_pairing_session(&host, session_info()); let cx = CallContext::default(); - let err = futures::executor::block_on(ResourceAllocation::request( + let response = futures::executor::block_on(ResourceAllocation::request( &host, &cx, resource_allocation_request(), )) - .unwrap_err(); - match err { - CallError::Domain(HostRequestResourceAllocationError::V1( - v01::ResourceAllocationError::Unknown { reason }, - )) => assert_eq!(reason, "User rejected resource allocation"), - other => panic!("expected user-rejected resource allocation error, got {other:?}"), - } + .unwrap(); + assert_eq!( + response, + HostRequestResourceAllocationResponse::V1(v01::HostRequestResourceAllocationResponse { + outcomes: vec![v01::AllocationOutcome::Rejected; 2], + }) + ); } #[test] @@ -5584,7 +5644,7 @@ fn auto_signing_test_platform(session: &SessionInfo, request_id: &str) -> Arc, request_id: &str) { futures::executor::block_on(ResourceAllocation::request( host, &CallContext::with_request_id(request_id.to_string()), @@ -5606,7 +5666,7 @@ fn auto_signing_vrf_request() -> HostAccountSignVrfRequest { /// A pairing host holding an AutoSigning capability, with its SSO script /// already spent on the allocation: anything that relays from here fails, so a /// call that succeeds was served locally. -fn granted_pairing_host() -> (Arc, ProductRuntimeHost) { +fn granted_pairing_host() -> (Arc, ProductRuntimeHost) { let session = sso_session_info(); let platform = auto_signing_test_platform(&session, "auto-1"); let host = @@ -5657,10 +5717,48 @@ fn auto_signing_serves_sign_raw_locally_without_prompt_or_sso() { ); } +#[test] +fn legacy_raw_review_preserves_cached_product_signing() { + let session = sso_session_info(); + let mut platform = auto_signing_test_platform(&session, "auto-1"); + Arc::get_mut(&mut platform).unwrap().sign_raw_confirmed = true; + let host = + ProductRuntimeHost::new(platform.clone(), runtime_config("myapp.dot"), test_spawner()); + install_pairing_session(&host, session); + request_auto_signing(&host, "auto-1"); + let keypair = granted_keypair(); + let request = v01::HostSignRawWithLegacyAccountRequest { + signer: subxt::utils::AccountId32(keypair.public.to_bytes()).to_string(), + payload: v01::RawPayload::Bytes { bytes: b"legacy cached".to_vec() }, + }; + let HostSignRawWithLegacyAccountResponse::V1(response) = + futures::executor::block_on(host.sign_raw_with_legacy_account( + &CallContext::default(), + HostSignRawWithLegacyAccountRequest::V1(request.clone()), + )) + .expect("the reviewed legacy request uses the cached key without another SSO response"); + let signature = schnorrkel::Signature::from_bytes(&response.signature).unwrap(); + assert_eq!( + ( + keypair + .public + .verify_simple(b"substrate", b"legacy cached", &signature) + .is_ok(), + response.signed_transaction, + platform.sign_raw_reviews.lock().unwrap().clone() + ), + ( + true, + None, + vec![crate::platform::SignRawReview::LegacyAccount { request, watermarked: true }] + ), + ); +} + /// The account a picked contact resolves to, and the handle a product holds /// for them. Minted through the real picker so the handle is keyed the way a /// product's would be. -fn picked_contact(host: &ProductRuntimeHost) -> v01::ContactHandle { +fn picked_contact(host: &ProductRuntimeHost) -> v01::ContactHandle { let HostContactsPickResponse::V1(picked) = pick(host).expect("the picker opens"); let v01::ContactPickOutcome::Picked { handle } = picked.outcome else { panic!("the user picked someone"); @@ -5693,7 +5791,9 @@ fn transaction_naming( /// A granted host that also serves a picker, so what a transaction naming a /// contact ends up signing is visible with no prompt in the way. -fn granted_pairing_host_with_contact(account: [u8; 32]) -> (Arc, ProductRuntimeHost) { +fn granted_pairing_host_with_contact( + account: [u8; 32], +) -> (Arc, ProductRuntimeHost) { let (platform, host) = granted_pairing_host(); assert!( host.connection @@ -6099,12 +6199,14 @@ fn auto_signing_ring_vrf_requires_registration_and_signs_locally() { let session = sso_session_info(); let platform = Arc::new(StubPlatform::default()); - let (host, pairing_host) = ProductRuntimeHost::new_pairing_for_tests( + let (host, pairing) = ProductRuntimeHost::new_pairing_for_tests( platform.clone(), ProductRuntimeHost::compat_host_config(), ProductContext::new("myapp.dot".to_string()).unwrap(), test_spawner(), ); + let accounts = pairing.accounts().clone(); + let sso = pairing.session().clone(); install_pairing_session(&host, session.clone()); let root = @@ -6117,13 +6219,14 @@ fn auto_signing_ring_vrf_requires_registration_and_signs_locally() { "myapp.dot", ) .unwrap(); - futures::executor::block_on(pairing_host.remember_auto_signing_key_for_tests( + let revision = accounts.grants_for_tests().lifecycle().revision(); + futures::executor::block_on(accounts.grants_for_tests().remember_auto_signing_key( + &sso.session_state(), &session, - pairing_host.current_session_lifecycle_epoch(), + revision, "myapp.dot", subtree.public.to_bytes(), - subtree.secret.to_bytes(), - domain, + AutoSigningKey::from_parts(subtree.secret.to_bytes(), domain), )) .unwrap(); @@ -6150,7 +6253,7 @@ fn auto_signing_ring_vrf_requires_registration_and_signs_locally() { .expect("verifiable is linked") .member(&entropy) .unwrap(); - futures::executor::block_on(pairing_host.register_ring_vrf_key_for_tests( + futures::executor::block_on(accounts.register_ring_vrf_key_for_tests( &session, handle.clone(), ring, @@ -6176,7 +6279,7 @@ fn auto_signing_ring_vrf_requires_registration_and_signs_locally() { ); let mismatched_handle = account_id("myapp.dot", 8); - futures::executor::block_on(pairing_host.register_ring_vrf_key_for_tests( + futures::executor::block_on(accounts.register_ring_vrf_key_for_tests( &session, mismatched_handle.clone(), ring_location_fixture(), @@ -6248,12 +6351,13 @@ fn auto_signing_logout_reset_clears_cached_and_persisted_capability() { let session = sso_session_info(); let platform = auto_signing_test_platform(&session, "auto-logout"); let (host_config, product) = runtime_config("myapp.dot"); - let (host, pairing_host) = ProductRuntimeHost::new_pairing_for_tests( + let (host, pairing) = ProductRuntimeHost::new_pairing_for_tests( platform.clone(), host_config, product, test_spawner(), ); + let accounts = pairing.accounts().clone(); install_pairing_session(&host, session.clone()); request_auto_signing(&host, "auto-logout"); assert!( @@ -6264,7 +6368,7 @@ fn auto_signing_logout_reset_clears_cached_and_persisted_capability() { .contains_key(&core_storage_test_key(CoreStorageKey::AutoSigningKeys)) ); - futures::executor::block_on(pairing_host.logout_and_reset_pairing()).unwrap(); + futures::executor::block_on(pairing.logout()).unwrap(); assert!( !platform @@ -6275,9 +6379,10 @@ fn auto_signing_logout_reset_clears_cached_and_persisted_capability() { ); assert!( !futures::executor::block_on( - pairing_host.has_auto_signing_key_for_tests(&session, "myapp.dot") + accounts.grants_for_tests().auto_signing_key(&session, "myapp.dot") ) - .expect("AutoSigning storage remains readable"), + .expect("AutoSigning storage remains readable") + .is_some(), "logout must evict the in-memory AutoSigning capability" ); } @@ -6287,48 +6392,64 @@ fn stale_secret_allocations_cannot_persist_after_reset_and_same_owner_reactivati let session = sso_session_info(); let platform = Arc::new(StubPlatform::default()); let (host_config, product) = runtime_config("myapp.dot"); - let (host, pairing_host) = ProductRuntimeHost::new_pairing_for_tests( + let (host, pairing) = ProductRuntimeHost::new_pairing_for_tests( platform.clone(), host_config, product, test_spawner(), ); + let accounts = pairing.accounts().clone(); + let sso = pairing.session().clone(); install_pairing_session(&host, session.clone()); - let stale_epoch = pairing_host.current_session_lifecycle_epoch(); + let stale_epoch = accounts.grants_for_tests().lifecycle().revision(); let root = crate::host_logic::product_account::derive_root_keypair_from_entropy(&[0xAB; 16]).unwrap(); let subtree = crate::host_logic::product_account::derive_product_subtree_keypair(&root, "myapp.dot") .unwrap(); - futures::executor::block_on(pairing_host.logout_and_reset_pairing()).unwrap(); - futures::executor::block_on(pairing_host.set_connected_session_for_tests(session.clone())); + futures::executor::block_on(pairing.logout()).unwrap(); + futures::executor::block_on(sso.set_connected_session_for_tests(session.clone())); let auto_signing_error = - futures::executor::block_on(pairing_host.remember_auto_signing_key_for_tests( + futures::executor::block_on(accounts.grants_for_tests().remember_auto_signing_key( + &sso.session_state(), &session, stale_epoch, "myapp.dot", subtree.public.to_bytes(), - subtree.secret.to_bytes(), - [0x42; 32], + AutoSigningKey::from_parts(subtree.secret.to_bytes(), [0x42; 32]), )) .expect_err("the old AutoSigning allocation completion must be rejected"); - let statement_store_result = - futures::executor::block_on(pairing_host.cache_statement_store_allowance_key( + let statement_store_result = futures::executor::block_on( + accounts.grants_for_tests().retain_allowance( + &sso.session_state(), &session, stale_epoch, "myapp.dot", - subtree.secret.to_bytes().to_vec(), - )); + &AccountGrant::StatementStore { + key: StatementStoreAllowanceKey::from_secret_bytes( + subtree.secret.to_bytes().to_vec(), + ) + .unwrap(), + period: None, + }, + ), + ); let Err(statement_store_error) = statement_store_result else { panic!("the old statement-store allocation completion must be rejected"); }; - let bulletin_result = futures::executor::block_on(pairing_host.cache_bulletin_allowance_key( - &session, - stale_epoch, - "myapp.dot", - subtree.secret.to_bytes().to_vec(), - )); + let bulletin_result = futures::executor::block_on( + accounts.grants_for_tests().retain_allowance( + &sso.session_state(), + &session, + stale_epoch, + "myapp.dot", + &AccountGrant::Bulletin( + BulletinAllowanceKey::from_secret_bytes(subtree.secret.to_bytes().to_vec()) + .unwrap(), + ), + ), + ); let Err(bulletin_error) = bulletin_result else { panic!("the old Bulletin allocation completion must be rejected"); }; @@ -6350,9 +6471,10 @@ fn stale_secret_allocations_cannot_persist_after_reset_and_same_owner_reactivati ); assert!( !futures::executor::block_on( - pairing_host.has_auto_signing_key_for_tests(&session, "myapp.dot") + accounts.grants_for_tests().auto_signing_key(&session, "myapp.dot") ) - .expect("AutoSigning storage remains readable"), + .expect("AutoSigning storage remains readable") + .is_some(), "the stale allocation must not restore cached AutoSigning authority" ); } @@ -6362,15 +6484,17 @@ fn product_clear_preserves_other_capabilities_and_fences_stale_work() { let session = sso_session_info(); let platform = Arc::new(StubPlatform::default()); let (host_config, product) = runtime_config("myapp.dot"); - let (host, pairing_host) = ProductRuntimeHost::new_pairing_for_tests( + let (host, pairing) = ProductRuntimeHost::new_pairing_for_tests( platform.clone(), host_config, product, test_spawner(), ); + let accounts = pairing.accounts().clone(); + let sso = pairing.session().clone(); install_pairing_session(&host, session.clone()); cache_test_product_subtree(&host, &session, "other.dot"); - let stale_epoch = pairing_host.current_session_lifecycle_epoch(); + let stale_epoch = accounts.grants_for_tests().lifecycle().revision(); let root = crate::host_logic::product_account::derive_root_keypair_from_entropy(&[0xAB; 16]).unwrap(); let first = @@ -6381,105 +6505,139 @@ fn product_clear_preserves_other_capabilities_and_fences_stale_work() { .unwrap(); for (product_id, subtree) in [("myapp.dot", &first), ("other.dot", &other)] { - futures::executor::block_on(pairing_host.remember_auto_signing_key_for_tests( + futures::executor::block_on(accounts.grants_for_tests().remember_auto_signing_key( + &sso.session_state(), &session, stale_epoch, product_id, subtree.public.to_bytes(), - subtree.secret.to_bytes(), - [0x42; 32], + AutoSigningKey::from_parts(subtree.secret.to_bytes(), [0x42; 32]), )) .unwrap(); - futures::executor::block_on(pairing_host.cache_statement_store_allowance_key( - &session, - stale_epoch, - product_id, - subtree.secret.to_bytes().to_vec(), - )) + futures::executor::block_on( + accounts.grants_for_tests().retain_allowance( + &sso.session_state(), + &session, + stale_epoch, + product_id, + &AccountGrant::StatementStore { + key: StatementStoreAllowanceKey::from_secret_bytes( + subtree.secret.to_bytes().to_vec(), + ) + .unwrap(), + period: None, + }, + ), + ) .unwrap(); - futures::executor::block_on(pairing_host.cache_bulletin_allowance_key( - &session, - stale_epoch, - product_id, - subtree.secret.to_bytes().to_vec(), - )) + futures::executor::block_on( + accounts.grants_for_tests().retain_allowance( + &sso.session_state(), + &session, + stale_epoch, + product_id, + &AccountGrant::Bulletin( + BulletinAllowanceKey::from_secret_bytes(subtree.secret.to_bytes().to_vec()) + .unwrap(), + ), + ), + ) .unwrap(); } - futures::executor::block_on(pairing_host.clear_product_state("myapp.dot")).unwrap(); - let current_epoch = pairing_host.current_session_lifecycle_epoch(); + futures::executor::block_on(accounts.clear_product_state("myapp.dot")).unwrap(); + let current_epoch = accounts.grants_for_tests().lifecycle().revision(); assert_ne!(current_epoch, stale_epoch); - assert_eq!(pairing_host.capability_cache_sizes_for_tests(), (1, 1, 1, 1)); + assert_eq!(accounts.grants_for_tests().capability_cache_sizes_for_tests(), (1, 1, 1, 1)); assert!( !futures::executor::block_on( - pairing_host.has_auto_signing_key_for_tests(&session, "myapp.dot") + accounts.grants_for_tests().auto_signing_key(&session, "myapp.dot") ) .unwrap() + .is_some() ); assert!( futures::executor::block_on( - pairing_host.has_auto_signing_key_for_tests(&session, "other.dot") + accounts.grants_for_tests().auto_signing_key(&session, "other.dot") ) .unwrap() + .is_some() ); assert!( - futures::executor::block_on(pairing_host.cached_statement_store_allowance_key( + futures::executor::block_on(accounts.grants_for_tests().cached_allowance( + &sso.session_state(), &session, current_epoch, "myapp.dot", + AllowanceResource::StatementStore )) .unwrap() .is_none() ); assert!( - futures::executor::block_on(pairing_host.cached_statement_store_allowance_key( + futures::executor::block_on(accounts.grants_for_tests().cached_allowance( + &sso.session_state(), &session, current_epoch, "other.dot", + AllowanceResource::StatementStore )) .unwrap() .is_some() ); assert!( - futures::executor::block_on(pairing_host.cached_bulletin_allowance_key( + futures::executor::block_on(accounts.grants_for_tests().cached_allowance( + &sso.session_state(), &session, current_epoch, "myapp.dot", + AllowanceResource::Bulletin )) .unwrap() .is_none() ); assert!( - futures::executor::block_on(pairing_host.cached_bulletin_allowance_key( + futures::executor::block_on(accounts.grants_for_tests().cached_allowance( + &sso.session_state(), &session, current_epoch, "other.dot", + AllowanceResource::Bulletin )) .unwrap() .is_some() ); assert!(matches!( - futures::executor::block_on(pairing_host.remember_auto_signing_key_for_tests( + futures::executor::block_on(accounts.grants_for_tests().remember_auto_signing_key( + &sso.session_state(), &session, stale_epoch, "myapp.dot", first.public.to_bytes(), - first.secret.to_bytes(), - [0x42; 32], + AutoSigningKey::from_parts(first.secret.to_bytes(), [0x42; 32]) )), Err(AuthorityError::Disconnected) )); assert!(matches!( - futures::executor::block_on(pairing_host.cache_statement_store_allowance_key( - &session, - stale_epoch, - "myapp.dot", - first.secret.to_bytes().to_vec(), - )), + futures::executor::block_on( + accounts.grants_for_tests().retain_allowance( + &sso.session_state(), + &session, + stale_epoch, + "myapp.dot", + &AccountGrant::StatementStore { + key: StatementStoreAllowanceKey::from_secret_bytes( + first.secret.to_bytes().to_vec() + ) + .unwrap(), + period: None + }, + ) + ), Err(AuthorityError::Disconnected) )); - assert_eq!(pairing_host.capability_cache_sizes_for_tests(), (1, 1, 1, 1)); + assert_eq!(accounts.grants_for_tests().capability_cache_sizes_for_tests(), (1, 1, 1, 1)); assert_eq!( platform.local_storage.lock().expect("local storage mutex poisoned").len(), 2, @@ -6492,48 +6650,62 @@ fn reset_session_state_clears_all_capabilities_without_peer_traffic() { let session = sso_session_info(); let platform = Arc::new(StubPlatform::default()); let (host_config, product) = runtime_config("myapp.dot"); - let (host, pairing_host) = ProductRuntimeHost::new_pairing_for_tests( + let (host, pairing) = ProductRuntimeHost::new_pairing_for_tests( platform.clone(), host_config, product, test_spawner(), ); + let accounts = pairing.accounts().clone(); + let sso = pairing.session().clone(); install_pairing_session(&host, session.clone()); - let lifecycle_epoch = pairing_host.current_session_lifecycle_epoch(); + let lifecycle_epoch = accounts.grants_for_tests().lifecycle().revision(); let root = crate::host_logic::product_account::derive_root_keypair_from_entropy(&[0xAB; 16]).unwrap(); let subtree = crate::host_logic::product_account::derive_product_subtree_keypair(&root, "myapp.dot") .unwrap(); - futures::executor::block_on(pairing_host.remember_auto_signing_key_for_tests( + futures::executor::block_on(accounts.grants_for_tests().remember_auto_signing_key( + &sso.session_state(), &session, lifecycle_epoch, "myapp.dot", subtree.public.to_bytes(), - subtree.secret.to_bytes(), - [0x42; 32], + AutoSigningKey::from_parts(subtree.secret.to_bytes(), [0x42; 32]), )) .unwrap(); - futures::executor::block_on(pairing_host.cache_statement_store_allowance_key( - &session, - lifecycle_epoch, - "myapp.dot", - subtree.secret.to_bytes().to_vec(), - )) + futures::executor::block_on( + accounts.grants_for_tests().retain_allowance( + &sso.session_state(), + &session, + lifecycle_epoch, + "myapp.dot", + &AccountGrant::StatementStore { + key: StatementStoreAllowanceKey::from_secret_bytes( + subtree.secret.to_bytes().to_vec(), + ) + .unwrap(), + period: None, + }, + ), + ) .unwrap(); - futures::executor::block_on(pairing_host.cache_bulletin_allowance_key( + futures::executor::block_on(accounts.grants_for_tests().retain_allowance( + &sso.session_state(), &session, lifecycle_epoch, "myapp.dot", - subtree.secret.to_bytes().to_vec(), + &AccountGrant::Bulletin( + BulletinAllowanceKey::from_secret_bytes(subtree.secret.to_bytes().to_vec()).unwrap(), + ), )) .unwrap(); - assert_eq!(pairing_host.capability_cache_sizes_for_tests(), (1, 1, 1, 1)); + assert_eq!(accounts.grants_for_tests().capability_cache_sizes_for_tests(), (1, 1, 1, 1)); - futures::executor::block_on(pairing_host.reset_session_state()); + futures::executor::block_on(sso.reset_session_state()); - assert!(pairing_host.session_state().current().is_none()); - assert_eq!(pairing_host.capability_cache_sizes_for_tests(), (0, 0, 0, 0)); + assert!(sso.session_state().current().is_none()); + assert_eq!(accounts.grants_for_tests().capability_cache_sizes_for_tests(), (0, 0, 0, 0)); assert!(platform.local_storage.lock().expect("local storage mutex poisoned").is_empty()); assert!( platform.sent_rpc.lock().expect("sent RPC mutex poisoned").is_empty(), @@ -6546,39 +6718,53 @@ fn identity_replacement_clears_all_stale_wallet_capabilities() { let session = sso_session_info(); let platform = auto_signing_test_platform(&session, "auto-replace"); let (host_config, product) = runtime_config("myapp.dot"); - let (host, pairing_host) = ProductRuntimeHost::new_pairing_for_tests( + let (host, pairing) = ProductRuntimeHost::new_pairing_for_tests( platform.clone(), host_config, product, test_spawner(), ); + let accounts = pairing.accounts().clone(); + let sso = pairing.session().clone(); install_pairing_session(&host, session.clone()); request_auto_signing(&host, "auto-replace"); - let lifecycle_epoch = pairing_host.current_session_lifecycle_epoch(); + let lifecycle_epoch = accounts.grants_for_tests().lifecycle().revision(); let root = crate::host_logic::product_account::derive_root_keypair_from_entropy(&[0xAB; 16]).unwrap(); let subtree = crate::host_logic::product_account::derive_product_subtree_keypair(&root, "myapp.dot") .unwrap(); - futures::executor::block_on(pairing_host.cache_statement_store_allowance_key( - &session, - lifecycle_epoch, - "myapp.dot", - subtree.secret.to_bytes().to_vec(), - )) + futures::executor::block_on( + accounts.grants_for_tests().retain_allowance( + &sso.session_state(), + &session, + lifecycle_epoch, + "myapp.dot", + &AccountGrant::StatementStore { + key: StatementStoreAllowanceKey::from_secret_bytes( + subtree.secret.to_bytes().to_vec(), + ) + .unwrap(), + period: None, + }, + ), + ) .unwrap(); - futures::executor::block_on(pairing_host.cache_bulletin_allowance_key( + futures::executor::block_on(accounts.grants_for_tests().retain_allowance( + &sso.session_state(), &session, lifecycle_epoch, "myapp.dot", - subtree.secret.to_bytes().to_vec(), + &AccountGrant::Bulletin( + BulletinAllowanceKey::from_secret_bytes(subtree.secret.to_bytes().to_vec()).unwrap(), + ), )) .unwrap(); let mut replacement = session; replacement.public_key = [0x44; 32]; replacement.sso.as_mut().expect("fixture has SSO identity").identity_account_id = [0x55; 32]; - futures::executor::block_on(pairing_host.set_connected_session_for_tests(replacement.clone())); + futures::executor::block_on(sso.set_connected_session_for_tests(replacement.clone())); assert_eq!(host.test_session_state().current(), Some(replacement.clone())); assert!( @@ -6594,9 +6780,10 @@ fn identity_replacement_clears_all_stale_wallet_capabilities() { ); assert!( !futures::executor::block_on( - pairing_host.has_auto_signing_key_for_tests(&replacement, "myapp.dot") + accounts.grants_for_tests().auto_signing_key(&replacement, "myapp.dot") ) - .expect("AutoSigning storage remains readable"), + .expect("AutoSigning storage remains readable") + .is_some(), "a newly paired wallet must not reuse the previous wallet's cached capability" ); } @@ -6614,19 +6801,21 @@ fn auto_signing_restored_different_wallet_rejects_persisted_capability() { replacement.public_key = [0x66; 32]; replacement.sso.as_mut().expect("fixture has SSO identity").identity_account_id = [0x77; 32]; let (host_config, product) = runtime_config("myapp.dot"); - let (restored, pairing_host) = ProductRuntimeHost::new_pairing_for_tests( + let (restored, pairing) = ProductRuntimeHost::new_pairing_for_tests( platform.clone(), host_config, product, test_spawner(), ); + let accounts = pairing.accounts().clone(); install_pairing_session(&restored, replacement.clone()); assert!( !futures::executor::block_on( - pairing_host.has_auto_signing_key_for_tests(&replacement, "myapp.dot") + accounts.grants_for_tests().auto_signing_key(&replacement, "myapp.dot") ) - .expect("AutoSigning storage remains readable"), + .expect("AutoSigning storage remains readable") + .is_some(), "a different restored wallet must not use a prior wallet's capability" ); assert!( @@ -6680,12 +6869,13 @@ fn auto_signing_rejects_and_erases_legacy_unscoped_secret() { #[test] fn external_session_activation_is_memory_only_and_rejects_trailing_bytes() { let platform = Arc::new(StubPlatform::default()); - let (host, pairing_host) = + let (host, pairing) = ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let sso = pairing.session().clone(); let session = sso_session_info(); let blob = crate::host_logic::session::encode_persisted_session(&session); - futures::executor::block_on(pairing_host.activate_external_session(&blob)) + futures::executor::block_on(sso.activate_external_session(&blob)) .expect("valid external session activates"); assert_eq!(host.test_session_state().current(), Some(session.clone())); @@ -6698,13 +6888,13 @@ fn external_session_activation_is_memory_only_and_rejects_trailing_bytes() { "external activation must not copy the blob into core storage" ); - let invalid = futures::executor::block_on(pairing_host.activate_external_session(&[0xff])) + let invalid = futures::executor::block_on(sso.activate_external_session(&[0xff])) .expect_err("invalid bytes are rejected"); assert!(invalid.starts_with("invalid session blob:")); let mut trailing = blob; trailing.push(0); - let error = futures::executor::block_on(pairing_host.activate_external_session(&trailing)) + let error = futures::executor::block_on(sso.activate_external_session(&trailing)) .expect_err("trailing bytes are rejected"); assert_eq!(error, "invalid session blob: trailing bytes"); assert_eq!( @@ -6717,10 +6907,11 @@ fn external_session_activation_is_memory_only_and_rejects_trailing_bytes() { #[test] fn external_session_activation_reports_its_outcome_when_the_blob_is_corrupt() { let platform = Arc::new(StubPlatform::default()); - let (_host, pairing_host) = + let (_host, pairing) = ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let sso = pairing.session().clone(); - futures::executor::block_on(pairing_host.activate_external_session(&[0xff])) + futures::executor::block_on(sso.activate_external_session(&[0xff])) .expect_err("invalid bytes are rejected"); // The decode fails before any transition can run, so without an @@ -6736,10 +6927,11 @@ fn external_session_activation_reports_its_outcome_when_the_blob_is_corrupt() { #[test] fn resetting_session_state_reports_its_outcome_when_nothing_was_active() { let platform = Arc::new(StubPlatform::default()); - let (_host, pairing_host) = + let (_host, pairing) = ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let sso = pairing.session().clone(); - futures::executor::block_on(pairing_host.reset_session_state()); + futures::executor::block_on(sso.reset_session_state()); // Clearing an already-signed-out state changes nothing, so without an // explicit announcement this is the silent case a host cannot tell @@ -6753,10 +6945,11 @@ fn resetting_session_state_reports_its_outcome_when_nothing_was_active() { #[test] fn external_session_activation_replaces_and_fences_the_previous_session() { - let (host, pairing_host) = ProductRuntimeHost::new_compat_with_pairing( + let (host, pairing) = ProductRuntimeHost::new_compat_with_pairing( Arc::new(StubPlatform::default()), test_spawner(), ); + let sso = pairing.session().clone(); let first = sso_session_info(); let mut replacement = first.clone(); replacement.public_key = [0x44; 32]; @@ -6764,12 +6957,12 @@ fn external_session_activation_replaces_and_fences_the_previous_session() { replacement.sso.as_mut().expect("fixture has SSO").identity_account_id = [0x55; 32]; futures::executor::block_on( - pairing_host.activate_external_session( - &crate::host_logic::session::encode_persisted_session(&first), - ), + sso.activate_external_session(&crate::host_logic::session::encode_persisted_session( + &first, + )), ) .expect("first external session activates"); - futures::executor::block_on(pairing_host.activate_external_session( + futures::executor::block_on(sso.activate_external_session( &crate::host_logic::session::encode_persisted_session(&replacement), )) .expect("replacement external session activates"); @@ -6784,9 +6977,9 @@ fn store_notification_during_external_activation_restores_persisted_session() { session_blob: Some(crate::host_logic::session::encode_persisted_session(&persisted)), ..Default::default() }); - let (host, pairing_host) = - ProductRuntimeHost::new_compat_with_pairing(platform, test_spawner()); - pairing_host.clone().start_session_store_sync_for_tests(test_spawner()); + let (host, pairing) = ProductRuntimeHost::new_compat_with_pairing(platform, test_spawner()); + let sso = pairing.session().clone(); + sso.clone().start_session_store_sync(test_spawner()); wait_until( || host.test_session_state().current() == Some(persisted.clone()), "initial persisted session was not restored", @@ -6795,16 +6988,15 @@ fn store_notification_during_external_activation_restores_persisted_session() { let mut stale_external = persisted.clone(); stale_external.public_key = [0x44; 32]; let stale_blob = crate::host_logic::session::encode_persisted_session(&stale_external); - let (activation_entered, resume_activation) = - pairing_host.pause_external_session_activation_for_tests(); + let (activation_entered, resume_activation) = sso.pause_external_session_activation_for_tests(); let activation = std::thread::spawn({ - let pairing_host = pairing_host.clone(); - move || futures::executor::block_on(pairing_host.activate_external_session(&stale_blob)) + let sso = sso.clone(); + move || futures::executor::block_on(sso.activate_external_session(&stale_blob)) }); futures::executor::block_on(activation_entered) .expect("external activation reached the installation fence"); - pairing_host.notify_session_store_changed(); + sso.notify_session_store_changed(); resume_activation.send(()).expect("external activation remains in flight"); activation .join() @@ -6820,16 +7012,16 @@ fn store_notification_during_external_activation_restores_persisted_session() { #[test] fn disconnect_during_external_activation_prevents_stale_reinstallation() { - let (host, pairing_host) = ProductRuntimeHost::new_compat_with_pairing( + let (host, pairing) = ProductRuntimeHost::new_compat_with_pairing( Arc::new(StubPlatform::default()), test_spawner(), ); + let sso = pairing.session().clone(); let stale_blob = crate::host_logic::session::encode_persisted_session(&sso_session_info()); - let (activation_entered, resume_activation) = - pairing_host.pause_external_session_activation_for_tests(); + let (activation_entered, resume_activation) = sso.pause_external_session_activation_for_tests(); let activation = std::thread::spawn({ - let pairing_host = pairing_host.clone(); - move || futures::executor::block_on(pairing_host.activate_external_session(&stale_blob)) + let sso = sso.clone(); + move || futures::executor::block_on(sso.activate_external_session(&stale_blob)) }); futures::executor::block_on(activation_entered) .expect("external activation reached the installation fence"); @@ -6854,10 +7046,11 @@ fn stored_session_activation_resolves_after_connected_installation() { session_blob: Some(crate::host_logic::session::encode_persisted_session(&stored)), ..Default::default() }); - let (host, pairing_host) = + let (host, pairing) = ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let sso = pairing.session().clone(); - futures::executor::block_on(pairing_host.activate_stored_session()) + futures::executor::block_on(sso.activate_stored_session()) .expect("valid stored session activates"); assert_eq!(host.test_session_state().current(), Some(stored.clone())); @@ -6875,11 +7068,11 @@ fn stored_session_activation_rejects_invalid_blob_and_disconnects() { session_clears: session_clears.clone(), ..Default::default() }); - let (host, pairing_host) = - ProductRuntimeHost::new_compat_with_pairing(platform, test_spawner()); + let (host, pairing) = ProductRuntimeHost::new_compat_with_pairing(platform, test_spawner()); + let sso = pairing.session().clone(); install_pairing_session(&host, sso_session_info()); - let error = futures::executor::block_on(pairing_host.activate_stored_session()) + let error = futures::executor::block_on(sso.activate_stored_session()) .expect_err("invalid stored session is rejected"); assert!(error.starts_with("invalid stored auth session:")); @@ -6897,10 +7090,18 @@ fn activating_an_untagged_stored_session_restores_it_and_rewrites_the_slot() { let untagged = stored.encode(); let platform = Arc::new(StubPlatform { session_blob: Some(untagged.clone()), ..Default::default() }); - let (host, pairing_host) = + let (host, pairing) = ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let sso = pairing.session().clone(); - futures::executor::block_on(pairing_host.activate_stored_session()) + let notify_sso = Arc::downgrade(&sso); + *platform.on_auth_session_write.lock().unwrap() = Some(Arc::new(move || { + if let Some(sso) = notify_sso.upgrade() { + sso.notify_session_store_changed(); + } + })); + + futures::executor::block_on(sso.activate_stored_session()) .expect("an untagged stored session activates"); assert_eq!(host.test_session_state().current(), Some(stored.clone())); @@ -6927,10 +7128,11 @@ fn session_store_sync_restores_valid_blob_from_tick() { session_blob: Some(crate::host_logic::session::encode_persisted_session(&stored)), ..Default::default() }); - let (host, pairing_host) = + let (host, pairing) = ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let sso = pairing.session().clone(); - pairing_host.clone().start_session_store_sync_for_tests(test_spawner()); + sso.clone().start_session_store_sync(test_spawner()); wait_until( || host.test_session_state().current() == Some(stored.clone()), "session store sync did not restore valid blob", @@ -6954,10 +7156,11 @@ fn session_store_sync_restores_valid_blob_from_tick() { #[test] fn session_store_sync_announces_a_signed_out_boot() { let platform = Arc::new(StubPlatform::default()); - let (_host, pairing_host) = + let (_host, pairing) = ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let sso = pairing.session().clone(); - pairing_host.clone().start_session_store_sync_for_tests(test_spawner()); + sso.clone().start_session_store_sync(test_spawner()); wait_until( || !platform.auth_states.lock().expect("auth state list mutex poisoned").is_empty(), @@ -6976,16 +7179,17 @@ fn session_store_sync_announces_a_restored_boot_once() { session_blob: Some(crate::host_logic::session::encode_persisted_session(&stored)), ..Default::default() }); - let (_host, pairing_host) = + let (_host, pairing) = ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let sso = pairing.session().clone(); - pairing_host.clone().start_session_store_sync_for_tests(test_spawner()); + sso.clone().start_session_store_sync(test_spawner()); wait_until( || !platform.auth_states.lock().expect("auth state list mutex poisoned").is_empty(), "boot reconcile did not report the restored session", ); - futures::executor::block_on(pairing_host.activate_stored_session()) + futures::executor::block_on(sso.activate_stored_session()) .expect("valid stored session activates"); assert_eq!( *platform.auth_states.lock().expect("auth state list mutex poisoned"), @@ -7000,18 +7204,19 @@ fn session_store_sync_stays_silent_on_an_unchanged_tick() { session_blob: Some(crate::host_logic::session::encode_persisted_session(&stored)), ..Default::default() }); - let (_host, pairing_host) = + let (_host, pairing) = ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let sso = pairing.session().clone(); - pairing_host.clone().start_session_store_sync_for_tests(test_spawner()); + sso.clone().start_session_store_sync(test_spawner()); wait_until( || !platform.auth_states.lock().expect("auth state list mutex poisoned").is_empty(), "boot reconcile did not report the restored session", ); - pairing_host.notify_session_store_changed(); + sso.notify_session_store_changed(); wait_until( - || pairing_host.session_store_change_ticks_for_tests() == 1, + || sso.session_store_change_ticks_for_tests() == 1, "session store sync did not process the change tick", ); @@ -7027,18 +7232,19 @@ fn session_store_sync_stays_silent_on_an_unchanged_tick() { fn session_store_sync_replaces_valid_blob_and_broadcasts_connected() { let mut replacement = sso_session_info(); replacement.public_key = [0x44; 32]; - let (host, pairing_host) = ProductRuntimeHost::new_compat_with_pairing( + let (host, pairing) = ProductRuntimeHost::new_compat_with_pairing( Arc::new(StubPlatform { session_blob: Some(crate::host_logic::session::encode_persisted_session(&replacement)), ..Default::default() }), test_spawner(), ); + let sso = pairing.session().clone(); install_pairing_session(&host, sso_session_info()); let mut statuses = host.test_session_state().subscribe(); let _ = futures::executor::block_on(statuses.next()); - pairing_host.clone().start_session_store_sync_for_tests(test_spawner()); + sso.clone().start_session_store_sync(test_spawner()); assert_eq!( futures::executor::block_on(statuses.next()).unwrap(), @@ -7052,11 +7258,12 @@ fn session_store_sync_replaces_valid_blob_and_broadcasts_connected() { #[test] fn session_store_sync_clears_invalid_blob() { let platform = Arc::new(StubPlatform { session_blob: Some(vec![0xff]), ..Default::default() }); - let (host, pairing_host) = + let (host, pairing) = ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let sso = pairing.session().clone(); install_pairing_session(&host, sso_session_info()); - pairing_host.clone().start_session_store_sync_for_tests(test_spawner()); + sso.clone().start_session_store_sync(test_spawner()); wait_until( || host.test_session_state().current().is_none(), "session store sync did not clear invalid blob", @@ -7078,7 +7285,7 @@ fn session_store_sync_clears_invalid_blob() { #[test] fn session_store_sync_clears_unreadable_blob() { let session_clears = Arc::new(Mutex::new(0)); - let (host, pairing_host) = ProductRuntimeHost::new_compat_with_pairing( + let (host, pairing) = ProductRuntimeHost::new_compat_with_pairing( Arc::new(StubPlatform { session_error: Some("storage unavailable"), session_clears: session_clears.clone(), @@ -7086,9 +7293,10 @@ fn session_store_sync_clears_unreadable_blob() { }), test_spawner(), ); + let sso = pairing.session().clone(); install_pairing_session(&host, sso_session_info()); - pairing_host.clone().start_session_store_sync_for_tests(test_spawner()); + sso.clone().start_session_store_sync(test_spawner()); wait_until( || *session_clears.lock().unwrap() == 1, "session store sync did not clear unreadable blob", @@ -7103,7 +7311,7 @@ fn session_store_sync_clears_unreadable_blob() { #[test] fn session_store_sync_clears_once_on_initial_persistent_read_error() { let session_clears = Arc::new(Mutex::new(0)); - let (host, pairing_host) = ProductRuntimeHost::new_compat_with_pairing( + let (host, pairing) = ProductRuntimeHost::new_compat_with_pairing( Arc::new(StubPlatform { session_error: Some("storage unavailable"), session_clears: session_clears.clone(), @@ -7111,9 +7319,10 @@ fn session_store_sync_clears_once_on_initial_persistent_read_error() { }), test_spawner(), ); + let sso = pairing.session().clone(); install_pairing_session(&host, sso_session_info()); - pairing_host.clone().start_session_store_sync_for_tests(test_spawner()); + sso.clone().start_session_store_sync(test_spawner()); wait_until(|| *session_clears.lock().unwrap() == 1, "clear_stored_session was never called"); assert_eq!(*session_clears.lock().unwrap(), 1); @@ -7139,7 +7348,7 @@ fn disconnect_submits_disconnected_message_best_effort() { #[test] fn pairing_logout_clears_session_and_bootstrap_identity() { let platform = Arc::new(StubPlatform::default()); - let (host, pairing_host) = + let (host, pairing) = ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); install_pairing_session(&host, sso_session_info()); { @@ -7151,7 +7360,7 @@ fn pairing_logout_clears_session_and_bootstrap_identity() { ); } - futures::executor::block_on(pairing_host.logout_and_reset_pairing()).unwrap(); + futures::executor::block_on(pairing.logout()).unwrap(); assert!(host.test_session_state().current().is_none()); let storage = platform.local_storage.lock().expect("local storage mutex poisoned"); @@ -7211,9 +7420,10 @@ fn disconnect_emits_disconnected_auth_state_after_store_sync_connected() { session_blob: Some(crate::host_logic::session::encode_persisted_session(&stored)), ..Default::default() }); - let (host, pairing_host) = + let (host, pairing) = ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); - pairing_host.clone().start_session_store_sync_for_tests(test_spawner()); + let sso = pairing.session().clone(); + sso.clone().start_session_store_sync(test_spawner()); wait_until( || platform.auth_states.lock().expect("auth state list mutex poisoned").len() == 1, "session store sync did not emit connected auth state", @@ -7266,7 +7476,7 @@ fn feature_supported_encodes_response_to_known_bytes() { mod signing; #[test] -fn the_pairing_authority_refuses_a_foreign_ring_vrf_key_without_a_grant() { +fn host_accounts_refuse_a_foreign_ring_vrf_key_without_a_grant() { let (host_config, product) = runtime_config("dim2.dot"); let platform: Arc = stub_platform(); let services = RuntimeServices::new( @@ -7277,36 +7487,36 @@ fn the_pairing_authority_refuses_a_foreign_ring_vrf_key_without_a_grant() { host_config.asset_hub_chain_genesis_hash, test_spawner(), ); - let pairing_host = PairingHost::new(services.clone(), host_config); + let pairing = PairingHost::new(services.clone(), host_config); + let accounts = pairing.accounts().clone(); + let sso = pairing.session().clone(); let adapters = crate::host_core::ConnectionAdapters::from_services(&services); let host = ProductRuntimeHost::from_services( services, adapters, - pairing_host.clone(), - pairing_host.clone(), + accounts.clone(), + sso.clone(), product, ); install_pairing_session(&host, session_info()); - let session = pairing_host.current_session().expect("the pairing host has an active session"); + let authority_session = + accounts.current_session().expect("the pairing host has an active session"); - let proof = futures::executor::block_on(ProductAuthority::create_proof( - &*pairing_host, + let proof = futures::executor::block_on(accounts.create_proof( + &authority_session, &CallContext::default(), - &session, - crate::host_internal::sso_messages::ProductRequest { - calling_product_id: "dim2.dot".to_string(), - payload: v01::HostAccountCreateProofRequest { - key_handle: v01::ProductAccountId { - dot_ns_identifier: "peopl.dot".to_string(), - derivation_index: v01::DerivationIndex::Index(0), - }, - context: v01::ProductProofContext { - product_id: "dim2.dot".to_string(), - suffix: v01::DerivationIndex::Index(0), - }, - ring_location: ring_location_fixture(), - message: b"prove me".to_vec(), + &ProductContext::new("dim2.dot".to_string()).unwrap(), + v01::HostAccountCreateProofRequest { + key_handle: v01::ProductAccountId { + dot_ns_identifier: "peopl.dot".to_string(), + derivation_index: v01::DerivationIndex::Index(0), + }, + context: v01::ProductProofContext { + product_id: "dim2.dot".to_string(), + suffix: v01::DerivationIndex::Index(0), }, + ring_location: ring_location_fixture(), + message: b"prove me".to_vec(), }, )); assert_eq!( @@ -7315,19 +7525,16 @@ fn the_pairing_authority_refuses_a_foreign_ring_vrf_key_without_a_grant() { "the pairing authority must refuse a foreign key that no manifest granted" ); - let signed = futures::executor::block_on(ProductAuthority::ring_vrf_sign( - &*pairing_host, + let signed = futures::executor::block_on(accounts.ring_vrf_sign( + &authority_session, &CallContext::default(), - &session, - crate::host_internal::sso_messages::ProductRequest { - calling_product_id: "dim2.dot".to_string(), - payload: v01::HostAccountRingVrfSignRequest { - key_handle: v01::ProductAccountId { - dot_ns_identifier: "peopl.dot".to_string(), - derivation_index: v01::DerivationIndex::Index(0), - }, - message: b"sign me".to_vec(), + &ProductContext::new("dim2.dot".to_string()).unwrap(), + v01::HostAccountRingVrfSignRequest { + key_handle: v01::ProductAccountId { + dot_ns_identifier: "peopl.dot".to_string(), + derivation_index: v01::DerivationIndex::Index(0), }, + message: b"sign me".to_vec(), }, )); assert_eq!( @@ -7361,38 +7568,36 @@ fn the_pairing_authority_admits_a_context_only_its_product_granted() { host_config.asset_hub_chain_genesis_hash, test_spawner(), ); - let pairing_host = PairingHost::new(services.clone(), host_config); + let pairing = PairingHost::new(services.clone(), host_config); + let accounts = pairing.accounts().clone(); let adapters = crate::host_core::ConnectionAdapters::from_services(&services); let host = ProductRuntimeHost::from_services( services, adapters, - pairing_host.clone(), - pairing_host.clone(), + accounts.clone(), + pairing.session().clone(), product, ); install_pairing_session(&host, session_info()); - let session = - pairing_host.current_session().expect("the pairing host has an active session"); + let authority_session = + accounts.current_session().expect("the pairing host has an active session"); let mut cx = CallContext::default(); cx.set_timeout(Duration::from_millis(200)); - futures::executor::block_on(ProductAuthority::create_proof( - &*pairing_host, + futures::executor::block_on(accounts.create_proof( + &authority_session, &cx, - &session, - crate::host_internal::sso_messages::ProductRequest { - calling_product_id: "dim2next.dot".to_string(), - payload: v01::HostAccountCreateProofRequest { - key_handle: v01::ProductAccountId { - dot_ns_identifier: "peopl.dot".to_string(), - derivation_index: v01::DerivationIndex::Index(0), - }, - context: v01::ProductProofContext { - product_id: "dim2.dot".to_string(), - suffix: v01::DerivationIndex::Index(0), - }, - ring_location: ring_location_fixture(), - message: b"prove me".to_vec(), + &ProductContext::new("dim2next.dot".to_string()).unwrap(), + v01::HostAccountCreateProofRequest { + key_handle: v01::ProductAccountId { + dot_ns_identifier: "peopl.dot".to_string(), + derivation_index: v01::DerivationIndex::Index(0), }, + context: v01::ProductProofContext { + product_id: "dim2.dot".to_string(), + suffix: v01::DerivationIndex::Index(0), + }, + ring_location: ring_location_fixture(), + message: b"prove me".to_vec(), }, )) }; @@ -7430,13 +7635,15 @@ fn a_grant_lookup_obeys_the_callers_deadline() { host_config.asset_hub_chain_genesis_hash, test_spawner(), ); - let pairing_host = PairingHost::new(services.clone(), host_config); + let pairing = PairingHost::new(services.clone(), host_config); + let accounts = pairing.accounts().clone(); + let sso = pairing.session().clone(); let adapters = crate::host_core::ConnectionAdapters::from_services(&services); let host = ProductRuntimeHost::from_services( services, adapters, - pairing_host.clone(), - pairing_host, + accounts.clone(), + sso.clone(), product, ); install_pairing_session(&host, session_info()); @@ -7603,7 +7810,9 @@ impl crate::platform::ScannerPlatform for StubScannerPlatform { /// A runtime for `greenmarket.dot` with no session, and `scanner` installed /// when given. -fn scanner_host(scanner: Option>) -> ProductRuntimeHost { +fn scanner_host( + scanner: Option>, +) -> ProductRuntimeHost { scanner_host_for(ProductExecutionKind::App, scanner) } @@ -7611,7 +7820,7 @@ fn scanner_host(scanner: Option>) -> ProductRuntimeHost fn scanner_host_for( kind: ProductExecutionKind, scanner: Option>, -) -> ProductRuntimeHost { +) -> ProductRuntimeHost { let (host_config, mut product) = runtime_config("greenmarket.dot"); product.execution_kind = kind; let services = RuntimeServices::with_chat_platform( @@ -7626,15 +7835,11 @@ fn scanner_host_for( if let Some(scanner) = scanner { services.install_scanner_platform(scanner); } - let pairing_host = PairingHost::new(services.clone(), host_config); + let pairing = PairingHost::new(services.clone(), host_config); + let accounts = pairing.accounts().clone(); + let sso = pairing.session().clone(); let adapters = crate::host_core::ConnectionAdapters::from_services(&services); - ProductRuntimeHost::from_services( - services, - adapters, - pairing_host.clone(), - pairing_host, - product, - ) + ProductRuntimeHost::from_services(services, adapters, accounts, sso, product) } fn receipt_request() -> truapi::latest::HostScannerScanRequest { @@ -7657,7 +7862,7 @@ fn unwrap_scan( } fn scan( - host: &ProductRuntimeHost, + host: &ProductRuntimeHost, ) -> Result> { unwrap_scan(futures::executor::block_on(Scanner::scan( host, @@ -7847,15 +8052,22 @@ fn a_pairing_test_host_keeps_a_submitted_preimage_and_serves_it_back() { let session = sso_session_info(); let platform = Arc::new(StubPlatform::default()); let (host_config, product) = runtime_config("myapp.dot"); - let (host, pairing_host) = + let (host, pairing) = ProductRuntimeHost::new_pairing_for_tests(platform, host_config, product, test_spawner()); + let pairing_host = pairing.accounts().clone(); + let sso = pairing.session().clone(); install_pairing_session(&host, session.clone()); - let lifecycle_epoch = pairing_host.current_session_lifecycle_epoch(); - futures::executor::block_on(pairing_host.cache_bulletin_allowance_key( + let lifecycle_epoch = pairing_host.grants_for_tests().lifecycle().revision(); + let allowance = + crate::host_logic::product_account::derive_root_keypair_from_entropy(&[0x42; 32]).unwrap(); + futures::executor::block_on(pairing_host.grants_for_tests().retain_allowance( + &sso.session_state(), &session, lifecycle_epoch, "myapp.dot", - [0x42; 64].to_vec(), + &AccountGrant::Bulletin( + BulletinAllowanceKey::from_secret_bytes(allowance.secret.to_bytes().to_vec()).unwrap(), + ), )) .expect("the wallet's allowance is cached"); pairing_host.set_submit_preimages_locally(true); @@ -7885,6 +8097,115 @@ fn a_pairing_test_host_keeps_a_submitted_preimage_and_serves_it_back() { ); } +/// A product reset concerns that product's grants, not which wallet is +/// paired, so it keeps a session that is being activated. +#[test] +fn a_product_reset_during_activation_keeps_the_new_session() { + let (host, pairing) = + ProductRuntimeHost::new_compat_with_pairing(stub_platform(), test_spawner()); + let accounts = pairing.accounts().clone(); + let sso = pairing.session().clone(); + let session = sso_session_info(); + let blob = crate::host_logic::session::encode_persisted_session(&session); + let (activation_entered, resume_activation) = sso.pause_external_session_activation_for_tests(); + let activation = std::thread::spawn({ + let sso = sso.clone(); + move || futures::executor::block_on(sso.activate_external_session(&blob)) + }); + futures::executor::block_on(activation_entered) + .expect("external activation reached the installation fence"); + + futures::executor::block_on(accounts.clear_product_state("myapp.dot")).unwrap(); + resume_activation.send(()).expect("external activation remains in flight"); + activation + .join() + .expect("external activation thread panicked") + .expect("external activation completes"); + + assert_eq!(host.test_session_state().current(), Some(session)); +} + +/// Ending the paired session reaches the host that keeps its grants, which +/// forgets what was allowed once, as a wallet change does on a signing host. +#[test] +fn a_paired_disconnect_forgets_allow_once() { + let platform = Arc::new(StubPlatform { + permission_confirmation_decisions: Mutex::new( + [ + crate::platform::PermissionDecision::AllowOnce, + crate::platform::PermissionDecision::Deny, + ] + .into(), + ), + ..Default::default() + }); + let (host, pairing) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let sso = pairing.session().clone(); + let get_other_account = || { + let session = sso_session_info(); + install_pairing_session(&host, session.clone()); + cache_test_product_subtree(&host, &session, "other.dot"); + let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { + product_account_id: account_id("other.dot", 0), + }); + futures::executor::block_on(host.get_account(&CallContext::default(), request)).is_ok() + }; + let first = get_other_account(); + + futures::executor::block_on(HostSession::disconnect(sso.as_ref())); + let after_disconnect = get_other_account(); + + assert_eq!( + (first, after_disconnect, platform.account_access_reviews.lock().unwrap().len()), + (true, false, 2), + ); +} + +/// The paired host's account holder keeps its copy of what the phone +/// registered, so the host's own provider list shows the phone's answer. +#[test] +fn a_paired_registration_records_the_phones_key_locally() { + let session = sso_session_info(); + let public_key = [7; 32]; + let platform = Arc::new(StubPlatform { + sso_response_script: Some(sso_success_response_script( + &session, + RemoteMessage { + message_id: "wallet-register".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::RegisterRingVrfKeyResponse( + Response { responding_to: "register".to_string(), payload: Ok(public_key) }, + )), + }, + )), + ..Default::default() + }); + let (host_config, product) = runtime_config("myapp.dot"); + let (host, pairing) = + ProductRuntimeHost::new_pairing_for_tests(platform, host_config, product, test_spawner()); + install_pairing_session(&host, session); + let accounts = pairing.accounts(); + let authority_session = accounts.current_session().unwrap(); + + let registered = futures::executor::block_on(accounts.register_ring_vrf_key( + &authority_session, + &CallContext::with_request_id("register".to_string()), + &ProductContext::new("myapp.dot".to_string()).unwrap(), + v01::HostAccountRegisterRingVrfKeyRequest { + index: v01::DerivationIndex::Index(0), + ring: ring_location_fixture(), + }, + )); + + assert_eq!( + ( + registered, + futures::executor::block_on(accounts.ring_vrf_providers(&ring_location_fixture())), + ), + (Ok(public_key), Ok(vec![account_id("myapp.dot", 0)])), + ); +} + fn payment_services() -> Arc { payment_services_over(stub_platform()) } @@ -7905,14 +8226,14 @@ fn payment_host( services: &Arc, product_id: &str, with_session: bool, -) -> ProductRuntimeHost { +) -> ProductRuntimeHost { let (host_config, product) = runtime_config(product_id); - let pairing_host = PairingHost::new(services.clone(), host_config); + let pairing = PairingHost::new(services.clone(), host_config); let host = ProductRuntimeHost::from_services( services.clone(), crate::host_core::ConnectionAdapters::from_services(services), - pairing_host.clone(), - pairing_host, + pairing.accounts().clone(), + pairing.session().clone(), product, ); if with_session { @@ -7964,7 +8285,7 @@ impl crate::platform::TopUpPlatform for RecordingTopUpPlatform { } fn top_up( - host: &ProductRuntimeHost, + host: &ProductRuntimeHost, amount: u128, source: v01::PaymentTopUpSource, ) -> Result< @@ -8135,7 +8456,7 @@ fn payment_request() -> v01::HostPaymentRequest { } fn request_payment( - host: &ProductRuntimeHost, + host: &ProductRuntimeHost, ) -> Result< truapi::versioned::payment::HostPaymentResponse, CallError, @@ -8148,7 +8469,7 @@ fn request_payment( } fn follow_payment( - host: &ProductRuntimeHost, + host: &ProductRuntimeHost, ) -> Vec< Result< truapi::versioned::payment::HostPaymentStatusSubscribeItem, @@ -8272,7 +8593,7 @@ impl crate::platform::BalancePlatform for RecordingBalancePlatform { } fn first_balance( - host: &ProductRuntimeHost, + host: &ProductRuntimeHost, ) -> Option< Result< v01::HostPaymentBalanceSubscribeItem, diff --git a/rust/crates/truapi/src/runtime/tests/signing.rs b/rust/crates/truapi/src/runtime/tests/signing.rs index 9a2cb17a22..cb839ba25b 100644 --- a/rust/crates/truapi/src/runtime/tests/signing.rs +++ b/rust/crates/truapi/src/runtime/tests/signing.rs @@ -577,7 +577,12 @@ fn create_transaction_accepts_confirmation_then_returns_sso_response() { fn gated_create_transaction( request_id: &str, -) -> (Arc, ProductRuntimeHost, CallContext, futures::channel::oneshot::Sender<()>) { +) -> ( + Arc, + ProductRuntimeHost, + CallContext, + futures::channel::oneshot::Sender<()>, +) { let (release, gate) = futures::channel::oneshot::channel(); let platform = Arc::new(StubPlatform { create_transaction_confirmed: true, diff --git a/rust/crates/truapi/src/test_support.rs b/rust/crates/truapi/src/test_support.rs index b84672aacd..f444704f6d 100644 --- a/rust/crates/truapi/src/test_support.rs +++ b/rust/crates/truapi/src/test_support.rs @@ -94,6 +94,8 @@ pub type StorageWriteHook = Arc; /// can exercise its delegation paths without pulling in a real backend. #[derive(Default)] pub struct StubPlatform { + /// Delay completion after a durable core write takes effect. + pub core_storage_write_gate: Mutex>>, pub device_permission_decisions: Mutex>, pub device_permission_requests: Mutex>, @@ -115,6 +117,8 @@ pub struct StubPlatform { pub account_access_confirmed: bool, pub account_access_error: Option<&'static str>, pub account_access_reviews: Arc>>, + /// Holds the account-access prompt open until the sender fires. + pub account_access_confirmation_gate: Mutex>>, /// Permission answers retain their lifetime separately from action confirmations. pub permission_confirmation_decisions: Mutex>, @@ -136,6 +140,8 @@ pub struct StubPlatform { pub sign_vrf_confirmed: bool, pub sign_vrf_error: Option<&'static str>, pub sign_vrf_reviews: Arc>>, + /// Pause a VRF review until the test releases its confirmation. + pub sign_vrf_confirmation_gate: Mutex>>, /// Every `StatementStoreProductSign` review passed to `confirm_user_action`, in order. pub statement_store_product_sign_reviews: Arc>>, pub create_transaction_confirmed: bool, @@ -216,6 +222,10 @@ pub struct StubPlatform { /// Hold every core-storage read pending forever, standing in for a host /// callback that is never answered. pub core_storage_pending: bool, + /// Pause the first core-storage read until released. + pub core_storage_read_gate: Mutex>>, + /// Substitute storage for wallet lifecycle tests. + pub core_storage_override: Option>, pub chain_connect_pending: bool, /// Set when a `chain_connect_pending` connect future is dropped. pub pending_connect_dropped: Arc, @@ -1040,10 +1050,39 @@ impl PlatformProductOperations for StubPlatform { #[crate::platform::async_trait] impl PlatformCoreStorage for StubPlatform { + async fn core_storage_keys( + &self, + ) -> Result { + if let Some(storage) = &self.core_storage_override { + return storage.core_storage_keys().await; + } + if let Some(reason) = self.local_storage_error { + return Err(v01::GenericError { reason: reason.to_string() }); + } + Ok(crate::platform::CoreStorageKeys { + encoded_keys: self + .local_storage + .lock() + .unwrap() + .keys() + .filter_map(|key| { + key.strip_prefix("core:").and_then(|encoded| hex::decode(encoded).ok()) + }) + .collect(), + }) + } + async fn read_core_storage( &self, key: CoreStorageKey, ) -> Result>, v01::GenericError> { + if let Some(storage) = &self.core_storage_override { + return storage.read_core_storage(key).await; + } + let gate = self.core_storage_read_gate.lock().unwrap().take(); + if let Some(gate) = gate { + let _ = gate.await; + } if self.core_storage_pending { futures::future::pending::<()>().await; } @@ -1074,6 +1113,9 @@ impl PlatformCoreStorage for StubPlatform { key: CoreStorageKey, value: Vec, ) -> Result<(), v01::GenericError> { + if let Some(storage) = &self.core_storage_override { + return storage.write_core_storage(key, value).await; + } if let CoreStorageKey::AuthSession = key { self.session_writes .lock() @@ -1096,10 +1138,17 @@ impl PlatformCoreStorage for StubPlatform { .lock() .expect("local storage mutex poisoned") .insert(core_storage_test_key(key), value); + let gate = self.core_storage_write_gate.lock().unwrap().take(); + if let Some(gate) = gate { + let _ = gate.await; + } Ok(()) } async fn clear_core_storage(&self, key: CoreStorageKey) -> Result<(), v01::GenericError> { + if let Some(storage) = &self.core_storage_override { + return storage.clear_core_storage(key).await; + } if let CoreStorageKey::AuthSession = key { *self.session_clears.lock().expect("session clear counter mutex poisoned") += 1; return Ok(()); @@ -1813,6 +1862,10 @@ impl UserConfirmation for StubPlatform { .lock() .expect("VRF signing review list mutex poisoned") .push(review); + let gate = self.sign_vrf_confirmation_gate.lock().unwrap().take(); + if let Some(gate) = gate { + gate.await.expect("VRF confirmation gate was released"); + } (self.sign_vrf_error, self.sign_vrf_confirmed) }, UserConfirmationReview::StatementStoreProductSign(review) => { @@ -1848,6 +1901,14 @@ impl UserConfirmation for StubPlatform { .lock() .expect("account access review list mutex poisoned") .push(review); + let gate = self + .account_access_confirmation_gate + .lock() + .expect("account access gate mutex poisoned") + .take(); + if let Some(gate) = gate { + gate.await.expect("account access gate was released"); + } (self.account_access_error, self.account_access_confirmed) }, UserConfirmationReview::IdentityDisclosure(_) => { diff --git a/rust/crates/truapi/src/truapi_core.rs b/rust/crates/truapi/src/truapi_core.rs index 549b0af7ea..7bf492941f 100644 --- a/rust/crates/truapi/src/truapi_core.rs +++ b/rust/crates/truapi/src/truapi_core.rs @@ -16,7 +16,7 @@ use crate::{ generated::dispatcher, host_logic::session::SessionState, runtime::{ - HostSession, PairingHostRole, ProductAuthority, ProductRuntimeHost, RuntimeServices, + AccountHolder, HostAccounts, HostSession, PairingHost, ProductRuntimeHost, RuntimeServices, }, subscription::Spawner, transport::Transport, @@ -28,6 +28,19 @@ pub struct TrUApiCore { session_state: Arc, } +/// Build a dispatcher core around an already-created product runtime. +#[instrument(skip_all, fields(runtime.method = "core.from_product_runtime"))] +pub fn from_product_runtime( + runtime: Arc>, + spawner: Spawner, +) -> TrUApiCore { + let execution_kind = runtime.connection().execution_kind(); + let session_state = runtime.host_session().session_state(); + let mut dispatcher = Dispatcher::for_execution(spawner, execution_kind); + dispatcher::register(&mut dispatcher, runtime); + TrUApiCore { dispatcher, session_state } +} + impl TrUApiCore { /// Build a core around a direct `TrUApi` implementation. The session /// state holder is unused on this path (no platform pushes updates), @@ -64,41 +77,32 @@ impl TrUApiCore { host_config.asset_hub_chain_genesis_hash, spawner.clone(), ); - let pairing_host = PairingHostRole::new(services.clone(), host_config); - pairing_host.clone().start_session_store_sync(spawner); - Self::from_runtime_parts(services, pairing_host.clone(), pairing_host, product) + let pairing = PairingHost::new(services.clone(), host_config); + pairing.session().clone().start_session_store_sync(spawner); + Self::from_runtime_parts( + services, + pairing.accounts().clone(), + pairing.session().clone(), + product, + ) } - /// Build a product-facing core from shared services and authority. + /// Build a product-facing core over shared accounts and session lifecycle. #[instrument(skip_all, fields(runtime.method = "core.from_runtime_parts"))] - fn from_runtime_parts( + fn from_runtime_parts( services: Arc, - authority: Arc, + accounts: Arc>, host_session: Arc, product: ProductContext, ) -> Self { - let session_state = host_session.session_state(); let runtime = Arc::new(ProductRuntimeHost::from_services( services.clone(), crate::host_core::ConnectionAdapters::from_services(&services), - authority, + accounts, host_session, product, )); - Self::from_product_runtime(runtime, services.spawner.clone(), session_state) - } - - /// Build a dispatcher core around an already-created product runtime. - #[instrument(skip_all, fields(runtime.method = "core.from_product_runtime"))] - pub fn from_product_runtime( - runtime: Arc, - spawner: Spawner, - session_state: Arc, - ) -> Self { - let execution_kind = runtime.connection().execution_kind(); - let mut dispatcher = Dispatcher::for_execution(spawner, execution_kind); - dispatcher::register(&mut dispatcher, runtime); - Self { dispatcher, session_state } + from_product_runtime(runtime, services.spawner.clone()) } /// Handle to the shared session-state holder used by subscriptions and diff --git a/rust/crates/truapi/src/wasm.rs b/rust/crates/truapi/src/wasm.rs index ea0462cb69..d0475ffe9d 100644 --- a/rust/crates/truapi/src/wasm.rs +++ b/rust/crates/truapi/src/wasm.rs @@ -1146,7 +1146,15 @@ pub fn describe_core_storage_key_for_wasm(encoded: Vec) -> Result "secret", + crate::platform::CoreStorageProtection::Public => "public", + }), + )?; if let Some(product_id) = description.product_id { Reflect::set(&value, &JsValue::from_str("productId"), &JsValue::from_str(&product_id))?; }