diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md index e25ef00dd..5a66e9d81 100644 --- a/rust/crates/truapi-host-cli/README.md +++ b/rust/crates/truapi-host-cli/README.md @@ -212,7 +212,8 @@ Commands always start with `/`: | `/pair ` | Decode a pairing QR from a PNG, JPEG, or WebP image (signing host). | | `/pair ` | Validate and answer a `polkadotapp://pair?...` deeplink (signing host). | | `/devices` or `/devices --list` | List every paired device saved for the active signing-host session. | -| `/devices --remove ` | Remove one paired device by its 32-byte statement account ID. | +| `/devices --remove ` | Disconnect and remove one paired device by its 32-byte statement account ID. | +| `/devices --remove --force` | Attempt to disconnect one paired device, then remove its local pairing even if notification fails. | | `/approval` | Show whether signing-host confirmations are manual or automatic. | | `/approval manual` | Prompt for every future signing-host confirmation. | | `/approval automatic` | Approve every future signing-host confirmation automatically. | @@ -384,8 +385,15 @@ statement lifetime. order with available host and platform metadata. Interactive `/devices --remove ` asks for confirmation. The same command through `exec` is an explicit one-shot removal and runs without another -prompt. Removing one device stops only its responder and allowance renewal. The -other saved pairings and the signing identity are unchanged. +prompt. Removal first submits `Disconnected` to the selected remote host. Only +after the statement is accepted does it stop that responder, remove the saved +pairing, and stop its allowance renewal. A submission failure preserves all +local pairing state. The other saved pairings and the signing identity are +unchanged. For recovery when notification cannot be submitted, append +`--force`. The command still attempts notification first, but warns and +continues with local cleanup if that attempt fails. The remote host may continue +to show stale connected state, but it cannot reach a responder on this signing +host. `/session --clear ` permanently deletes that session's local signer keys, scripts, core/product storage, and permissions. `/session --clear-all` @@ -420,6 +428,7 @@ truapi-host signing-host exec '/pair polkadotapp://pair?handshake=...' truapi-host signing-host --session alice.01 exec '/devices' truapi-host signing-host --session alice.01 exec '/devices --list' truapi-host signing-host --session alice.01 exec '/devices --remove 0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' +truapi-host signing-host --session alice.01 exec '/devices --remove 0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef --force' ``` `exec` does not enable raw mode or emit terminal controls. Command results go @@ -559,6 +568,12 @@ Six scripts ship under `js/scripts/`: --auto-accept ``` + `e2e/device-removal-disconnect.sh` automates the two-host removal case. It + pairs an isolated signing host with an isolated pairing host, removes the + device interactively, and verifies the remote `Disconnected` status, cleared + pairing auth storage, and empty signing-host device list. Run `make codegen` + once in a fresh checkout, build `truapi-host-cli`, then run the script. + - `whoami.ts` — calls `getUserId` and prints `WHOAMI `; this remains available as an explicit `/script ` example. - `signing-smoke.ts` — a focused product-account signing check. diff --git a/rust/crates/truapi-host-cli/SPEC.md b/rust/crates/truapi-host-cli/SPEC.md index 356b34320..08b2de788 100644 --- a/rust/crates/truapi-host-cli/SPEC.md +++ b/rust/crates/truapi-host-cli/SPEC.md @@ -422,6 +422,7 @@ For example: truapi-host signing-host --session alice.01 exec '/devices' truapi-host signing-host --session alice.01 exec '/devices --list' truapi-host signing-host --session alice.01 exec '/devices --remove 0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' +truapi-host signing-host --session alice.01 exec '/devices --remove 0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef --force' ``` `exec '/script'` needs a TTY because it opens an editor. In non-TTY execution, @@ -431,7 +432,9 @@ use `exec '/script '` instead. `/copy` and `/approval` are unavailable. `exec '/devices'` and `exec '/devices --list'` inspect the selected session's saved pairings without starting their responders. `exec '/devices --remove '` is an explicit removal and does not ask for another -confirmation. +confirmation. It submits `Disconnected` directly and removes local state only +after the statement is accepted. Appending `--force` still attempts that +submission, but warns and continues with local cleanup if it fails. ### 6.5 `--serve` @@ -536,7 +539,8 @@ Commands start with `/`. There are no `q`, `quit`, `exit`, or non-slash aliases. | `/pair ` | no | yes | Validate and answer a `polkadotapp://pair?...` link. | | `/devices` | no | yes | List paired devices saved for the active managed session. | | `/devices --list` | no | yes | List paired devices saved for the active managed session. | -| `/devices --remove ` | no | yes | Remove one paired device by its 32-byte statement account ID. | +| `/devices --remove ` | no | yes | Disconnect and remove one paired device by its 32-byte statement account ID. | +| `/devices --remove --force` | no | yes | Attempt to disconnect one paired device, then remove its local pairing even if notification fails. | | `/approval` | no | yes | Print the current manual or automatic approval mode. TUI only. | | `/approval manual` | no | yes | Prompt for every future confirmation. TUI only. | | `/approval automatic` | no | yes | Approve every future confirmation automatically. TUI only. | @@ -562,8 +566,14 @@ quoted or escaped `/pair` argument is treated as an image path. `/devices` and `/devices --list` are equivalent. They sort peers by statement account ID and print each ID with any available host and platform metadata. `/devices --remove` accepts exactly one 32-byte hexadecimal statement account ID -with an optional `0x` prefix. Interactive removal uses the `[y/N]` approval and -describes that only the selected peer is affected. `exec` removal runs directly. +with an optional `0x` prefix and an optional trailing `--force`. Interactive +removal uses the `[y/N]` approval and describes that only the selected peer is +affected. `exec` removal runs directly. Both modes submit one `Disconnected` +message before local cleanup. If submission fails, ordinary removal preserves +the saved pairing, responder, and allowance-renewal target. Forced removal emits +an unfiltered warning and continues with local cleanup, so the remote host may +continue to show stale connected state, but it cannot reach a responder on this +signing host. Unknown commands, missing required arguments, invalid log levels, invalid products, invalid session names, and arguments passed to no-argument commands diff --git a/rust/crates/truapi-host-cli/e2e/device-removal-disconnect.sh b/rust/crates/truapi-host-cli/e2e/device-removal-disconnect.sh new file mode 100755 index 000000000..92d7719de --- /dev/null +++ b/rust/crates/truapi-host-cli/e2e/device-removal-disconnect.sh @@ -0,0 +1,230 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)" +BIN="$ROOT/target/debug/truapi-host" +SCRIPT="$ROOT/rust/crates/truapi-host-cli/js/scripts/device-removal-disconnect.ts" +PRODUCT_ID="${PRODUCT_ID:-truapi-playground.dot}" +NETWORK="${TRUAPI_E2E_NETWORK:-paseo-next-v2}" +TIMEOUT_SECONDS="${TRUAPI_E2E_TIMEOUT_SECONDS:-300}" + +[ -x "$BIN" ] || { echo "missing $BIN, run: cargo build -p truapi-host-cli" >&2; exit 2; } +[ -f "$ROOT/js/packages/truapi/src/generated/index.ts" ] || { + echo "missing generated TypeScript client, run: make codegen" >&2 + exit 2 +} +command -v bun >/dev/null || { echo "bun is required" >&2; exit 2; } +command -v tmux >/dev/null || { echo "tmux is required" >&2; exit 2; } + +PAIRING_BASE="$(mktemp -d /tmp/truapi-device-remove-pairing.XXXXXX)" +LOG_DIR="$(mktemp -d /tmp/truapi-device-remove-logs.XXXXXX)" +SIGNER_BASE="${TRUAPI_HOST_BASE_PATH:-$(mktemp -d /tmp/truapi-device-remove-signer.XXXXXX)}" +SIGNER_BASE_OWNED=1 +if [ -n "${TRUAPI_HOST_BASE_PATH:-}" ]; then + SIGNER_BASE_OWNED=0 +fi +PAIRING_LOG="$LOG_DIR/pairing.log" +SIGNING_LOG="$LOG_DIR/signing.log" +TMUX_SESSION="truapi-device-remove-$$" +PAIRING_PID="" +CORE_STORAGE="" + +process_running() { + local process_id="$1" + local state + state="$(ps -p "$process_id" -o stat= 2>/dev/null || true)" + [ -n "$state" ] && [ "${state#Z}" = "$state" ] +} + +stop_process() { + local process_id="$1" + [ -n "$process_id" ] || return 0 + pkill -TERM -P "$process_id" 2>/dev/null || true + kill -TERM "$process_id" 2>/dev/null || true + wait "$process_id" 2>/dev/null || true +} + +capture_signing_host() { + tmux capture-pane -p -J -S - -t "$TMUX_SESSION" >"$SIGNING_LOG" +} + +stop_signing_host() { + tmux kill-session -t "$TMUX_SESSION" 2>/dev/null || true +} + +cleanup() { + local status=$? + if [ "$status" -ne 0 ] && tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then + capture_signing_host || true + fi + stop_signing_host + stop_process "$PAIRING_PID" + if [ "$status" -eq 0 ]; then + rm -rf -- "$PAIRING_BASE" "$LOG_DIR" + if [ "$SIGNER_BASE_OWNED" -eq 1 ]; then + rm -rf -- "$SIGNER_BASE" + fi + else + echo "E2E logs preserved at $LOG_DIR" >&2 + echo "Pairing state preserved at $PAIRING_BASE" >&2 + if [ "$SIGNER_BASE_OWNED" -eq 1 ]; then + echo "Signing state preserved at $SIGNER_BASE" >&2 + fi + fi + return "$status" +} +trap cleanup EXIT + +wait_for_pairing_pattern() { + local pattern="$1" + local deadline=$((SECONDS + TIMEOUT_SECONDS)) + while [ "$SECONDS" -lt "$deadline" ]; do + if grep -qE "$pattern" "$PAIRING_LOG"; then + return 0 + fi + if ! process_running "$PAIRING_PID"; then + echo "pairing host exited before matching $pattern" >&2 + return 1 + fi + sleep 1 + done + echo "timed out waiting for $pattern in $PAIRING_LOG" >&2 + return 1 +} + +wait_for_new_pairing_pattern() { + local pattern="$1" + local previous_count="$2" + local deadline=$((SECONDS + TIMEOUT_SECONDS)) + while [ "$SECONDS" -lt "$deadline" ]; do + local current_count + current_count="$(grep -cE "$pattern" "$PAIRING_LOG" || true)" + if [ "$current_count" -gt "$previous_count" ]; then + return 0 + fi + if ! process_running "$PAIRING_PID"; then + echo "pairing host exited before another match for $pattern" >&2 + return 1 + fi + sleep 1 + done + echo "timed out waiting for another match for $pattern in $PAIRING_LOG" >&2 + return 1 +} + +wait_for_signing_pattern() { + local pattern="$1" + local deadline=$((SECONDS + TIMEOUT_SECONDS)) + while [ "$SECONDS" -lt "$deadline" ]; do + if ! tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then + echo "signing host exited before matching $pattern" >&2 + return 1 + fi + capture_signing_host + if grep -qE "$pattern" "$SIGNING_LOG"; then + return 0 + fi + sleep 1 + done + echo "timed out waiting for $pattern in signing-host pane" >&2 + return 1 +} + +send_signing_command() { + tmux send-keys -t "$TMUX_SESSION" -l "$1" + tmux send-keys -t "$TMUX_SESSION" Enter +} + +wait_for_persisted_auth_session() { + local current_user_path="$PAIRING_BASE/$NETWORK/pairing-host/current-user" + local deadline=$((SECONDS + TIMEOUT_SECONDS)) + while [ "$SECONDS" -lt "$deadline" ]; do + if [ -s "$current_user_path" ]; then + local current_user + current_user="$(tr -d '\r\n' <"$current_user_path")" + CORE_STORAGE="$PAIRING_BASE/$NETWORK/${current_user}_pairing_host/core-storage.json" + if [ -f "$CORE_STORAGE" ] && grep -qE '"00"[[:space:]]*:' "$CORE_STORAGE"; then + return 0 + fi + fi + if ! process_running "$PAIRING_PID"; then + echo "pairing host exited before persisting its auth session" >&2 + return 1 + fi + sleep 1 + done + echo "timed out waiting for the persisted pairing-host auth session" >&2 + return 1 +} + +wait_for_auth_session_clear() { + local deadline=$((SECONDS + TIMEOUT_SECONDS)) + while [ "$SECONDS" -lt "$deadline" ]; do + if [ -f "$CORE_STORAGE" ] && ! grep -qE '"00"[[:space:]]*:' "$CORE_STORAGE"; then + return 0 + fi + if ! process_running "$PAIRING_PID"; then + echo "pairing host exited before clearing its persisted auth session" >&2 + return 1 + fi + sleep 1 + done + echo "timed out waiting for AuthSession key 00 to leave $CORE_STORAGE" >&2 + return 1 +} + +TRUAPI_HOST_NO_UPDATE=1 NO_COLOR=1 "$BIN" pairing-host \ + --product-id "$PRODUCT_ID" \ + --network "$NETWORK" \ + --script "$SCRIPT" \ + --base-path "$PAIRING_BASE" \ + --auto-accept >"$PAIRING_LOG" 2>&1 & +PAIRING_PID=$! + +wait_for_pairing_pattern 'polkadotapp://pair\?handshake=[[:xdigit:]]+' +deeplink="$(grep -m1 -oE 'polkadotapp://pair\?handshake=[[:xdigit:]]+' "$PAIRING_LOG")" + +printf -v signing_command '%q ' \ + env -u HOST_CLI_SIGNER_MNEMONIC TRUAPI_HOST_NO_UPDATE=1 NO_COLOR=1 \ + "$BIN" signing-host \ + --network "$NETWORK" \ + --base-path "$SIGNER_BASE" \ + --auto-accept +tmux new-session -d -s "$TMUX_SESSION" -x 240 -y 100 -c "$ROOT" "$signing_command" +tmux set-option -t "$TMUX_SESSION" history-limit 10000 >/dev/null + +wait_for_signing_pattern 'TrUAPI signing host' +send_signing_command "/pair $deeplink" +wait_for_pairing_pattern '^DEVICE_REMOVE_CONNECTED$' +wait_for_persisted_auth_session + +send_signing_command '/devices' +wait_for_signing_pattern 'Paired devices for session' +capture_signing_host +mapfile -t device_ids < <( + sed -nE 's/^.*(0x[[:xdigit:]]{64}) .*/\1/p' "$SIGNING_LOG" | sort -u +) +if [ "${#device_ids[@]}" -ne 1 ]; then + echo "expected exactly one listed paired device, found ${#device_ids[@]}" >&2 + exit 1 +fi + +send_signing_command "/devices --remove ${device_ids[0]}" +wait_for_signing_pattern 'Remove paired device' +disconnect_marker_before_removal="$(grep -cE '^DEVICE_REMOVE_DISCONNECT_OK$' "$PAIRING_LOG" || true)" +pairing_ended_before_removal="$(grep -c 'Pairing ended' "$PAIRING_LOG" || true)" +if [ "$disconnect_marker_before_removal" -ne 0 ]; then + echo "pairing host disconnected before removal was confirmed" >&2 + exit 1 +fi +tmux send-keys -t "$TMUX_SESSION" y +wait_for_signing_pattern 'Paired device removed' + +wait_for_new_pairing_pattern '^DEVICE_REMOVE_DISCONNECT_OK$' "$disconnect_marker_before_removal" +wait_for_new_pairing_pattern 'Pairing ended' "$pairing_ended_before_removal" +wait_for_auth_session_clear + +send_signing_command '/devices' +wait_for_signing_pattern 'No paired devices for session' + +echo "DEVICE_REMOVE_E2E_OK" diff --git a/rust/crates/truapi-host-cli/js/scripts/device-removal-disconnect.ts b/rust/crates/truapi-host-cli/js/scripts/device-removal-disconnect.ts new file mode 100644 index 000000000..c78ba5a75 --- /dev/null +++ b/rust/crates/truapi-host-cli/js/scripts/device-removal-disconnect.ts @@ -0,0 +1,42 @@ +/// +export {}; + +const login = await truapi.account.requestLogin({ reason: undefined }); +if ( + !login.isOk() || + (login.value !== "Success" && login.value !== "AlreadyConnected") +) { + throw new Error( + `requestLogin failed: ${login.isOk() ? login.value : JSON.stringify(login.error)}`, + ); +} + +const statuses: string[] = []; +await new Promise((resolve, reject) => { + let subscription: { unsubscribe(): void } | undefined; + subscription = truapi.account.connectionStatusSubscribe().subscribe({ + next(status) { + statuses.push(status); + if (status === "Connected") { + console.log("DEVICE_REMOVE_CONNECTED"); + } + if (status === "Disconnected") { + subscription?.unsubscribe(); + resolve(); + } + }, + error(error) { + reject(error); + }, + }); +}); + +const expectedStatuses = ["Connected", "Disconnected"]; +assert( + JSON.stringify(statuses) === JSON.stringify(expectedStatuses), + "unexpected account connection statuses", + statuses, +); +console.log("DEVICE_REMOVE_DISCONNECT_OK"); + +await new Promise(() => {}); diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index 323672608..1fef79b76 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -2415,14 +2415,13 @@ fn mark_current_account_exhausted(session: &SigningHostSession) -> Result<()> { async fn respond_to_deeplink(session: &mut SigningHostSession, deeplink: String) -> Result<()> { let host = establish_paired_host(session, &deeplink).await?; - let statement_account_id = host.statement_account_id(); let exit = session .runtime .resume_pairing(paired_sso_peer(&host)) .await .map_err(|err| anyhow::anyhow!("pairing failed: {}", err.reason))?; if exit == ResponderExit::PeerDisconnected && session.profile.is_some() { - remove_paired_host(session, &statement_account_id).await?; + remove_paired_host_locally(session, host).await?; } terminal_ui::output_event(SystemEvent::SigningHostExit { outcome: format!("{exit:?}"), @@ -2439,15 +2438,15 @@ async fn start_deeplink_responder( Ok(()) } -async fn remove_paired_host( - session: &mut SigningHostSession, +fn find_paired_host( + session: &SigningHostSession, statement_account_id: &[u8; 32], ) -> Result { let profile = session .profile .as_ref() .context("paired-device management is unavailable when launched with --mnemonic")?; - let paired_host = session + session .catalog .paired_hosts(profile)? .into_iter() @@ -2458,14 +2457,69 @@ async fn remove_paired_host( hex::encode(statement_account_id), profile.name ) - })?; + }) +} + +struct PairedHostRemoval { + paired_host: PairedHost, + notification_failure: Option, +} + +/// Submit the disconnect first so ordinary removal never deletes an unnotified +/// pairing. A later cleanup failure remains retryable even though the peer may +/// already consider the session closed. +async fn disconnect_and_remove_paired_host( + session: &mut SigningHostSession, + statement_account_id: &[u8; 32], + force: bool, +) -> Result { + let paired_host = find_paired_host(session, statement_account_id)?; + let notification_failure = match session + .runtime + .disconnect_paired_host(paired_sso_peer(&paired_host)) + .await + { + Ok(()) => None, + Err(error) if force => Some(error.reason), + Err(error) => { + bail!( + "failed to notify paired device before removal: {}", + error.reason + ) + } + }; + let paired_host = remove_paired_host_locally(session, paired_host).await?; + Ok(PairedHostRemoval { + paired_host, + notification_failure, + }) +} + +fn forced_removal_warning(reason: &str) -> (&'static str, String) { + ( + "Paired device removed without notification", + format!( + "Notification failed: {reason}. Forced local removal completed. The remote host may still show stale connected state, but it cannot reach a responder on this signing host." + ), + ) +} + +async fn remove_paired_host_locally( + session: &mut SigningHostSession, + paired_host: PairedHost, +) -> Result { + let statement_account_id = paired_host.statement_account_id(); + let profile = session + .profile + .as_ref() + .context("paired-device management is unavailable when launched with --mnemonic")?; session .catalog - .remove_paired_host(profile, statement_account_id)?; - session.responders.remove(statement_account_id); + .remove_paired_host(profile, &statement_account_id)?; + session.responders.remove(&statement_account_id); if let Err(error) = session .runtime - .untrack_statement_renewal_account(statement_account_id) + .untrack_statement_renewal_account(&statement_account_id) .await { tracing::warn!( @@ -2658,27 +2712,22 @@ fn format_paired_device_list(session_name: &str, mut paired_hosts: Vec Result<(String, String)> { let profile = session .profile .as_ref() .context("paired-device management is unavailable when launched with --mnemonic")?; - let host = session - .catalog - .paired_hosts(profile)? - .into_iter() - .find(|host| host.statement_account_id() == *statement_account_id) - .with_context(|| { - format!( - "paired device 0x{} does not exist in session {}; use /devices to list paired devices", - hex::encode(statement_account_id), - profile.name - ) - })?; + let host = find_paired_host(session, statement_account_id)?; + let notification_failure = if force { + "If notification fails, local removal still continues. The remote host may show stale connected state, but it cannot reach this responder after removal." + } else { + "If notification fails, nothing is removed." + }; Ok(( format!("Remove paired device {}", paired_device_label(&host)), format!( - "Statement account 0x{}. This stops its responder and removes its saved pairing from session {}. Other paired devices and the signing identity are unchanged. The remote host must pair again.", + "Statement account 0x{}. This notifies the remote host, then stops its responder and removes its saved pairing from session {}. {notification_failure} Other paired devices and the signing identity are unchanged. The remote host must pair again.", hex::encode(statement_account_id), profile.name ), @@ -3203,15 +3252,21 @@ async fn signing_interactive_loop( Ok(devices) => ui.system(devices), Err(error) => ui.error(format!("failed to list paired devices: {error}")), }, - ShellCommand::Devices(DeviceCommand::Remove(statement_account_id)) => { - let (action, detail) = - match paired_device_remove_confirmation(session, &statement_account_id) { - Ok(confirmation) => confirmation, - Err(error) => { - ui.error(error.to_string()); - continue; - } - }; + ShellCommand::Devices(DeviceCommand::Remove { + statement_account_id, + force, + }) => { + let (action, detail) = match paired_device_remove_confirmation( + session, + &statement_account_id, + force, + ) { + Ok(confirmation) => confirmation, + Err(error) => { + ui.error(error.to_string()); + continue; + } + }; let handle = ui.handle(); let approved = match ui .drive(input.clone(), handle.confirm(action, detail)) @@ -3224,15 +3279,22 @@ async fn signing_interactive_loop( ui.system("Paired-device removal cancelled"); continue; } - match remove_paired_host(session, &statement_account_id).await { - Ok(host) => ui.success( - "Paired device removed", - Some(format!( - "{}\nStatement account 0x{}", - paired_device_label(&host), - hex::encode(statement_account_id) - )), - ), + match disconnect_and_remove_paired_host(session, &statement_account_id, force).await + { + Ok(removal) => { + if let Some(reason) = removal.notification_failure { + let (title, detail) = forced_removal_warning(&reason); + ui.warning(title, Some(detail)); + } + ui.success( + "Paired device removed", + Some(format!( + "{}\nStatement account 0x{}", + paired_device_label(&removal.paired_host), + hex::encode(statement_account_id) + )), + ); + } Err(error) => ui.error(error.to_string()), } } @@ -3522,14 +3584,22 @@ async fn execute_non_interactive_command( ShellCommand::Devices(DeviceCommand::List) => { println!("{}", paired_device_list(session)?); } - ShellCommand::Devices(DeviceCommand::Remove(statement_account_id)) => { + ShellCommand::Devices(DeviceCommand::Remove { + statement_account_id, + force, + }) => { let profile_name = session .profile .as_ref() .context("paired-device management is unavailable when launched with --mnemonic")? .name .clone(); - remove_paired_host(session, &statement_account_id).await?; + let removal = + disconnect_and_remove_paired_host(session, &statement_account_id, force).await?; + if let Some(reason) = removal.notification_failure { + let (title, detail) = forced_removal_warning(&reason); + terminal_ui::output_warning(title, Some(detail)); + } println!( "Removed paired device 0x{} from session {}", hex::encode(statement_account_id), diff --git a/rust/crates/truapi-host-cli/src/signing_shell.rs b/rust/crates/truapi-host-cli/src/signing_shell.rs index 788a61b9e..08ccf5ff9 100644 --- a/rust/crates/truapi-host-cli/src/signing_shell.rs +++ b/rust/crates/truapi-host-cli/src/signing_shell.rs @@ -25,7 +25,10 @@ pub enum DeviceCommand { /// List paired devices for the active managed session. List, /// Remove the device with this statement account ID. - Remove([u8; 32]), + Remove { + statement_account_id: [u8; 32], + force: bool, + }, } /// Operation selected through `/approval`. @@ -200,14 +203,23 @@ pub fn parse_command(input: &str) -> Result { let arguments = shlex::split(argument).ok_or_else(|| "invalid /devices quoting".to_string())?; if arguments.first().is_some_and(|value| value == "--remove") { - if arguments.len() != 2 { - return Err("usage: /devices --remove ".to_string()); - } - return Ok(ShellCommand::Devices(DeviceCommand::Remove( - parse_statement_account_id(&arguments[1])?, - ))); + let (statement_account_id, force) = match arguments.as_slice() { + [_, statement_account_id] => (statement_account_id, false), + [_, statement_account_id, force] if force == "--force" => { + (statement_account_id, true) + } + _ => { + return Err( + "usage: /devices --remove [--force]".to_string() + ); + } + }; + return Ok(ShellCommand::Devices(DeviceCommand::Remove { + statement_account_id: parse_statement_account_id(statement_account_id)?, + force, + })); } - Err("usage: /devices [--list | --remove ]".to_string()) + Err("usage: /devices [--list | --remove [--force]]".to_string()) } "/approval" => match argument { "" => Ok(ShellCommand::Approval(ApprovalCommand::Current)), @@ -394,6 +406,21 @@ fn completions_for_scope( if scope == CommandScope::SigningHost && let Some(prefix) = input.strip_prefix("/devices ") { + if let Some((statement_account_id, force_prefix)) = + prefix.strip_prefix("--remove ").and_then(|value| { + value + .split_once(char::is_whitespace) + .map(|(statement_account_id, force_prefix)| { + (statement_account_id, force_prefix.trim_start()) + }) + }) + { + return fixed_argument_completions( + &format!("/devices --remove {statement_account_id}"), + force_prefix, + &[("--force", "remove locally if notification fails")], + ); + } return fixed_argument_completions( "/devices", prefix, @@ -805,7 +832,8 @@ pub const HELP_TEXT: &str = "\ /pair read a pairing QR image file /pair answer a Polkadot Mobile pairing URL /devices list paired devices for the active session -/devices --remove remove one paired device by statement account ID +/devices --remove disconnect and remove one paired device by statement account ID +/devices --remove --force remove locally even if notification fails /approval show the current confirmation approval mode /approval manual prompt for every future confirmation /approval automatic approve every future confirmation automatically @@ -902,11 +930,24 @@ mod tests { ); assert_eq!( parse_command(&format!("/devices --remove 0x{DEVICE_ID}")), - Ok(ShellCommand::Devices(DeviceCommand::Remove([1; 32]))) + Ok(ShellCommand::Devices(DeviceCommand::Remove { + statement_account_id: [1; 32], + force: false, + })) ); assert_eq!( parse_command(&format!("/devices --remove 0X{DEVICE_ID}")), - Ok(ShellCommand::Devices(DeviceCommand::Remove([1; 32]))) + Ok(ShellCommand::Devices(DeviceCommand::Remove { + statement_account_id: [1; 32], + force: false, + })) + ); + assert_eq!( + parse_command(&format!("/devices --remove 0x{DEVICE_ID} --force")), + Ok(ShellCommand::Devices(DeviceCommand::Remove { + statement_account_id: [1; 32], + force: true, + })) ); assert_eq!( parse_command("/session"), @@ -975,6 +1016,9 @@ mod tests { assert!(parse_command("/devices --remove").is_err()); assert!(parse_command("/devices --remove not-an-account").is_err()); assert!(parse_command(&format!("/devices --remove {DEVICE_ID} extra")).is_err()); + assert!(parse_command(&format!("/devices --remove --force {DEVICE_ID}")).is_err()); + assert!(parse_command(&format!("/devices --remove {DEVICE_ID} --force --force")).is_err()); + assert!(parse_command("/devices --force").is_err()); assert!(parse_command("/devices --unknown").is_err()); assert!(parse_command("/log noisy").is_err()); assert!(parse_command("/product example.com").is_err()); @@ -1125,6 +1169,17 @@ mod tests { }, ] ); + assert_eq!( + completions_for_scope( + &format!("/devices --remove {DEVICE_ID} --f"), + &[], + CommandScope::SigningHost + ), + vec![Completion { + value: format!("/devices --remove {DEVICE_ID} --force"), + description: "remove locally if notification fails", + }] + ); assert!(completions_for_scope("/devices", &[], CommandScope::PairingHost).is_empty()); } diff --git a/rust/crates/truapi-host-cli/src/terminal_ui.rs b/rust/crates/truapi-host-cli/src/terminal_ui.rs index 3093a4973..7cf28a601 100644 --- a/rust/crates/truapi-host-cli/src/terminal_ui.rs +++ b/rust/crates/truapi-host-cli/src/terminal_ui.rs @@ -355,6 +355,20 @@ pub fn output_success(title: impl Into, detail: Option) { } } +/// Emit a warning through the active transcript or standard error. +pub fn output_warning(title: impl Into, detail: Option) { + let title = title.into(); + if !send_to_active(UiEvent::Notice { + tone: NoticeTone::Warning, + title: title.clone(), + detail: detail.clone(), + }) { + let mut app = App::new_pairing(String::new(), String::new(), "info".to_string()); + app.notice(NoticeTone::Warning, title, detail); + write_human_stderr(&app.transcript_text()); + } +} + fn write_human_stdout(text: &str) { let styled = styled_output(io::stdout().is_terminal()); let mut stdout = io::stdout().lock(); @@ -723,6 +737,11 @@ impl ActiveTerminalUi { self.app.notice(NoticeTone::Success, text.into(), detail); } + /// Record an immediate warning. + pub fn warning(&mut self, text: impl Into, detail: Option) { + self.app.notice(NoticeTone::Warning, text.into(), detail); + } + /// Record a typed lifecycle event. pub fn event(&mut self, event: SystemEvent) { self.app.handle_system_event(event); @@ -3084,6 +3103,19 @@ mod tests { ) } + fn test_active_ui() -> ActiveTerminalUi { + let (sender, receiver) = mpsc::unbounded_channel(); + ActiveTerminalUi { + terminal: None, + events: None, + receiver, + sender, + app: test_app(), + clipboard: None, + copy_next_pairing_deeplink: false, + } + } + #[test] fn approval_temporarily_replaces_and_then_restores_command_draft() { let mut app = test_app(); @@ -3390,16 +3422,7 @@ mod tests { #[test] fn interactive_error_preserves_backend_cause_chain() { - let (sender, receiver) = mpsc::unbounded_channel(); - let mut ui = ActiveTerminalUi { - terminal: None, - events: None, - receiver, - sender, - app: test_app(), - clipboard: None, - copy_next_pairing_deeplink: false, - }; + let mut ui = test_active_ui(); let error = anyhow::anyhow!("backend supplied explanation") .context("username registration failed (503 Service Unavailable)") .context("attest account auto-1"); @@ -3412,6 +3435,22 @@ mod tests { ); } + #[test] + fn immediate_warning_precedes_following_success() { + let mut ui = test_active_ui(); + + ui.warning( + "Paired device removed without notification", + Some("notification failed".to_string()), + ); + ui.success("Paired device removed", None); + + assert_eq!( + ui.app.transcript_text(), + "! Paired device removed without notification\n notification failed\nāœ“ Paired device removed" + ); + } + #[test] fn script_streams_group_lines_and_preserve_blank_lines() { let mut app = test_app(); diff --git a/rust/crates/truapi-host-cli/tests/signing_host_cli.rs b/rust/crates/truapi-host-cli/tests/signing_host_cli.rs index 7c741b13c..2e2f419eb 100644 --- a/rust/crates/truapi-host-cli/tests/signing_host_cli.rs +++ b/rust/crates/truapi-host-cli/tests/signing_host_cli.rs @@ -1,11 +1,40 @@ //! Process-boundary smoke tests for signing-host invocation modes. +use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; fn command() -> Command { Command::new(env!("CARGO_BIN_EXE_truapi-host")) } +fn seed_two_paired_hosts(base_path: &Path) -> PathBuf { + let profile = base_path.join("paseo-next-v2/alice_signing_host"); + std::fs::create_dir_all(&profile).expect("create signing-host profile"); + std::fs::write( + profile.join("paired-hosts.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "version": 1, + "paired_hosts": [ + { + "version": 1, + "statement_account_id": vec![1_u8; 32], + "encryption_public_key": vec![11_u8; 32], + "host_name": "First" + }, + { + "version": 1, + "statement_account_id": vec![2_u8; 32], + "encryption_public_key": vec![22_u8; 32], + "host_name": "Second" + } + ] + })) + .expect("encode paired hosts"), + ) + .expect("seed paired hosts"); + profile +} + #[test] fn interactive_mode_rejects_non_tty_stdio_with_usage_exit() { let output = command() @@ -45,6 +74,7 @@ fn exec_help_is_plain_and_exits_successfully() { assert!(String::from_utf8_lossy(&output.stdout).contains("/product")); assert!(String::from_utf8_lossy(&output.stdout).contains("/session")); assert!(String::from_utf8_lossy(&output.stdout).contains("/devices")); + assert!(String::from_utf8_lossy(&output.stdout).contains("/devices --remove --force")); assert!(String::from_utf8_lossy(&output.stdout).contains("/approval automatic")); assert!(String::from_utf8_lossy(&output.stdout).contains("/session --clear-all")); #[cfg(unix)] @@ -232,32 +262,9 @@ fn exec_clear_all_removes_every_session_for_the_network() { } #[test] -fn exec_devices_lists_and_removes_exactly_one_paired_device() { +fn exec_device_removal_preserves_pairings_when_the_local_session_is_inactive() { let temporary = tempfile::tempdir().expect("create temporary session root"); - let profile = temporary.path().join("paseo-next-v2/alice_signing_host"); - std::fs::create_dir_all(&profile).expect("create signing-host profile"); - std::fs::write( - profile.join("paired-hosts.json"), - serde_json::to_vec_pretty(&serde_json::json!({ - "version": 1, - "paired_hosts": [ - { - "version": 1, - "statement_account_id": vec![1_u8; 32], - "encryption_public_key": vec![11_u8; 32], - "host_name": "First" - }, - { - "version": 1, - "statement_account_id": vec![2_u8; 32], - "encryption_public_key": vec![22_u8; 32], - "host_name": "Second" - } - ] - })) - .expect("encode paired hosts"), - ) - .expect("seed paired hosts"); + let profile = seed_two_paired_hosts(temporary.path()); let listed = command() .args(["signing-host", "--frame-listen", "127.0.0.1:0"]) @@ -282,11 +289,61 @@ fn exec_devices_lists_and_removes_exactly_one_paired_device() { .stdin(Stdio::null()) .output() .expect("remove paired device"); + assert_eq!(removed.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&removed.stderr).contains("no active local session")); + + let stored: serde_json::Value = serde_json::from_slice( + &std::fs::read(profile.join("paired-hosts.json")).expect("read paired hosts"), + ) + .expect("decode paired hosts"); + assert_eq!( + stored["paired_hosts"], + serde_json::json!([ + { + "version": 1, + "statement_account_id": vec![1_u8; 32], + "encryption_public_key": vec![11_u8; 32], + "host_name": "First" + }, + { + "version": 1, + "statement_account_id": vec![2_u8; 32], + "encryption_public_key": vec![22_u8; 32], + "host_name": "Second" + } + ]) + ); +} + +#[test] +fn exec_force_device_removal_removes_exactly_one_pairing_when_notification_fails() { + let temporary = tempfile::tempdir().expect("create temporary session root"); + let profile = seed_two_paired_hosts(temporary.path()); + + let remove_command = format!("/devices --remove 0x{} --force", hex::encode([1_u8; 32])); + let removed = command() + .args(["signing-host", "--frame-listen", "127.0.0.1:0"]) + .arg("--base-path") + .arg(temporary.path()) + .args(["--session", "alice", "exec", &remove_command]) + .stdin(Stdio::null()) + .output() + .expect("force remove paired device"); + assert!(removed.status.success()); - assert!(String::from_utf8_lossy(&removed.stdout).contains(&format!( + let expected = format!( "Removed paired device 0x{} from session alice", - hex::encode([1; 32]) - ))); + hex::encode([1_u8; 32]) + ); + assert_eq!( + String::from_utf8_lossy(&removed.stdout).lines().last(), + Some(expected.as_str()) + ); + let stderr = String::from_utf8_lossy(&removed.stderr); + assert!(stderr.contains("Paired device removed without notification")); + assert!(stderr.contains("no active local session")); + assert!(stderr.contains("Forced local removal completed")); + assert!(stderr.contains("cannot reach a responder on this signing host")); let stored: serde_json::Value = serde_json::from_slice( &std::fs::read(profile.join("paired-hosts.json")).expect("read paired hosts"), @@ -294,12 +351,14 @@ fn exec_devices_lists_and_removes_exactly_one_paired_device() { .expect("decode paired hosts"); assert_eq!( stored["paired_hosts"], - serde_json::json!([{ - "version": 1, - "statement_account_id": vec![2_u8; 32], - "encryption_public_key": vec![22_u8; 32], - "host_name": "Second" - }]) + serde_json::json!([ + { + "version": 1, + "statement_account_id": vec![2_u8; 32], + "encryption_public_key": vec![22_u8; 32], + "host_name": "Second" + } + ]) ); } diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index 1b037d2a0..77230f86e 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -34,7 +34,8 @@ use crate::host_logic::sso::messages::{RemoteMessage, RemoteMessageData, SsoRequ use crate::runtime::{ ChatConnection, DEFAULT_REMOTE_AUTHORITY_RESPONSE_TIMEOUT, LocalActivation, PairedSsoPeer, PairingHostRole, ProductAuthority, ProductRuntimeHost, ResponderExit, RuntimeServices, - SigningHostRole, answer_remote_message, establish_pairing, respond_to_pairing, resume_pairing, + SigningHostRole, answer_remote_message, disconnect_paired_host, establish_pairing, + respond_to_pairing, resume_pairing, }; use crate::subscription::{HostInitiatedSubscriptionManager, Spawner}; use crate::transport::Transport; @@ -771,6 +772,17 @@ impl SigningHostRuntime { .map_err(|reason| v01::GenericError { reason }) } + /// Notify a paired host that this signing host is ending their SSO session. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.disconnect_paired_host"))] + pub async fn disconnect_paired_host( + &self, + peer: PairedSsoPeer, + ) -> Result<(), v01::GenericError> { + disconnect_paired_host(self.services.clone(), self.signing_host.clone(), peer) + .await + .map_err(|reason| v01::GenericError { reason }) + } + /// Answer one decrypted SSO remote message with this signing host. /// /// Session control stays with the caller: `Disconnected` is reported as an @@ -1496,6 +1508,14 @@ impl Transport for SinkTransport { mod tests { use super::*; use crate::frame::{Payload, ProtocolMessage, subscription_ids}; + use crate::host_logic::product_account::derive_identity_keypair; + use crate::host_logic::sso::messages::{ + RemoteMessage, RemoteMessageData, decode_incoming_sso_request, v1, + }; + use crate::host_logic::sso::pairing::{ + PairingBootstrap, derive_x25519_keypair_from_entropy, establish_sso_session_info, + x25519_public_key, + }; use crate::test_support::{StubPlatform, runtime_config, test_spawner, wait_until}; use parity_scale_codec::Encode; use std::sync::atomic::Ordering; @@ -1514,6 +1534,27 @@ mod tests { } } + fn activated_signing_runtime(platform: Arc) -> SigningHostRuntime { + use truapi_platform::{HostInfo, PlatformInfo, SigningHostConfig}; + + let config = SigningHostConfig::new( + HostInfo { + name: "Polkadot Mobile".to_string(), + icon: None, + version: None, + platform: truapi::latest::HostPlatform::Unknown, + }, + PlatformInfo::default(), + [0; 32], + [0xbb; 32], + ) + .expect("signing host config is valid"); + let runtime = SigningHostRuntime::new(platform, config, test_spawner()); + futures::executor::block_on(runtime.activate_local_session(vec![0xab; 32])) + .expect("activation succeeds"); + runtime + } + /// Install `session` once the boot reconcile has reported the empty /// session store, so its clear cannot land on top of the session. fn install_session_after_boot( @@ -2336,4 +2377,121 @@ mod tests { assert_eq!(payload.responding_to, "m3"); assert!(payload.product_public_key.is_ok()); } + + #[test] + fn disconnect_paired_host_submits_one_disconnected_message_to_the_selected_peer() { + let platform = Arc::new(StubPlatform { + rpc_responses: vec![ + r#"{"jsonrpc":"2.0","id":"truapi:1","result":{"status":"new"}}"#.to_string(), + ], + ..Default::default() + }); + let runtime = activated_signing_runtime(platform.clone()); + let peer_encryption_secret = [0x42; 32]; + let peer = PairedSsoPeer { + statement_account_id: [0x31; 32], + encryption_public_key: x25519_public_key(peer_encryption_secret), + }; + let identity = derive_identity_keypair(&[0xab; 32]).expect("identity derivation succeeds"); + let (_, responder_encryption_public_key) = + derive_x25519_keypair_from_entropy(&[0xab; 32], b"sso"); + let pairing_session = establish_sso_session_info( + &PairingBootstrap { + deeplink: String::new(), + topic: [0; 32], + statement_store_public_key: peer.statement_account_id, + statement_store_secret: [0; 64], + encryption_public_key: peer.encryption_public_key, + encryption_secret_key: peer_encryption_secret, + }, + identity.public.to_bytes(), + responder_encryption_public_key, + ) + .expect("pairing session derivation succeeds"); + let unrelated_peer_encryption_secret = [0x43; 32]; + let unrelated_pairing_session = establish_sso_session_info( + &PairingBootstrap { + deeplink: String::new(), + topic: [0; 32], + statement_store_public_key: [0x32; 32], + statement_store_secret: [0; 64], + encryption_public_key: x25519_public_key(unrelated_peer_encryption_secret), + encryption_secret_key: unrelated_peer_encryption_secret, + }, + identity.public.to_bytes(), + responder_encryption_public_key, + ) + .expect("unrelated pairing session derivation succeeds"); + + futures::executor::block_on(runtime.disconnect_paired_host(peer)) + .expect("disconnect submission succeeds"); + + let submits = platform + .sent_rpc + .lock() + .expect("rpc list mutex poisoned") + .iter() + .filter_map(|request| { + let value: serde_json::Value = serde_json::from_str(request).ok()?; + (value["method"] == "statement_submit").then_some(value) + }) + .collect::>(); + let statement_hex = submits[0]["params"][0] + .as_str() + .expect("statement submit carries encoded bytes"); + let statement = hex::decode(statement_hex.strip_prefix("0x").unwrap_or(statement_hex)) + .expect("submitted statement is hex"); + let incoming = decode_incoming_sso_request(&pairing_session, &statement) + .expect("selected peer decrypts the statement") + .expect("submitted statement is an SSO request"); + let unrelated_error = decode_incoming_sso_request(&unrelated_pairing_session, &statement) + .expect_err("unrelated peer cannot decrypt the statement envelope"); + let message_id = incoming.messages[0].message_id.clone(); + + assert_eq!( + ( + submits.len(), + incoming.request_id, + incoming.messages, + unrelated_error.request_id, + unrelated_error + .reason + .starts_with("failed to decrypt SSO statement data"), + ), + ( + 1, + message_id.clone(), + vec![RemoteMessage { + message_id, + data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), + }], + None, + true, + ) + ); + } + + #[test] + fn disconnect_paired_host_propagates_submission_failure() { + let platform = Arc::new(StubPlatform { + rpc_responses: vec![ + r#"{"jsonrpc":"2.0","id":"truapi:1","result":{"reason":"badProof","status":"rejected"}}"# + .to_string(), + ], + ..Default::default() + }); + let runtime = activated_signing_runtime(platform); + let peer = PairedSsoPeer { + statement_account_id: [0x31; 32], + encryption_public_key: x25519_public_key([0x42; 32]), + }; + + let error = futures::executor::block_on(runtime.disconnect_paired_host(peer)) + .expect_err("disconnect submission failure is returned to the caller"); + + assert_eq!( + error.reason, + r#"statement_submit not accepted: {"reason":"badProof","status":"rejected"}"# + ); + } } diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index c083b24a8..6f863e89d 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -68,8 +68,8 @@ pub(crate) use services::RuntimeServices; #[cfg(not(target_arch = "wasm32"))] pub use signing_host::StatementRenewalTarget; pub(crate) use signing_host::{ - LocalActivation, SigningHost as SigningHostRole, answer_remote_message, establish_pairing, - respond_to_pairing, resume_pairing, + LocalActivation, SigningHost as SigningHostRole, answer_remote_message, disconnect_paired_host, + establish_pairing, respond_to_pairing, resume_pairing, }; pub use signing_host::{PairedSsoPeer, ResponderExit}; diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index d2f0c82d2..4c7a3c4d6 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -30,7 +30,8 @@ pub use allowance_renewal::StatementRenewalTarget; pub(crate) use local_activation::LocalActivation; pub use sso_responder::{PairedSsoPeer, ResponderExit}; pub(crate) use sso_responder::{ - answer_remote_message, establish_pairing, respond_to_pairing, resume_pairing, + answer_remote_message, disconnect_paired_host, establish_pairing, respond_to_pairing, + resume_pairing, }; use super::authority::{ 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 5f1bcec68..1dfdc4975 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 @@ -59,7 +59,7 @@ use crate::runtime::authority::{ SignRawAuthorityRequest, }; use crate::runtime::services::RuntimeServices; -use crate::runtime::sso_remote::fresh_statement_expiry; +use crate::runtime::sso_remote::{fresh_statement_expiry, sso_message_id}; #[cfg(not(target_arch = "wasm32"))] use crate::runtime::statement_allowance::StatementAllowanceError; use crate::runtime::statement_store_rpc; @@ -345,6 +345,33 @@ pub(crate) async fn resume_pairing( .await } +/// Notify a paired host that this signing host is ending their SSO session. +pub(crate) async fn disconnect_paired_host( + services: Arc, + signing_host: Arc, + peer: PairedSsoPeer, +) -> Result<(), String> { + let entropy = signing_host + .root_entropy() + .map_err(|err| format!("signing host has no active local session: {err}"))?; + let session = responder_session(&entropy, peer)?; + let message_id = sso_message_id(); + let message = RemoteMessage { + message_id: message_id.clone(), + data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), + }; + let statement = build_outgoing_request_statement( + &session, + message_id, + vec![message], + fresh_statement_expiry(), + )?; + services + .statement_store + .submit_sso(statement, "sso-responder disconnect") + .await +} + fn responder_session(entropy: &[u8], peer: PairedSsoPeer) -> Result { let (identity, _) = derive_responder_identity(entropy) .map_err(|err| format!("responder identity derivation failed: {err}"))?;