Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/account-holders.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/bulletin-capacity-check.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/host-grant-store.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/native-allowance-storage.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/product-consent.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/refused-allocation-rejected.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/relayed-vrf-auto-signing.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/remote-account-holder.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/sso-peer-services.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/sso-services.md
Original file line number Diff line number Diff line change
@@ -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`.
5 changes: 5 additions & 0 deletions .changeset/statement-sponsorship-renewal.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/wallet-account-holder-modules.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/wallet-account-holder.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 5 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion android/truapi-host/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ interface HostStorage {
* [HostRejection] on failure.
*/
interface HostCoreStorage {
@Throws(HostRejection::class)
suspend fun keys(): List<ByteArray> = throw HostRejection.Rejected("Core storage enumeration is unavailable")

@Throws(HostRejection::class)
suspend fun read(key: ByteArray): ByteArray?

Expand Down Expand Up @@ -552,6 +555,9 @@ private class HostCallbackAdapter(private val bridge: HostBridge) : HostCallback
}
}

override suspend fun coreStorageKeys(): List<ByteArray> =
withHostRejection { bridge.coreStorage.keys() }

override suspend fun coreStorageRead(key: ByteArray): ByteArray? =
withHostRejection { bridge.coreStorage.read(key) }

Expand Down Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion docs/rfcs/0002-permission-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 2 additions & 5 deletions docs/rfcs/sso-request-cancellation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import kotlinx.coroutines.flow.Flow
typealias InitialValueProducer<T> = suspend () -> T

interface Preferences {
fun keys(): Set<String> = error("Preference enumeration is unavailable")

fun contains(field: String): Boolean

fun putString(
Expand Down Expand Up @@ -119,4 +121,6 @@ interface Editor {
fun remove(field: String)

fun apply()

fun commit(): Boolean
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ class RealPreferences @Inject constructor(
) : Preferences {
private val sharedPreferences = context.getSharedPreferences(SHARED_PREFERENCES_FILE, Context.MODE_PRIVATE)

override fun keys(): Set<String> = sharedPreferences.all.keys

/*
SharedPreferencesImpl stores listeners in a WeakHashMap,
meaning listener is subject to GC if it is not kept anywhere else.
Expand Down Expand Up @@ -209,5 +211,7 @@ class RealPreferences @Inject constructor(
override fun apply() {
editor.apply()
}

override fun commit(): Boolean = editor.commit()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
Loading
Loading