diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index fb12921f..26e3faa0 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -147,7 +147,11 @@ export type CoreStorageKey = /** * Wallet-bound RFC-0010 AutoSigning capabilities for the active pairing. */ - | { tag: "AutoSigningKeys"; value?: undefined }; + | { tag: "AutoSigningKeys"; value?: undefined } + /** + * Statement-store allowance targets the signing host keeps renewed. + */ + | { tag: "StatementRenewalTargets"; value?: undefined }; /** * Review shown before a product creates a ring-VRF proof (RFC 0004). @@ -515,6 +519,7 @@ export const CoreStorageKey: S.Codec = S.lazy( productId: string; }>, AutoSigningKeys: S._void, + StatementRenewalTargets: S._void, }), ); diff --git a/rust/crates/truapi-host-cli/e2e/run.sh b/rust/crates/truapi-host-cli/e2e/run.sh new file mode 100755 index 00000000..f6dec3d4 --- /dev/null +++ b/rust/crates/truapi-host-cli/e2e/run.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# Headless end-to-end run: a pairing host drives a product script against a +# signing host, pairing over the real People-chain statement store. +# +# make headless # build once +# e2e/run.sh # generates pairing-host-cli.md (default) +# e2e/run.sh path/to/script.ts # runs a custom product script +# +# Env: +# PRODUCT_ID product id the pairing host serves (default truapi-playground.dot) +# HOST_CLI_SIGNER_MNEMONIC optional wallet mnemonic; when unset, signing-host auto-manages one +# TRUAPI_HOST_BASE_PATH optional root for generated accounts and host state +# TRUAPI_PAIRING_BASE_PATH optional pairing-host state root; defaults to a fresh temporary root +# TRUAPI_E2E_LOAD_ENV set to 0 to ignore the gitignored e2e/.env (default 1) +# FRAME frame-server address (default 127.0.0.1:9955) +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)" +BIN="$ROOT/target/debug/truapi-host" + +# Load HOST_CLI_SIGNER_MNEMONIC / TRUAPI_HOST_BASE_PATH (and any other vars) +# from a gitignored e2e/.env if present. +ENV_FILE="$(dirname "$0")/.env" +if [ "${TRUAPI_E2E_LOAD_ENV:-1}" = 1 ] && [ -f "$ENV_FILE" ]; then + set -a + . "$ENV_FILE" + set +a +fi + +SCRIPT="${1:-$ROOT/rust/crates/truapi-host-cli/js/scripts/battery.ts}" +PRODUCT_ID="${PRODUCT_ID:-truapi-playground.dot}" +FRAME="${FRAME:-127.0.0.1:9955}" + +PAIRING_BASE_PATH_OWNED=0 +if [ -n "${TRUAPI_PAIRING_BASE_PATH:-}" ]; then + PAIRING_BASE_PATH="$TRUAPI_PAIRING_BASE_PATH" +else + PAIRING_BASE_PATH="$(mktemp -d /tmp/truapi-e2e-pairing.XXXXXX)" + PAIRING_BASE_PATH_OWNED=1 +fi + +[ -x "$BIN" ] || { echo "missing $BIN — run: make headless" >&2; exit 2; } + +LOG="$(mktemp)" +SIGNER_PID="" +PAIR_PID="" +stop_pairing_host() { + [ -n "$PAIR_PID" ] || return 0 + pkill -TERM -P "$PAIR_PID" 2>/dev/null || true + kill -TERM "$PAIR_PID" 2>/dev/null || true + sleep 0.5 + pkill -KILL -P "$PAIR_PID" 2>/dev/null || true + kill -KILL "$PAIR_PID" 2>/dev/null || true +} +cleanup() { + [ -n "$SIGNER_PID" ] && kill "$SIGNER_PID" 2>/dev/null || true + stop_pairing_host + rm -f "$LOG" + if [ "$PAIRING_BASE_PATH_OWNED" -eq 1 ]; then + rm -rf -- "$PAIRING_BASE_PATH" + fi +} +trap cleanup EXIT + +# The pairing host runs the product script; the script's +# `truapi.account.requestLogin` makes the host emit a pairing deeplink, which we +# hand to a signing host. The pairing host exits with the script's status. +"$BIN" pairing-host --product-id "$PRODUCT_ID" --script "$SCRIPT" \ + --frame-listen "$FRAME" --base-path "$PAIRING_BASE_PATH" \ + --auto-accept > >(tee "$LOG") 2>&1 & +PAIR_PID=$! + +deeplink="" +for _ in $(seq 1 600); do + deeplink="$(grep -m1 -oE 'polkadotapp://pair\?handshake=[[:xdigit:]]+' "$LOG" || true)" + [ -n "$deeplink" ] && break + kill -0 "$PAIR_PID" 2>/dev/null || break + sleep 0.5 +done +[ -n "$deeplink" ] || { echo "pairing host did not emit a deeplink" >&2; exit 1; } + +# The signing host reads HOST_CLI_SIGNER_MNEMONIC from the env when set. +# Otherwise it auto-selects or creates an attested account under its base path. +"$BIN" signing-host --auto-accept exec "/deeplink $deeplink" & +SIGNER_PID=$! + +pid_running() { + local stat + stat="$(ps -p "$1" -o stat= 2>/dev/null || true)" + [ -n "$stat" ] && [ "${stat#Z}" = "$stat" ] +} + +while :; do + if ! pid_running "$PAIR_PID"; then + wait "$PAIR_PID" + exit $? + fi + if ! pid_running "$SIGNER_PID"; then + stop_pairing_host + exit 1 + fi + sleep 0.5 +done diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index cb5312fa..420f8037 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -37,7 +37,10 @@ use tracing_subscriber::util::SubscriberInitExt; use truapi_platform::{HostInfo, PlatformInfo}; use truapi_server::statement_allowance as alloc; use truapi_server::subscription::Spawner; -use truapi_server::{PairingHostConfig, PairingHostRuntime, SigningHostConfig, SigningHostRuntime}; +use truapi_server::{ + PairingHostConfig, PairingHostRuntime, SigningHostConfig, SigningHostRuntime, + StatementRenewalTarget, +}; use crate::accounts::{ResolveSignerConfig, ResolvedSigner}; use crate::network::{Network, NetworkConfig}; @@ -898,11 +901,9 @@ fn build_signing_runtime( network.bulletin_genesis, ) .context("invalid signing host config")?; - Ok(Arc::new(SigningHostRuntime::new( - platform, - config, - tokio_spawner(), - ))) + let runtime = Arc::new(SigningHostRuntime::new(platform, config, tokio_spawner())); + runtime.start_statement_allowance_renewal(); + Ok(runtime) } impl Drop for SigningHostSession { @@ -1082,7 +1083,10 @@ async fn prepare_pairing_response(session: &mut SigningHostSession, deeplink: &s ) }; match register_pairing_allowances(session.network.people_ws, &entropy, deeplink).await { - Ok(()) => return Ok(()), + Ok(device) => { + track_pairing_renewal_targets(session, device).await; + return Ok(()); + } Err(err) if auto_managed && is_statement_slot_exhaustion(&err) => { attempts += 1; if attempts > 8 { @@ -1132,6 +1136,99 @@ fn is_statement_slot_exhaustion(err: &anyhow::Error) -> bool { err.to_string().contains("no free StatementStore slot") } +/// Best-effort: record the pairing allowance accounts in the renewal ledger so +/// the background renewer keeps them allowed across periods. +async fn track_pairing_renewal_targets(session: &SigningHostSession, device: [u8; 32]) { + let result = session + .runtime + .track_statement_renewal_targets(vec![ + StatementRenewalTarget::WalletSso, + StatementRenewalTarget::Account { + account_id: device, + label: "device".to_string(), + }, + ]) + .await; + if let Err(err) = result { + tracing::warn!(reason = %err.reason, "failed to record pairing renewal targets"); + } +} + +/// Renew tracked statement-store allowances now, reporting each target. On +/// slot exhaustion an auto-managed signer account is marked exhausted so the +/// next pairing rotates to a fresh one. +async fn run_renew(session: &mut SigningHostSession) -> Result<()> { + use truapi_server::statement_allowance::renewal::TargetRenewalStatus; + + ensure_signer(session).await?; + let report = session + .runtime + .renew_statement_allowances() + .await + .map_err(|err| anyhow::anyhow!("allowance renewal failed: {}", err.reason))?; + + let (mut renewed, mut fresh, mut failed, mut skipped) = (0usize, 0usize, 0usize, 0usize); + for (target, status) in &report.outcomes { + match status { + TargetRenewalStatus::Registered { seq, block_hash } => { + renewed += 1; + terminal_ui::output_event(SystemEvent::AllowanceReady { + target: target.clone(), + sequence: *seq, + block_hash: Some(block_hash.clone()), + already_allocated: false, + }); + } + TargetRenewalStatus::AlreadyAllocated { seq } => { + fresh += 1; + terminal_ui::output_event(SystemEvent::AllowanceReady { + target: target.clone(), + sequence: *seq, + block_hash: None, + already_allocated: true, + }); + } + TargetRenewalStatus::Failed { reason } => { + failed += 1; + terminal_ui::output_event(SystemEvent::AllowanceRenewalFailed { + target: target.clone(), + reason: reason.clone(), + }); + } + TargetRenewalStatus::SkippedExhausted => skipped += 1, + } + } + terminal_ui::output_event(SystemEvent::AllowanceRenewalReport { + period: report.period, + renewed, + fresh, + failed, + skipped, + }); + + if report.slots_exhausted { + mark_current_account_exhausted(session)?; + } + Ok(()) +} + +fn mark_current_account_exhausted(session: &SigningHostSession) -> Result<()> { + let Some(signer) = session.signer.as_ref() else { + return Ok(()); + }; + if !signer.auto_managed { + return Ok(()); + } + let Some(name) = signer.account_name.clone() else { + return Ok(()); + }; + let period = accounts::current_statement_period()?; + let account_base_path = current_account_base_path(session)?; + accounts::mark_account_exhausted(&account_base_path, session.network.id, &name, period)?; + terminal_ui::output_event(SystemEvent::SigningHostAccountExhausted { name, period }); + Ok(()) +} + async fn respond_to_deeplink(session: &mut SigningHostSession, deeplink: String) -> Result<()> { prepare_pairing_response(session, &deeplink).await?; let exit = session @@ -1316,11 +1413,13 @@ async fn switch_session(session: &mut SigningHostSession, name: String) -> Resul /// statements during pairing: the signing host's RFC-0022 `uid.dot` identity /// account and the pairing host's per-pairing device key (from the deeplink). /// Proves the signing account's LitePeople ring membership once and reuses it. +/// Returns the device statement account id so the caller can track it for +/// renewal. async fn register_pairing_allowances( statement_store_url: &str, entropy: &[u8], deeplink: &str, -) -> Result<()> { +) -> Result<[u8; 32]> { use truapi_server::host_logic::product_account::{ derive_identity_keypair, derive_lite_person_ring_vrf_entropy, }; @@ -1418,7 +1517,7 @@ async fn register_pairing_allowances( } } } - Ok(()) + Ok(device) } async fn pairing_interactive_loop( @@ -1527,7 +1626,7 @@ async fn pairing_interactive_loop( Err(error) => ui.error(error.to_string()), } } - ShellCommand::Pair(_) | ShellCommand::Session(_) => { + ShellCommand::Pair(_) | ShellCommand::Session(_) | ShellCommand::Renew => { ui.error("command is only available on the signing host"); } } @@ -1777,6 +1876,7 @@ async fn execute_interactive_operation( ShellCommand::Session(SessionCommand::Switch(name)) => { switch_session(session, name).await?; } + ShellCommand::Renew => run_renew(session).await?, ShellCommand::Login => bail!("/login is only available on the pairing host"), ShellCommand::Logout => bail!("/logout is only available on the pairing host"), ShellCommand::Product(_) => bail!("command must be handled by the terminal UI"), @@ -1858,6 +1958,7 @@ async fn execute_non_interactive_command( ShellCommand::Session(SessionCommand::Switch(name)) => { switch_session(session, name).await?; } + ShellCommand::Renew => run_renew(session).await?, } Ok(()) } diff --git a/rust/crates/truapi-host-cli/src/signing_shell.rs b/rust/crates/truapi-host-cli/src/signing_shell.rs index 3650f58b..fc8656b2 100644 --- a/rust/crates/truapi-host-cli/src/signing_shell.rs +++ b/rust/crates/truapi-host-cli/src/signing_shell.rs @@ -52,6 +52,8 @@ pub enum ShellCommand { Product(ProductCommand), /// Inspect, list, or switch the active persistent session. Session(SessionCommand), + /// Renew tracked statement-store allowances for the current period. + Renew, /// Shut down the signing host. Quit, } @@ -116,6 +118,7 @@ pub fn parse_command(input: &str) -> Result { argument.to_string(), ))) } + "/renew" => no_argument(name, argument, ShellCommand::Renew), "/quit" => no_argument(name, argument, ShellCommand::Quit), _ => Err(format!( "unknown command `{name}`; use /help to list commands" @@ -146,6 +149,7 @@ const SIGNING_COMMANDS: &[(&str, &str)] = &[ ("/log", "set error, warn, info, debug, or trace"), ("/product", "show or switch the active product"), ("/session", "show or switch the active session"), + ("/renew", "renew statement-store allowances now"), ("/help", "show commands and keyboard shortcuts"), ("/clear", "clear the visible transcript"), ("/copy", "copy the transcript to the clipboard"), @@ -532,6 +536,7 @@ pub const HELP_TEXT: &str = "\ /session show the current session and path /session switch to or create a session /session --list list sessions for this network +/renew renew statement-store allowances now /help show this help /clear clear the visible transcript /copy copy the transcript to the clipboard @@ -593,6 +598,7 @@ mod tests { ))) ); assert_eq!(parse_command("/copy"), Ok(ShellCommand::Copy)); + assert_eq!(parse_command("/renew"), Ok(ShellCommand::Renew)); assert_eq!( parse_command("/session"), Ok(ShellCommand::Session(SessionCommand::Current)) @@ -618,6 +624,7 @@ mod tests { assert!(parse_command("/logout now").is_err()); assert!(parse_command("/pair https://example.com").is_err()); assert!(parse_command("/deeplink polkadotapp://pair?handshake=01").is_err()); + assert!(parse_command("/renew now").is_err()); assert!(parse_command("/log noisy").is_err()); assert!(parse_command("/product example.com").is_err()); assert!(parse_command("/session ../escape").is_err()); diff --git a/rust/crates/truapi-host-cli/src/terminal_ui.rs b/rust/crates/truapi-host-cli/src/terminal_ui.rs index 3d64a174..a3626ed4 100644 --- a/rust/crates/truapi-host-cli/src/terminal_ui.rs +++ b/rust/crates/truapi-host-cli/src/terminal_ui.rs @@ -136,6 +136,17 @@ pub enum SystemEvent { block_hash: Option, already_allocated: bool, }, + AllowanceRenewalFailed { + target: String, + reason: String, + }, + AllowanceRenewalReport { + period: u32, + renewed: usize, + fresh: usize, + failed: usize, + skipped: usize, + }, NotificationDelivered { id: u32, text: String, @@ -283,7 +294,7 @@ pub fn output_success(title: impl Into, detail: Option) { } fn write_human_stdout(text: &str) { - let styled = io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none(); + let styled = styled_output(io::stdout().is_terminal()); let mut stdout = io::stdout().lock(); for line in text.lines() { if !styled { @@ -344,7 +355,7 @@ impl Drop for LogWriter { for line in text.lines().filter(|line| !line.is_empty()) { if !send_to_active(UiEvent::Log(sanitize_terminal_text(line))) { let mut stderr = io::stderr(); - if stderr.is_terminal() && std::env::var_os("NO_COLOR").is_none() { + if styled_output(stderr.is_terminal()) { let _ = writeln!(stderr, "\x1b[2m{line}\x1b[0m"); } else { let _ = writeln!(stderr, "{line}"); @@ -383,7 +394,7 @@ fn sso_event_text(event: SsoEvent) -> String { } fn write_human_stderr(text: &str) { - let styled = io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none(); + let styled = styled_output(io::stderr().is_terminal()); let mut stderr = io::stderr().lock(); for line in text.lines() { if !styled { @@ -406,6 +417,17 @@ fn write_human_stderr(text: &str) { } } +fn styled_output(is_terminal: bool) -> bool { + let no_color = std::env::var_os("NO_COLOR").is_some(); + let force_color = + std::env::var_os("FORCE_COLOR").is_some_and(|value| !value.is_empty() && value != "0"); + should_style_output(is_terminal, no_color, force_color) +} + +fn should_style_output(is_terminal: bool, no_color: bool, force_color: bool) -> bool { + !no_color && (is_terminal || force_color) +} + #[derive(Default)] struct SsoSummaryVisitor { event: SsoEvent, @@ -1320,6 +1342,40 @@ impl App { }), ActivityState::Succeeded, ), + SystemEvent::AllowanceRenewalFailed { target, reason } => self.activity( + format!("allowance:{target}"), + format!("{} renewal failed", allowance_name(&target)), + Some(reason), + ActivityState::Failed, + ), + SystemEvent::AllowanceRenewalReport { + period, + renewed, + fresh, + failed, + skipped, + } => { + if renewed + fresh + failed + skipped == 0 { + self.notice( + NoticeTone::Info, + "No tracked allowance targets".to_string(), + Some(format!("Statement period {period}")), + ); + } else { + let tone = if failed + skipped > 0 { + NoticeTone::Warning + } else { + NoticeTone::Success + }; + self.notice( + tone, + "Allowance renewal finished".to_string(), + Some(format!( + "Period {period} · {renewed} renewed · {fresh} fresh · {failed} failed · {skipped} skipped" + )), + ); + } + } SystemEvent::NotificationDelivered { id, text, deeplink } => self.notice( NoticeTone::Info, format!("Notification #{id}"), @@ -2714,11 +2770,17 @@ mod tests { app.editor.completion_index(), MAX_VISIBLE_COMPLETIONS, ); + assert!(visible.len() <= MAX_VISIBLE_COMPLETIONS); assert!( - completions[visible] + completions .iter() .any(|completion| completion.value == "/copy") ); + assert!( + completions + .iter() + .any(|completion| completion.value == "/renew") + ); assert_eq!(completion_window(12, 0, 8), 0..8); assert_eq!(completion_window(12, 11, 8), 4..12); @@ -3195,6 +3257,14 @@ mod tests { assert_eq!(rgb_to_ansi256(255, 255, 255), 231); } + #[test] + fn forced_color_styles_redirected_output_but_never_overrides_no_color() { + assert!(should_style_output(true, false, false)); + assert!(should_style_output(false, false, true)); + assert!(!should_style_output(false, false, false)); + assert!(!should_style_output(true, true, true)); + } + fn render_app(app: &mut App, width: u16, height: u16) -> Result<(String, Position)> { let backend = TestBackend::new(width, height); let mut terminal = Terminal::new(backend)?; diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 0f074040..4d2bc267 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -605,7 +605,10 @@ pub enum CoreStorageKey { }, /// Wallet-bound RFC-0010 AutoSigning capabilities for the active pairing. AutoSigningKeys, + /// Statement-store allowance targets the signing host keeps renewed. + StatementRenewalTargets, } + /// Stable metadata describing one strictly decoded [`CoreStorageKey`]. /// /// `kind` is the Rust variant name and is part of the host embedding contract. @@ -651,6 +654,7 @@ pub fn describe_core_storage_key( CoreStorageKey::LastProcessedPairingStatement => ("LastProcessedPairingStatement", None), CoreStorageKey::AutoSigningKey { product_id } => ("AutoSigningKey", Some(product_id)), CoreStorageKey::AutoSigningKeys => ("AutoSigningKeys", None), + CoreStorageKey::StatementRenewalTargets => ("StatementRenewalTargets", None), }; Ok(CoreStorageKeyDescription { kind, product_id }) } @@ -720,6 +724,7 @@ 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]); @@ -766,6 +771,11 @@ mod tests { Some("product.dot"), ), (CoreStorageKey::AutoSigningKeys, "AutoSigningKeys", None), + ( + CoreStorageKey::StatementRenewalTargets, + "StatementRenewalTargets", + None, + ), ] { let description = describe_core_storage_key(&key.encode()).expect("valid key"); assert_eq!(description.kind, kind); @@ -854,8 +864,6 @@ mod tests { #[test] fn remote_permission_authorization_key_handles_separator_chars_in_domains() { - // Domain strings containing separator-looking text must not be able to - // forge a key that matches an unrelated permission. let injecting = RemotePermissionRequest { permission: RemotePermission::Remote { domains: vec!["a|b".into(), "c,d".into(), "remote:web-rtc".into()], @@ -872,8 +880,6 @@ mod tests { CoreStorageKey::remote_permission_authorization("product.dot", &benign_same_set); assert_ne!(injecting_key, benign_key); - // The injecting permission must also be distinct from the `WebRtc` - // variant it tries to impersonate via crafted strings. let webrtc = RemotePermissionRequest { permission: RemotePermission::WebRtc, }; @@ -882,8 +888,6 @@ mod tests { CoreStorageKey::remote_permission_authorization("product.dot", &webrtc) ); - // Re-ordering the same domains still collapses to a single key - // (canonicalization is order-independent). let injecting_reordered = RemotePermissionRequest { permission: RemotePermission::Remote { domains: vec!["remote:web-rtc".into(), "c,d".into(), "a|b".into()], diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index c967b51c..6332bc88 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -446,6 +446,54 @@ impl SigningHostRuntime { } } +#[cfg(not(target_arch = "wasm32"))] +impl SigningHostRuntime { + /// Record statement-store accounts the host must keep renewed across + /// allowance periods. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.track_statement_renewal_targets"))] + pub async fn track_statement_renewal_targets( + &self, + targets: Vec, + ) -> Result<(), v01::GenericError> { + self.signing_host + .track_statement_renewal_targets(targets) + .await + .map_err(|reason| v01::GenericError { reason }) + } + + /// Run one statement-store renewal pass now and return per-target + /// outcomes. This is the primary entry point; hosts whose process cannot + /// stay alive (mobile) call it from an OS scheduler instead of + /// [`Self::start_statement_allowance_renewal`]. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.renew_statement_allowances"))] + pub async fn renew_statement_allowances( + &self, + ) -> Result + { + self.signing_host + .renew_statement_allowances() + .await + .map_err(|reason| v01::GenericError { reason }) + } + + /// Start the periodic statement-store renewal loop (hourly, plus a tick + /// just after each period boundary). Idempotent; the loop stops when this + /// runtime is dropped. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.start_statement_allowance_renewal"))] + pub fn start_statement_allowance_renewal(&self) { + self.signing_host.start_statement_allowance_renewal(); + } + + /// Delay until the next renewal pass is due, for hosts that schedule + /// wake-ups through an OS scheduler instead of the in-process loop. + pub fn next_statement_renewal_delay(&self) -> std::time::Duration { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|now| crate::statement_allowance::renewal::next_tick_delay(now.as_secs())) + .unwrap_or(std::time::Duration::from_secs(3_600)) + } +} + /// Adapters scoped to one product connection: the platform serving its /// syscalls, the optional native Chat adapter, and the connection's Chat /// stream state. Non-native connections use [`Self::from_services`]. diff --git a/rust/crates/truapi-server/src/lib.rs b/rust/crates/truapi-server/src/lib.rs index 600100d8..d4dc3f8e 100644 --- a/rust/crates/truapi-server/src/lib.rs +++ b/rust/crates/truapi-server/src/lib.rs @@ -55,6 +55,8 @@ pub use host_logic::session::{ }; pub use runtime::ResponderExit; #[cfg(not(target_arch = "wasm32"))] +pub use runtime::StatementRenewalTarget; +#[cfg(not(target_arch = "wasm32"))] pub use runtime::statement_allowance; pub use truapi_platform::{ CoreStorageKeyDescription, CoreStorageKeyDescriptionError, HostRuntimeConfig, diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index e1704aa3..bf2e7cde 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -62,6 +62,8 @@ use pairing_host::PairingHost; pub(crate) use pairing_host::PairingHost as PairingHostRole; pub(crate) use services::RuntimeServices; pub use signing_host::ResponderExit; +#[cfg(not(target_arch = "wasm32"))] +pub use signing_host::StatementRenewalTarget; pub(crate) use signing_host::{ LocalActivation, SigningHost as SigningHostRole, respond_to_pairing, }; diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index a0fde9e0..1a34ae52 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -12,6 +12,8 @@ //! bandersnatch ring-VRF aliases and membership proofs, and product-scoped //! Statement Store and Bulletin allowance keys (native only). +#[cfg(not(target_arch = "wasm32"))] +mod allowance_renewal; mod local_activation; mod ring_vrf; mod sso_responder; @@ -22,6 +24,8 @@ use std::sync::{Arc, Mutex}; use parity_scale_codec::Encode; use subxt::utils::{AccountId32, MultiSignature}; +#[cfg(not(target_arch = "wasm32"))] +pub use allowance_renewal::StatementRenewalTarget; pub(crate) use local_activation::LocalActivation; pub use sso_responder::ResponderExit; pub(crate) use sso_responder::respond_to_pairing; @@ -100,6 +104,8 @@ pub(crate) struct SigningHost { /// lifecycle mutex also makes session replacement and snapshot creation /// atomic with respect to generation changes. local_grants: Mutex, + #[cfg(not(target_arch = "wasm32"))] + renewal: allowance_renewal::RenewalState, } impl SigningHost { @@ -115,6 +121,8 @@ impl SigningHost { ring_resolver, root_entropy: Mutex::new(None), local_grants: Mutex::new(LocalGrantState::default()), + #[cfg(not(target_arch = "wasm32"))] + renewal: allowance_renewal::RenewalState::default(), }) } @@ -137,6 +145,8 @@ impl SigningHost { ring_resolver, root_entropy: Mutex::new(None), local_grants: Mutex::new(LocalGrantState::default()), + #[cfg(not(target_arch = "wasm32"))] + renewal: allowance_renewal::RenewalState::default(), }) } @@ -385,6 +395,29 @@ impl SigningHost { } } +#[cfg(not(target_arch = "wasm32"))] +impl SigningHost { + /// Record statement-store accounts to keep renewed across periods. + pub(crate) async fn track_statement_renewal_targets( + &self, + targets: Vec, + ) -> Result<(), String> { + allowance_renewal::track(self, targets).await + } + + /// Run one statement-store renewal pass over the tracked targets. + pub(crate) async fn renew_statement_allowances( + &self, + ) -> Result { + allowance_renewal::renew_now(&self.services, self).await + } + + /// Start the periodic statement-store renewal loop. Idempotent. + pub(crate) fn start_statement_allowance_renewal(self: &Arc) { + allowance_renewal::start_renewal_loop(&self.services, self); + } +} + #[async_trait::async_trait] impl ProductAuthority for SigningHost { fn current_session(&self) -> Option { diff --git a/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs b/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs new file mode 100644 index 00000000..ffa9cdc6 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs @@ -0,0 +1,737 @@ +//! 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`). + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use futures::lock::Mutex; +use parity_scale_codec::{Decode, Encode}; +use tracing::{debug, info, warn}; +use truapi_platform::{CoreStorage, CoreStorageKey}; + +use super::SigningHost; +use super::sso_responder::current_unix_secs; +use crate::host_logic::product_account::{ + derive_lite_person_ring_vrf_entropy, derive_root_keypair_from_entropy, derive_sr25519_hard_path, +}; +use crate::runtime::RuntimeServices; +use crate::runtime::statement_allowance::renewal::{ + RenewalChainContext, ResolvedRenewalTarget, StatementRenewalReport, next_tick_delay, + renew_targets, +}; +use crate::runtime::statement_allowance::{ + self, fetch_chain_state, fetch_metadata, find_including_ring, +}; + +/// Fallback tick delay when the system clock is unusable. +const CLOCK_FAILURE_TICK_DELAY: Duration = Duration::from_secs(3_600); + +/// A statement-store account the signing host promised to keep renewed. +/// +/// Entropy-derived variants are recipes, not raw account ids, so the ledger +/// survives root-entropy rotation (the CLI rotates auto-managed accounts on +/// slot exhaustion). +#[derive(Clone, Debug, PartialEq, Eq, Encode, Decode)] +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, + }, +} + +/// One persisted ledger entry. +/// +/// A derivation recipe resolves under whatever root entropy is active, so it +/// carries no owner and keeps working across a rotation. A raw account id does +/// not re-derive, so it records the root public key that promised it and is +/// ignored under any other identity: without that, a later account would spend +/// its own slot-table capacity keeping a previous account's peer allowed. +#[derive(Clone, Debug, PartialEq, Eq, Encode, Decode)] +struct LedgerEntry { + target: StatementRenewalTarget, + owner: Option<[u8; 32]>, +} + +impl LedgerEntry { + /// 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) + } +} + +/// 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(super) 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<()>, + loop_started: AtomicBool, +} + +impl RenewalState { + pub(super) fn registration_lock(&self) -> &Mutex<()> { + &self.registration_lock + } + + fn ledger_lock(&self) -> &Mutex<()> { + &self.ledger_lock + } +} + +/// Read the renewal ledger; an absent or undecodable slot is an empty ledger. +/// +/// A ledger this build cannot decode is treated as empty rather than failing +/// the pass: the entries are recipes and raw account ids that +/// [`track_targets`] rebuilds on the next allocation or pairing, so refusing to +/// renew anything is strictly worse than starting over. +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 = LedgerEntry::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: &[LedgerEntry], +) -> 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) +} + +/// Resolve a ledger entry into a concrete account for this session's entropy. +fn resolve_target( + entropy: &[u8], + target: &StatementRenewalTarget, +) -> Result { + 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: format!("product:{product_id}"), + account_id: pair.public.to_bytes(), + }) + } + StatementRenewalTarget::WalletSso => { + let pair = derive_sr25519_hard_path(entropy, &["wallet", "sso"]) + .map_err(|err| err.to_string())?; + Ok(ResolvedRenewalTarget { + label: "wallet-sso".to_string(), + account_id: pair.public.to_bytes(), + }) + } + StatementRenewalTarget::Account { account_id, label } => Ok(ResolvedRenewalTarget { + label: label.clone(), + account_id: *account_id, + }), + } +} + +/// Record `targets` in the ledger under the active identity. +pub(super) async fn track( + signing_host: &SigningHost, + targets: Vec, +) -> Result<(), String> { + 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 +} + +/// Resolve every ledger target under `entropy`, skipping any that cannot be +/// resolved. +/// +/// A target is skipped rather than failing the pass: one unusable entry must +/// not stop every other target from being renewed. +fn resolve_targets( + entropy: &[u8], + targets: &[StatementRenewalTarget], +) -> Vec { + targets + .iter() + .filter_map(|target| match resolve_target(entropy, target) { + Ok(resolved) => Some(resolved), + Err(reason) => { + warn!(?target, %reason, "skipping an unresolvable renewal target"); + None + } + }) + .collect() +} + +/// One renewal pass: resolve the ledger against the active session and renew +/// every target for the current period. +pub(super) 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 owner = owner_key(&entropy)?; + let storage = signing_host.platform.as_ref(); + let entries = read_entries(storage).await?; + // Entries promised by a different identity are dropped rather than renewed: + // they would consume this identity's slots for an account it never promised. + let (owned, foreign): (Vec<_>, Vec<_>) = entries + .into_iter() + .partition(|entry| entry.is_owned_by(owner)); + if !foreign.is_empty() { + warn!( + dropped = foreign.len(), + "pruning renewal targets promised by a previous identity" + ); + let _guard = signing_host.renewal.ledger_lock().lock().await; + write_entries(storage, &owned).await?; + } + let targets: Vec = + owned.into_iter().map(|entry| entry.target).collect(); + let resolved = resolve_targets(&entropy, &targets); + if resolved.is_empty() { + return Ok(StatementRenewalReport { + period, + outcomes: Vec::new(), + slots_exhausted: false, + }); + } + + let bandersnatch = derive_lite_person_ring_vrf_entropy(&entropy); + 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 current = statement_allowance::ring::read_current_ring_index(&rpc) + .await + .map_err(|err| err.to_string())?; + let ring = find_including_ring(&rpc, &metadata, bandersnatch, current) + .await + .map_err(|err| err.to_string())? + .ok_or_else(|| { + "signing account is not a LitePeople ring member; cannot renew statement-store allowances" + .to_string() + })?; + let context = RenewalChainContext { + rpc: &rpc, + metadata: &metadata, + chain_state: &chain_state, + ring: &ring, + }; + Ok(renew_targets( + &context, + bandersnatch, + period, + &resolved, + signing_host.renewal.registration_lock(), + ) + .await) +} + +/// 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(super) fn start_renewal_loop(services: &Arc, signing_host: &Arc) { + if signing_host + .renewal + .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; + } + match renew_now(services, signing_host).await { + Ok(report) if report.slots_exhausted => { + warn!( + period = report.period, + "statement-store renewal hit slot exhaustion" + ); + } + Ok(report) => { + info!( + period = report.period, + targets = report.outcomes.len(), + "statement-store renewal pass complete" + ); + } + Err(reason) => warn!(%reason, "statement-store renewal tick failed"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::sync::Mutex; + + use truapi::latest::GenericError; + + #[derive(Default)] + struct MemStorage { + inner: Mutex, Vec>>, + } + + #[truapi_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.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); + + #[truapi_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")]); + }); + } + + #[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, &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, target)) + .collect::, _>>() + .is_err() + ); + + let resolved = resolve_targets(&entropy, &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 ledger_rejects_trailing_bytes() { + let mut blob = vec![LedgerEntry::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, &product("a.dot")).unwrap(); + assert_eq!( + resolved, + ResolvedRenewalTarget { + label: "product:a.dot".to_string(), + account_id: expected, + } + ); + } + + #[test] + fn wallet_sso_target_resolves_to_wallet_sso_derivation() { + let entropy = [7u8; 32]; + let expected = derive_sr25519_hard_path(&entropy, &["wallet", "sso"]) + .unwrap() + .public + .to_bytes(); + + let resolved = resolve_target(&entropy, &StatementRenewalTarget::WalletSso).unwrap(); + assert_eq!( + resolved, + ResolvedRenewalTarget { + label: "wallet-sso".to_string(), + account_id: expected, + } + ); + } + + #[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 owner_key_follows_the_root_entropy() { + assert_ne!( + owner_key(&[7u8; 32]).unwrap(), + owner_key(&[8u8; 32]).unwrap() + ); + } +} diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs index 0e9fa8b9..868442e5 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs @@ -855,6 +855,7 @@ pub(super) async fn allocate_statement_store_allowance( product_id: &str, policy: OnExistingAllowancePolicy, ) -> Result, AllowanceAllocationError> { + use super::allowance_renewal::{self, StatementRenewalTarget}; use crate::runtime::statement_allowance::{ self, RegistrationParams, fetch_chain_state, fetch_metadata, find_including_ring, register_statement_account, @@ -880,19 +881,22 @@ pub(super) async fn allocate_statement_store_allowance( resource: "statement-store", })?; let period = statement_allowance::slot::current_period(current_unix_secs()?); - let outcome = register_statement_account( - &rpc, - &metadata, - &chain_state, - bandersnatch, - RegistrationParams { - target: &target, - period, - ring: &ring, - reuse_existing: matches!(policy, OnExistingAllowancePolicy::Ignore), - }, - ) - .await?; + let outcome = { + let _guard = signing_host.renewal.registration_lock().lock().await; + register_statement_account( + &rpc, + &metadata, + &chain_state, + bandersnatch, + RegistrationParams { + target: &target, + period, + ring: &ring, + reuse_existing: matches!(policy, OnExistingAllowancePolicy::Ignore), + }, + ) + .await? + }; match outcome { statement_allowance::RegistrationOutcome::Registered { block_hash, @@ -915,6 +919,16 @@ pub(super) async fn allocate_statement_store_allowance( ); } } + 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"); + } Ok(allowance.secret.to_bytes().to_vec()) } @@ -1035,7 +1049,7 @@ pub(super) async fn allocate_bulletin_allowance( } #[cfg(not(target_arch = "wasm32"))] -fn current_unix_secs() -> Result { +pub(super) fn current_unix_secs() -> Result { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|duration| duration.as_secs()) diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance.rs b/rust/crates/truapi-server/src/runtime/statement_allowance.rs index ba9f71e8..491dac24 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance.rs @@ -9,6 +9,7 @@ pub mod extension; pub mod extrinsic; pub mod proof; +pub mod renewal; pub mod ring; pub mod rpc; pub mod slot; diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/dynamic.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/dynamic.rs new file mode 100644 index 00000000..42e1df77 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/dynamic.rs @@ -0,0 +1,164 @@ +//! Minimal metadata-driven SCALE walker. +//! +//! Just enough to read one field out of a storage struct without a full dynamic +//! codec: `skip` advances a cursor past one value of a given type, +//! `read_field_variant_name` walks a composite to a named field and returns its +//! enum variant name (used for `CollectionInfo.ring_size` -> `R2e9`/`R2e10`/`R2e14`), +//! and `read_field_u32` reads simple numeric fields such as `RingRoot.revision`. + +use parity_scale_codec::{Compact, Decode}; +use scale_info::{PortableRegistry, TypeDef, TypeDefPrimitive}; + +/// Advance `input` past exactly one SCALE-encoded value of `type_id`. +pub fn skip(registry: &PortableRegistry, type_id: u32, input: &mut &[u8]) -> Result<(), String> { + let ty = registry + .resolve(type_id) + .ok_or_else(|| format!("unknown type id {type_id}"))?; + match &ty.type_def { + TypeDef::Composite(c) => { + for field in &c.fields { + skip(registry, field.ty.id, input)?; + } + } + TypeDef::Tuple(t) => { + for field in &t.fields { + skip(registry, field.id, input)?; + } + } + TypeDef::Array(a) => { + for _ in 0..a.len { + skip(registry, a.type_param.id, input)?; + } + } + TypeDef::Sequence(s) => { + let len = read_compact(input)?; + for _ in 0..len { + skip(registry, s.type_param.id, input)?; + } + } + TypeDef::Variant(v) => { + let index = read_u8(input)?; + let variant = v + .variants + .iter() + .find(|var| var.index == index) + .ok_or_else(|| format!("unknown variant index {index}"))?; + for field in &variant.fields { + skip(registry, field.ty.id, input)?; + } + } + TypeDef::Compact(_) => { + read_compact(input)?; + } + TypeDef::BitSequence(_) => { + let bits = read_compact(input)?; + advance(input, bits.div_ceil(8))?; + } + TypeDef::Primitive(p) => { + let len = match p { + TypeDefPrimitive::Bool | TypeDefPrimitive::U8 | TypeDefPrimitive::I8 => 1, + TypeDefPrimitive::U16 | TypeDefPrimitive::I16 => 2, + TypeDefPrimitive::Char | TypeDefPrimitive::U32 | TypeDefPrimitive::I32 => 4, + TypeDefPrimitive::U64 | TypeDefPrimitive::I64 => 8, + TypeDefPrimitive::U128 | TypeDefPrimitive::I128 => 16, + TypeDefPrimitive::U256 | TypeDefPrimitive::I256 => 32, + // Length-prefixed UTF-8: compact byte length then the bytes. + TypeDefPrimitive::Str => read_compact(input)?, + }; + advance(input, len)?; + } + } + Ok(()) +} + +/// Walk composite `struct_type_id` to `field_name` and return the enum variant +/// name selected there (the field must be a fieldless/simple enum). +pub fn read_field_variant_name( + registry: &PortableRegistry, + struct_type_id: u32, + field_name: &str, + bytes: &[u8], +) -> Result { + let ty = registry + .resolve(struct_type_id) + .ok_or_else(|| format!("unknown type id {struct_type_id}"))?; + let TypeDef::Composite(composite) = &ty.type_def else { + return Err(format!("type {struct_type_id} is not a composite")); + }; + + let mut input = bytes; + for field in &composite.fields { + if field.name.as_deref() == Some(field_name) { + let field_ty = registry + .resolve(field.ty.id) + .ok_or_else(|| format!("unknown field type id {}", field.ty.id))?; + let TypeDef::Variant(variant) = &field_ty.type_def else { + return Err(format!("field `{field_name}` is not an enum")); + }; + let index = read_u8(&mut input)?; + return variant + .variants + .iter() + .find(|var| var.index == index) + .map(|var| var.name.clone()) + .ok_or_else(|| format!("unknown variant index {index} for `{field_name}`")); + } + skip(registry, field.ty.id, &mut input)?; + } + Err(format!("field `{field_name}` not found")) +} + +/// Walk composite `struct_type_id` to `field_name` and decode the field as a +/// SCALE `u32`. +pub fn read_field_u32( + registry: &PortableRegistry, + struct_type_id: u32, + field_name: &str, + bytes: &[u8], +) -> Result { + let ty = registry + .resolve(struct_type_id) + .ok_or_else(|| format!("unknown type id {struct_type_id}"))?; + let TypeDef::Composite(composite) = &ty.type_def else { + return Err(format!("type {struct_type_id} is not a composite")); + }; + + let mut input = bytes; + for field in &composite.fields { + if field.name.as_deref() == Some(field_name) { + let field_ty = registry + .resolve(field.ty.id) + .ok_or_else(|| format!("unknown field type id {}", field.ty.id))?; + if !matches!(field_ty.type_def, TypeDef::Primitive(TypeDefPrimitive::U32)) { + return Err(format!("field `{field_name}` is not a u32")); + } + return u32::decode(&mut input).map_err(|err| format!("field `{field_name}`: {err}")); + } + skip(registry, field.ty.id, &mut input)?; + } + Err(format!("field `{field_name}` not found")) +} + +/// Decode a SCALE compact-encoded length, advancing `input`. +fn read_compact(input: &mut &[u8]) -> Result { + let Compact(value) = Compact::::decode(input).map_err(|err| format!("compact: {err}"))?; + usize::try_from(value).map_err(|_| "compact length overflow".to_string()) +} + +/// Read one byte, advancing `input`. +fn read_u8(input: &mut &[u8]) -> Result { + let (&first, rest) = input + .split_first() + .ok_or_else(|| "unexpected end".to_string())?; + *input = rest; + Ok(first) +} + +/// Advance `input` by `n` bytes. +fn advance(input: &mut &[u8], n: usize) -> Result<(), String> { + if input.len() < n { + return Err(format!("need {n} bytes, have {}", input.len())); + } + *input = &input[n..]; + Ok(()) +} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/renewal.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/renewal.rs new file mode 100644 index 00000000..3108651e --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/renewal.rs @@ -0,0 +1,374 @@ +//! Proactive renewal of statement-store allowances across period boundaries. +//! +//! Allowances are claimed per UTC-day period and die at the boundary, so a +//! long-lived host must re-register every account it promised to keep allowed +//! (RFC-0010 assigns renewal to the Account Holder). This module is the +//! chain-pure pass: given already-resolved targets, register each for the +//! requested period. Scheduling and target persistence live in +//! `signing_host::allowance_renewal`. + +use std::time::Duration; + +use futures::lock::Mutex; +use tracing::{debug, info, warn}; + +use super::extension::{ChainState, Metadata}; +use super::ring::RingParams; +use super::rpc::RpcClient; +use super::slot::{STATEMENT_STORE_PERIOD_SECONDS, SlotError}; +use super::{ + RegistrationOutcome, RegistrationParams, StatementAllowanceError, register_statement_account, +}; + +/// Cap between renewal ticks, mirroring the on-chain grace period after a +/// period boundary. +const MAX_TICK_INTERVAL: Duration = Duration::from_secs(3_600); +/// Margin after a period boundary before the boundary tick fires, so the +/// 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. +/// +/// The distinction drives the rest of the pass, so it is read off the typed +/// error rather than its rendered text. +#[derive(Debug, Clone, PartialEq, Eq)] +struct RenewalFailure { + reason: String, + slots_exhausted: bool, +} + +impl From for RenewalFailure { + fn from(err: StatementAllowanceError) -> Self { + Self { + slots_exhausted: matches!( + err, + StatementAllowanceError::Slot(SlotError::NoFreeStatementStoreSlot { .. }) + ), + reason: err.to_string(), + } + } +} + +/// One resolved renewal target: account id plus a label for reports. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedRenewalTarget { + /// Human-readable name used in logs and reports. + pub label: String, + /// Account to keep allowed. + pub account_id: [u8; 32], +} + +/// Outcome of renewing one target. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TargetRenewalStatus { + /// The extrinsic reached a block; the target holds `seq` this period. + Registered { + /// Claimed slot sequence. + seq: u32, + /// Block hash the extrinsic landed in. + block_hash: String, + }, + /// The target already held a slot this period; nothing submitted. + AlreadyAllocated { + /// Existing slot sequence. + seq: u32, + }, + /// Registration failed; the target is retried on the next tick. + Failed { + /// Failure detail. + reason: String, + }, + /// Not attempted: the host ran out of slots earlier in the pass. + SkippedExhausted, +} + +/// Summary of one renewal pass. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StatementRenewalReport { + /// Period the pass registered for. + pub period: u32, + /// Per-target `(label, status)` in ledger order. + pub outcomes: Vec<(String, TargetRenewalStatus)>, + /// Whether the pass hit slot exhaustion for this period. + pub slots_exhausted: bool, +} + +/// Chain context shared by every registration in one renewal pass. +pub struct RenewalChainContext<'a> { + /// People-chain RPC connection. + pub rpc: &'a RpcClient, + /// Decoded runtime metadata. + pub metadata: &'a Metadata, + /// Signed-extension chain state. + pub chain_state: &'a ChainState, + /// Ring the host's membership proof is built against. + pub ring: &'a RingParams, +} + +/// 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). +/// +/// `registration_lock` is held per target, not for the whole pass, so an +/// on-demand allocation sharing the lock waits at most one registration. +pub async fn renew_targets( + context: &RenewalChainContext<'_>, + entropy: [u8; 32], + period: u32, + targets: &[ResolvedRenewalTarget], + registration_lock: &Mutex<()>, +) -> StatementRenewalReport { + let mut results = Vec::with_capacity(targets.len()); + for target in targets { + let result = { + let _guard = registration_lock.lock().await; + register_statement_account( + context.rpc, + context.metadata, + context.chain_state, + entropy, + RegistrationParams { + target: &target.account_id, + period, + ring: context.ring, + reuse_existing: true, + }, + ) + .await + .map_err(RenewalFailure::from) + }; + log_target_result(period, &target.label, &result); + let exhausted = matches!(&result, Err(failure) if failure.slots_exhausted); + results.push(result); + if exhausted { + break; + } + } + fold_outcomes(period, targets, results) +} + +/// Delay until the next renewal tick: hourly, but always shortly after each +/// period boundary so expired allowances are refreshed within the grace window. +pub fn next_tick_delay(now_seconds: u64) -> Duration { + let next_boundary = + (now_seconds / STATEMENT_STORE_PERIOD_SECONDS + 1) * STATEMENT_STORE_PERIOD_SECONDS; + let until_boundary = next_boundary - now_seconds; + let until_after_boundary = Duration::from_secs(until_boundary) + PERIOD_BOUNDARY_MARGIN; + // Once the boundary is within an hour, wait for it plus the margin rather + // than capping: a capped tick can land inside the margin, where the local + // clock reports the new period but the chain has not rotated into it, and + // the pass would scan slots for a period the chain does not agree on. + if MAX_TICK_INTERVAL.as_secs() >= until_boundary { + until_after_boundary + } else { + MAX_TICK_INTERVAL + } +} + +fn log_target_result( + period: u32, + label: &str, + result: &Result, +) { + match result { + Ok(RegistrationOutcome::Registered { + block_hash, seq, .. + }) => info!(period, label, seq, %block_hash, "renewed statement-store allowance"), + Ok(RegistrationOutcome::AlreadyAllocated { seq }) => { + debug!( + period, + label, seq, "statement-store allowance already fresh" + ); + } + Err(failure) => { + warn!(period, label, reason = %failure.reason, "statement-store renewal failed"); + } + } +} + +/// 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( + period: u32, + targets: &[ResolvedRenewalTarget], + results: Vec>, +) -> StatementRenewalReport { + let mut slots_exhausted = false; + let mut results = results.into_iter(); + let outcomes = targets + .iter() + .map(|target| { + let status = match results.next() { + Some(Ok(RegistrationOutcome::Registered { + block_hash, seq, .. + })) => TargetRenewalStatus::Registered { seq, block_hash }, + Some(Ok(RegistrationOutcome::AlreadyAllocated { seq })) => { + TargetRenewalStatus::AlreadyAllocated { seq } + } + Some(Err(failure)) => { + slots_exhausted |= failure.slots_exhausted; + TargetRenewalStatus::Failed { + reason: failure.reason, + } + } + None => TargetRenewalStatus::SkippedExhausted, + }; + (target.label.clone(), status) + }) + .collect(); + StatementRenewalReport { + period, + outcomes, + slots_exhausted, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The failure a slot-exhausted registration produces. + fn exhausted_failure() -> RenewalFailure { + StatementAllowanceError::Slot(SlotError::NoFreeStatementStoreSlot { period: 7, max: 8 }) + .into() + } + + #[test] + fn only_a_missing_slot_counts_as_exhaustion() { + assert!(exhausted_failure().slots_exhausted); + + let other: RenewalFailure = StatementAllowanceError::BulletinAuthorizationTimeout.into(); + assert!(!other.slots_exhausted); + } + + fn target(label: &str) -> ResolvedRenewalTarget { + ResolvedRenewalTarget { + label: label.to_string(), + account_id: [0u8; 32], + } + } + + #[test] + fn tick_delay_caps_at_one_hour_mid_day() { + let mid_day = 86_400 * 20_000 + 43_200; + assert_eq!(next_tick_delay(mid_day), Duration::from_secs(3_600)); + } + + #[test] + fn tick_delay_lands_after_the_period_boundary() { + let just_before_boundary = 86_400 * 20_001 - 10; + assert_eq!( + next_tick_delay(just_before_boundary), + Duration::from_secs(10 + 120) + ); + } + + #[test] + fn tick_delay_never_lands_inside_the_post_boundary_margin() { + let boundary = 86_400 * 20_001; + // Every start in the last two hours before a boundary. + for offset in 1..=7_200 { + let now = boundary - offset; + let landing = now + next_tick_delay(now).as_secs(); + assert!( + landing < boundary || landing >= boundary + PERIOD_BOUNDARY_MARGIN.as_secs(), + "tick from {now} lands at {landing}, inside the margin after {boundary}" + ); + } + } + + #[test] + fn tick_delay_waits_for_the_boundary_once_it_is_within_an_hour() { + let boundary = 86_400 * 20_001; + // Exactly one hour out, the cap used to land the tick on the boundary. + assert_eq!( + next_tick_delay(boundary - 3_600), + Duration::from_secs(3_600) + PERIOD_BOUNDARY_MARGIN + ); + } + + #[test] + fn tick_delay_at_boundary_reverts_to_hourly() { + assert_eq!(next_tick_delay(86_400 * 20_001), Duration::from_secs(3_600)); + } + + #[test] + fn mid_list_failure_does_not_stop_the_pass() { + let targets = [target("a"), target("b"), target("c")]; + let report = fold_outcomes( + 7, + &targets, + vec![ + Ok(RegistrationOutcome::AlreadyAllocated { seq: 1 }), + Err(RenewalFailure { + reason: "rpc timeout".to_string(), + slots_exhausted: false, + }), + Ok(RegistrationOutcome::Registered { + block_hash: "0xabc".to_string(), + seq: 2, + ring_index: 0, + }), + ], + ); + assert_eq!( + report, + StatementRenewalReport { + period: 7, + outcomes: vec![ + ( + "a".to_string(), + TargetRenewalStatus::AlreadyAllocated { seq: 1 } + ), + ( + "b".to_string(), + TargetRenewalStatus::Failed { + reason: "rpc timeout".to_string() + } + ), + ( + "c".to_string(), + TargetRenewalStatus::Registered { + seq: 2, + block_hash: "0xabc".to_string() + } + ), + ], + slots_exhausted: false, + } + ); + } + + #[test] + fn exhaustion_skips_remaining_targets() { + let targets = [target("a"), target("b"), target("c")]; + let report = fold_outcomes( + 7, + &targets, + vec![ + Ok(RegistrationOutcome::AlreadyAllocated { seq: 0 }), + Err(exhausted_failure()), + ], + ); + assert_eq!( + report, + StatementRenewalReport { + period: 7, + outcomes: vec![ + ( + "a".to_string(), + TargetRenewalStatus::AlreadyAllocated { seq: 0 } + ), + ( + "b".to_string(), + TargetRenewalStatus::Failed { + reason: exhausted_failure().reason + } + ), + ("c".to_string(), TargetRenewalStatus::SkippedExhausted), + ], + slots_exhausted: true, + } + ); + } +} diff --git a/rust/crates/truapi/src/api/signing.rs b/rust/crates/truapi/src/api/signing.rs index 6e305a9b..6575dd5e 100644 --- a/rust/crates/truapi/src/api/signing.rs +++ b/rust/crates/truapi/src/api/signing.rs @@ -99,11 +99,15 @@ pub trait Signing: Send + Sync { /// Sign raw bytes with a non-product account. /// /// ```ts - /// const identityResult = await ss58AddressForDotNsUsername(); - /// assert(identityResult.isOk(), "DotNS identity lookup failed:", identityResult); + /// const accountsResult = await truapi.account.getLegacyAccounts(); + /// assert(accountsResult.isOk(), "getLegacyAccounts failed:", accountsResult); + /// const identityAccount = + /// accountsResult.value.accounts.find((account) => account.name === "Identity") ?? + /// accountsResult.value.accounts[0]; + /// assert(identityAccount, "no legacy accounts available"); /// /// const result = await truapi.signing.signRawWithLegacyAccount({ - /// signer: identityResult.value, + /// signer: identityAccount.publicKey, /// payload: { /// tag: "Bytes", /// value: { bytes: "0x48656c6c6f" }, diff --git a/rust/crates/truapi/src/api/statement_store.rs b/rust/crates/truapi/src/api/statement_store.rs index 2b5a7d78..92756b09 100644 --- a/rust/crates/truapi/src/api/statement_store.rs +++ b/rust/crates/truapi/src/api/statement_store.rs @@ -38,18 +38,23 @@ pub trait StatementStore: Send + Sync { /// assert(submitted.isOk(), "failed to submit statement:", submitted); /// console.log("statement submitted"); /// - /// const page = await firstValueFrom( - /// from( - /// truapi.statementStore.subscribe({ - /// request: { tag: "MatchAll", value: [topic] }, - /// }), - /// ), - /// ); - /// assert( - /// page.statements.some((item) => item.topics.includes(topic)), - /// "subscription did not return the submitted statement:", - /// page, - /// ); + /// const waitForStatement = async () => { + /// for (let attempt = 0; attempt < 15; attempt++) { + /// const page = await firstValueFrom( + /// from( + /// truapi.statementStore.subscribe({ + /// request: { tag: "MatchAll", value: [topic] }, + /// }), + /// ), + /// ); + /// if (page.statements.some((item) => item.topics.includes(topic))) { + /// return page; + /// } + /// await new Promise((resolve) => setTimeout(resolve, 1_000)); + /// } + /// throw new Error("submitted statement was not visible after 15 seconds"); + /// }; + /// const page = await waitForStatement(); /// console.log("subscribe received", page); /// ``` #[wire(start_id = 56)]