Skip to content
Closed
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 rust/crates/truapi-platform/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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() {
Expand Down
45 changes: 43 additions & 2 deletions rust/crates/truapi-server/src/chain_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,9 @@ impl ChainRuntime {
local_follow_id: String,
with_runtime: bool,
) -> Result<String, RuntimeFailure> {
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?;
Expand Down Expand Up @@ -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<String, RuntimeFailure> {
// `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.
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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],
},
))
Expand All @@ -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]
Expand Down
26 changes: 26 additions & 0 deletions rust/crates/truapi-server/src/host_core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"))]
Expand Down
82 changes: 82 additions & 0 deletions rust/crates/truapi-server/src/host_logic/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8> {
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.
Expand Down Expand Up @@ -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));
Expand Down
3 changes: 3 additions & 0 deletions rust/crates/truapi-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
115 changes: 115 additions & 0 deletions rust/crates/truapi-server/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading