diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 25e85f94..d1dc0d32 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -514,6 +514,7 @@ pub trait JsonRpcConnection: Send + Sync { #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum CoreStorageKey { /// Opaque SSO/auth session blob. + #[codec(index = 0)] AuthSession, /// Pairing device identity used during SSO flows. PairingDeviceIdentity, @@ -603,6 +604,10 @@ fn canonical_remote_request(request: &RemotePermissionRequest) -> RemotePermissi #[cfg(test)] mod tests { use super::*; + #[test] + fn auth_session_storage_key_has_stable_encoding() { + assert_eq!(CoreStorageKey::AuthSession.encode(), [0]); + } #[test] fn permission_authorization_keys_separate_product_and_request_variants() { diff --git a/rust/crates/truapi-server/src/chain_runtime.rs b/rust/crates/truapi-server/src/chain_runtime.rs index 28aa4e8b..716c5f3e 100644 --- a/rust/crates/truapi-server/src/chain_runtime.rs +++ b/rust/crates/truapi-server/src/chain_runtime.rs @@ -741,6 +741,9 @@ impl ChainRuntime { local_follow_id: String, with_runtime: bool, ) -> Result { + let local_follow_id = connection + .resolve_local_follow_id(method, &local_follow_id) + .await?; let remote_follow_id = connection .require_remote_follow(method, local_follow_id.clone()) .await?; @@ -907,6 +910,42 @@ impl ChainConnection { .and_then(|follow| follow.remote_subscription_id.clone()) } + /// Resolve product-SDK follow aliases. PAPI assigns its own `follow_N` + /// handle after the TrUAPI follow request, so that handle does not equal + /// the request id used to key the core follow. With one live follow on this + /// chain the association is unambiguous; multiple follows must use their + /// exact ids rather than guessing across subscriptions. + async fn resolve_local_follow_id( + &self, + method: &'static str, + requested_follow_id: &str, + ) -> Result { + // `remote_chain_head_follow` installs its state on a spawned task. + // A product can legally issue its first operation immediately after + // the follow request, before that task wins the executor. Give the + // registration a short bounded window, then resolve PAPI's `follow_N` + // alias when this chain has exactly one live follow. + for _ in 0..100 { + { + let follows = self.follows.lock().unwrap(); + if follows.contains_key(requested_follow_id) { + return Ok(requested_follow_id.to_string()); + } + if follows.len() == 1 { + return Ok(follows.keys().next().unwrap().clone()); + } + if follows.len() > 1 { + break; + } + } + futures_timer::Delay::new(Duration::from_millis(1)).await; + } + Err(RuntimeFailure::host_failure( + method, + format!("unknown follow subscription id {requested_follow_id:?}"), + )) + } + /// Record intent to follow `local_follow_id`, attaching `sender` for a /// follow subscriber. Idempotent: an existing follow keeps its /// `with_runtime` flag and remote id; only the sender is (re)attached. @@ -1776,7 +1815,7 @@ mod tests { } #[test] - fn header_request_reuses_existing_follow() { + fn header_request_maps_provider_alias_to_only_existing_follow() { let provider = Arc::new(ScriptedProvider::new(|request| { let id = extract_id(request).unwrap(); if request.contains("chainHead_v1_follow") { @@ -1812,7 +1851,7 @@ mod tests { let response = futures::executor::block_on(runtime.remote_chain_head_header( RemoteChainHeadHeaderRequest { genesis_hash: vec![0u8; 32], - follow_subscription_id: "local-follow".to_string(), + follow_subscription_id: "follow_0".to_string(), hash: vec![1u8; 32], }, )) @@ -1823,6 +1862,8 @@ mod tests { assert_eq!(sent.len(), 2); assert!(sent[0].contains("chainHead_v1_follow")); assert!(sent[1].contains("chainHead_v1_header")); + let header: Value = serde_json::from_str(&sent[1]).unwrap(); + assert_eq!(header["params"][0], "REMOTE-FOLLOW"); } #[test] diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index 2e684951..621b5358 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -152,6 +152,32 @@ impl PairingHostRuntime { self.pairing_host.cancel_login(); } + /// Activate a canonical session blob supplied by an external encrypted + /// session owner without writing the blob to core storage. + /// + /// Success means decoding, username resolution, replacement fencing, and + /// 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 + .activate_external_session(blob) + .await + .map_err(|reason| v01::GenericError { reason }) + } + + /// Await restoration of the persisted auth-session blob. + /// + /// Success means decoding, username resolution, stale-read fencing, and + /// connected-session installation have completed, so product frames may + /// 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 + .activate_stored_session() + .await + .map_err(|reason| v01::GenericError { reason }) + } + /// Notify the pairing runtime that the persisted auth-session blob may /// have changed and should be re-read. #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.notify_session_store_changed"))] diff --git a/rust/crates/truapi-server/src/host_logic/session.rs b/rust/crates/truapi-server/src/host_logic/session.rs index 69c4f733..be9f8ad1 100644 --- a/rust/crates/truapi-server/src/host_logic/session.rs +++ b/rust/crates/truapi-server/src/host_logic/session.rs @@ -88,6 +88,37 @@ pub struct SsoSessionInfo { /// Statement channel for signing-host initiated requests. pub peer_request_channel: [u8; 32], } +/// Session fields supplied by an already-paired external host runtime. +/// +/// This is an input shape, not a second persistence format. Encoding always +/// goes through [`encode_persisted_session`] so callers cannot duplicate or +/// depend on the private SCALE layout of [`SessionInfo`]. +pub struct ExternalPairedSession { + /// Signing host's sr25519 root public key. + pub root_public_key: [u8; 32], + /// Pairing host's established SSO channel and key material. + pub sso: SsoSessionInfo, + /// Wallet-provided source for deterministic product entropy. + pub root_entropy_source: [u8; 32], + /// Wallet identity account id used for People-chain username lookup. + pub identity_account_id: [u8; 32], +} + +/// Encode an already-paired external host session as the canonical opaque +/// pairing-runtime session blob. +/// +/// Usernames are intentionally absent: the pairing runtime resolves and +/// persists them through its normal identity lookup path. +pub fn encode_external_paired_session(info: ExternalPairedSession) -> Vec { + encode_persisted_session(&SessionInfo { + public_key: info.root_public_key, + sso: Some(info.sso), + root_entropy_source: Some(info.root_entropy_source), + identity_account_id: Some(info.identity_account_id), + lite_username: None, + full_username: None, + }) +} /// Encode the active-session fields the core currently understands into an /// opaque host-global session blob. @@ -316,6 +347,57 @@ mod tests { assert_eq!(decoded, session); } + #[test] + fn external_paired_session_uses_canonical_shape_and_exact_fields() { + let external = ExternalPairedSession { + root_public_key: [11; 32], + sso: SsoSessionInfo { + ss_secret: [1; 64], + ss_public_key: [2; 32], + enc_secret: [3; 32], + peer_enc_pubkey: [4; 32], + identity_account_id: [5; 32], + session_id_own: [6; 32], + session_id_peer: [7; 32], + request_channel: [8; 32], + response_channel: [9; 32], + peer_request_channel: [10; 32], + }, + root_entropy_source: [12; 32], + identity_account_id: [5; 32], + }; + + let blob = encode_external_paired_session(external); + let decoded = decode_persisted_session(&blob).expect("canonical decoder accepts blob"); + + assert_eq!( + decoded, + SessionInfo { + public_key: [11; 32], + sso: Some(SsoSessionInfo { + ss_secret: [1; 64], + ss_public_key: [2; 32], + enc_secret: [3; 32], + peer_enc_pubkey: [4; 32], + identity_account_id: [5; 32], + session_id_own: [6; 32], + session_id_peer: [7; 32], + request_channel: [8; 32], + response_channel: [9; 32], + peer_request_channel: [10; 32], + }), + root_entropy_source: Some([12; 32]), + identity_account_id: Some([5; 32]), + lite_username: None, + full_username: None, + } + ); + + let mut wrong_shape = blob; + wrong_shape.push(0); + assert!(decode_persisted_session(&wrong_shape).is_err()); + } + #[test] fn persisted_session_rejects_trailing_bytes() { let mut blob = encode_persisted_session(&info(0x42)); diff --git a/rust/crates/truapi-server/src/lib.rs b/rust/crates/truapi-server/src/lib.rs index 2848f7d6..0d830167 100644 --- a/rust/crates/truapi-server/src/lib.rs +++ b/rust/crates/truapi-server/src/lib.rs @@ -42,6 +42,9 @@ pub use host_core::{ FrameSink, HostAdmin, PairingHostRuntime, ProductRuntime, ProductRuntimeError, SigningHostRuntime, }; +pub use host_logic::session::{ + ExternalPairedSession, SsoSessionInfo, decode_persisted_session, encode_external_paired_session, +}; pub use runtime::ResponderExit; #[cfg(not(target_arch = "wasm32"))] pub use runtime::statement_allowance; diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 06e08fe2..b271849d 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -4554,6 +4554,121 @@ mod tests { .expect("local AutoSigning VRF verifies"); } + #[test] + fn external_session_activation_is_memory_only_and_rejects_trailing_bytes() { + let platform = Arc::new(StubPlatform::default()); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + 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)) + .expect("valid external session activates"); + + assert_eq!(host.test_session_state().current(), Some(session.clone())); + assert!( + platform + .session_writes + .lock() + .expect("session write list mutex poisoned") + .is_empty(), + "external activation must not copy the blob into core storage" + ); + + let invalid = futures::executor::block_on(pairing_host.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)) + .expect_err("trailing bytes are rejected"); + assert_eq!(error, "invalid session blob: trailing bytes"); + assert_eq!( + host.test_session_state().current(), + Some(session), + "invalid replacement preserves the active external session" + ); + } + + #[test] + fn external_session_activation_replaces_and_fences_the_previous_session() { + let (host, pairing_host) = ProductRuntimeHost::new_compat_with_pairing( + Arc::new(StubPlatform::default()), + test_spawner(), + ); + let first = sso_session_info(); + let mut replacement = first.clone(); + replacement.public_key = [0x44; 32]; + replacement.identity_account_id = Some([0x55; 32]); + 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), + )) + .expect("first external session activates"); + futures::executor::block_on(pairing_host.activate_external_session( + &crate::host_logic::session::encode_persisted_session(&replacement), + )) + .expect("replacement external session activates"); + + assert_eq!(host.test_session_state().current(), Some(replacement)); + } + + #[test] + fn stored_session_activation_resolves_after_connected_installation() { + let stored = sso_session_info(); + let platform = Arc::new(StubPlatform { + session_blob: Some(crate::host_logic::session::encode_persisted_session( + &stored, + )), + ..Default::default() + }); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + + futures::executor::block_on(pairing_host.activate_stored_session()) + .expect("valid stored session activates"); + + assert_eq!(host.test_session_state().current(), Some(stored.clone())); + assert_eq!( + *platform + .auth_states + .lock() + .expect("auth state list mutex poisoned"), + vec![AuthState::Connected(connected_session_ui_info(&stored))] + ); + } + + #[test] + fn stored_session_activation_rejects_invalid_blob_and_disconnects() { + let session_clears = Arc::new(Mutex::new(0)); + let platform = Arc::new(StubPlatform { + session_blob: Some(vec![0xff]), + session_clears: session_clears.clone(), + ..Default::default() + }); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform, test_spawner()); + install_pairing_session(&host, sso_session_info()); + + let error = futures::executor::block_on(pairing_host.activate_stored_session()) + .expect_err("invalid stored session is rejected"); + + assert!(error.starts_with("invalid stored auth session:")); + assert!(host.test_session_state().current().is_none()); + assert_eq!( + *session_clears + .lock() + .expect("session clear counter mutex poisoned"), + 1 + ); + } + #[test] fn session_store_sync_restores_valid_blob_from_tick() { let stored = sso_session_info(); diff --git a/rust/crates/truapi-server/src/runtime/pairing_host.rs b/rust/crates/truapi-server/src/runtime/pairing_host.rs index 518af880..449d0908 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host.rs @@ -7,6 +7,7 @@ mod sso_channel; use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, Weak}; use futures::channel::oneshot; @@ -126,6 +127,25 @@ impl Drop for LoginInFlightOwner<'_> { } } +#[derive(Debug)] +enum StoredSessionActivationError { + Missing, + Invalid(String), + Read(String), + Changed, +} + +impl std::fmt::Display for StoredSessionActivationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Missing => f.write_str("stored auth session is absent"), + Self::Invalid(reason) => write!(f, "invalid stored auth session: {reason}"), + Self::Read(reason) => write!(f, "failed to read stored auth session: {reason}"), + Self::Changed => f.write_str("stored auth session changed during activation"), + } + } +} + /// Remote account authority for a pairing host. pub(crate) struct PairingHost { /// Host platform backing all syscalls. @@ -149,6 +169,8 @@ pub(crate) struct PairingHost { bulletin_allowances: Mutex>, product_subtrees: Mutex>, auto_signing_keys: Mutex>, + session_store_activation: futures::lock::Mutex<()>, + external_session_active: AtomicBool, /// Self-reference captured by the spawned disconnect-monitor task. weak_self: Weak, /// Task spawner for background monitors. @@ -176,6 +198,8 @@ impl PairingHost { bulletin_allowances: Mutex::new(HashMap::new()), product_subtrees: Mutex::new(HashMap::new()), auto_signing_keys: Mutex::new(HashMap::new()), + session_store_activation: futures::lock::Mutex::new(()), + external_session_active: AtomicBool::new(false), weak_self: weak_self.clone(), spawner: services.spawner.clone(), }) @@ -189,6 +213,7 @@ impl PairingHost { /// Signal that the persisted auth session may have changed; the sync task /// re-reads it. pub(crate) fn notify_session_store_changed(&self) { + self.external_session_active.store(false, Ordering::Release); self.session_store_changes.notify(); } @@ -216,6 +241,112 @@ impl PairingHost { } } + /// Validate, resolve, and install an externally persisted canonical + /// session blob without copying it into core storage. + pub(crate) async fn activate_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 resolved = resolve_session_identity_with_chain( + &self.chain, + self.host_config.people_chain_genesis_hash, + session, + ) + .await; + self.set_connected_session(resolved); + self.external_session_active.store(true, Ordering::Release); + Ok(()) + } + + /// Read, validate, resolve, and install the persisted auth session before + /// returning. Product frames may use the connected session once this + /// future resolves. + pub(crate) async fn activate_stored_session(&self) -> Result<(), String> { + self.reconcile_stored_session(true, false) + .await + .map_err(|error| error.to_string()) + } + + 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.external_session_active.load(Ordering::Acquire) { + return Ok(()); + } + self.external_session_active.store(false, Ordering::Release); + 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.people_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); + Ok(()) + } + /// Spawn the background task that re-reads the persisted auth session on /// every change notification and reconciles the in-memory session. #[instrument(skip_all, fields(runtime.method = "session_store.sync"))] @@ -236,49 +367,17 @@ impl PairingHost { break; }; match pairing_host - .platform - .read_core_storage(CoreStorageKey::AuthSession) + .reconcile_stored_session(!cleared_after_read_error, true) .await { - Ok(Some(blob)) => { - cleared_after_read_error = false; - match crate::host_logic::session::decode_persisted_session(&blob) { - Ok(session) => { - let resolved = resolve_session_identity_with_chain( - &pairing_host.chain, - pairing_host.host_config.people_chain_genesis_hash, - session, - ) - .await; - if encode_persisted_session(&resolved) != blob { - let _ = pairing_host - .platform - .write_core_storage( - CoreStorageKey::AuthSession, - encode_persisted_session(&resolved), - ) - .await; - } - pairing_host.set_connected_session(resolved); - } - Err(_) => { - pairing_host.clear_disconnected_session(true).await; - } - } - } - Ok(None) => { + Ok(()) + | Err(StoredSessionActivationError::Missing) + | Err(StoredSessionActivationError::Invalid(_)) + | Err(StoredSessionActivationError::Changed) => { cleared_after_read_error = false; - pairing_host.clear_disconnected_session(false).await; } - Err(_) => { - pairing_host.clear_disconnected_session(false).await; - if !cleared_after_read_error { - cleared_after_read_error = true; - let _ = pairing_host - .platform - .clear_core_storage(CoreStorageKey::AuthSession) - .await; - } + Err(StoredSessionActivationError::Read(_)) => { + cleared_after_read_error = true; } } } @@ -465,6 +564,7 @@ impl PairingHost { #[instrument(skip_all, fields(runtime.method = "session_store.clear_disconnected"))] async fn clear_disconnected_session(&self, clear_auth_session: bool) { + self.external_session_active.store(false, Ordering::Release); let previous = self.session_state.current(); self.session_state.clear_session(); self.stop_session_channel(previous.as_ref()); diff --git a/rust/crates/truapi-server/src/wasm.rs b/rust/crates/truapi-server/src/wasm.rs index 74ea5998..dbb298bb 100644 --- a/rust/crates/truapi-server/src/wasm.rs +++ b/rust/crates/truapi-server/src/wasm.rs @@ -724,6 +724,25 @@ impl WasmPairingHostRuntime { self.runtime.cancel_pairing(); } + /// Activate an externally persisted canonical session without writing it + /// to core storage; resolves only after product frames may use it. + #[wasm_bindgen(js_name = activateExternalSession)] + pub async fn activate_external_session(&self, blob: Vec) -> Result<(), JsValue> { + self.runtime + .activate_external_session(&blob) + .await + .map_err(generic_error_to_js) + } + + /// Restore the persisted auth session and resolve only after it is active. + #[wasm_bindgen(js_name = activateStoredSession)] + pub async fn activate_stored_session(&self) -> Result<(), JsValue> { + self.runtime + .activate_stored_session() + .await + .map_err(generic_error_to_js) + } + /// Notify the runtime that the auth session slot may have changed. #[wasm_bindgen(js_name = notifySessionStoreChanged)] pub fn notify_session_store_changed(&self) {