diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md index b948ca9a8..fa9199838 100644 --- a/rust/crates/truapi-host-cli/README.md +++ b/rust/crates/truapi-host-cli/README.md @@ -227,7 +227,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. | @@ -399,8 +400,16 @@ 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 store accepts it 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 or times out after 30 seconds. +Submission does not wait for the remote host to acknowledge receipt. 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` @@ -435,6 +444,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 @@ -507,7 +517,7 @@ Product-local KV is persisted independently under each identity root as product id and raw product keys. Product and core JSON writes use a flushed temporary file and atomic rename. -Six scripts ship under `js/scripts/`: +Scripts under `js/scripts/` include: - `battery.ts` — the generated full-surface gate. It discovers every method from the same code-generated example manifest as the playground Diagnosis, @@ -572,6 +582,12 @@ Six scripts ship under `js/scripts/`: --auto-accept ``` +- `device-removal-disconnect.ts`: verifies `Connected` followed by `Disconnected`. + Run it through `e2e/device-removal-disconnect.sh`, which pairs isolated hosts, + removes the device interactively, and checks cleared pairing auth storage and + an empty signing-host device list. Run `make codegen` once in a fresh checkout, + build `truapi-host-cli`, then run the shell 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 fa45c9c83..4996efdb9 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 store accepts it. 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,16 @@ 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, allowing up to 30 seconds for the statement store +to accept it. This does not wait for a peer acknowledgement. If submission fails +or times out, 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..20ac0abd1 --- /dev/null +++ b/rust/crates/truapi-host-cli/e2e/device-removal-disconnect.sh @@ -0,0 +1,216 @@ +#!/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 previous_count="${2:-0}" + 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/v2/$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/v2/$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 +device_ids=() +while IFS= read -r device_id; do + device_ids+=("$device_id") +done < <( + 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_pairing_pattern '^DEVICE_REMOVE_DISCONNECT_OK$' "$disconnect_marker_before_removal" +wait_for_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 742776687..ec0da845c 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -2421,14 +2421,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.statement_account_id()).await?; } terminal_ui::output_event(SystemEvent::SigningHostExit { outcome: format!("{exit:?}"), @@ -2445,15 +2444,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() @@ -2464,7 +2463,63 @@ 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 = tokio::time::timeout( + Duration::from_secs(30), + session + .runtime + .disconnect_paired_host(paired_sso_peer(&paired_host)), + ) + .await + .map_err(|_| "disconnect notification submission timed out".to_string()) + .and_then(|result| result.map_err(|error| error.reason)); + let notification_failure = match notification { + Ok(()) => None, + Err(reason) if force => Some(reason), + Err(reason) => { + bail!("failed to notify paired device before removal: {reason}") + } + }; + remove_paired_host_locally(session, statement_account_id).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, + statement_account_id: &[u8; 32], +) -> Result<()> { + 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)?; @@ -2479,7 +2534,7 @@ async fn remove_paired_host( "paired device was removed, but its allowance renewal could not be removed" ); } - Ok(paired_host) + Ok(()) } fn validate_session_clear( @@ -2664,27 +2719,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 ), @@ -3209,15 +3259,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)) @@ -3230,16 +3286,29 @@ 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) - )), - ), - Err(error) => ui.error(error.to_string()), + match ui + .drive( + input, + disconnect_and_remove_paired_host(session, &statement_account_id, force), + ) + .await? + { + DriveResult::Complete(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) + )), + ); + } + DriveResult::Complete(Err(error)) => ui.error(error.to_string()), + DriveResult::Cancelled => ui.system("Paired-device removal cancelled"), } } ShellCommand::Session(SessionCommand::Clear(target)) => { @@ -3528,14 +3597,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 b8f3841f2..8d7147140 100644 --- a/rust/crates/truapi-host-cli/src/signing_shell.rs +++ b/rust/crates/truapi-host-cli/src/signing_shell.rs @@ -24,8 +24,11 @@ pub enum ProductCommand { pub enum DeviceCommand { /// List paired devices for the active managed session. List, - /// Remove the device with this statement account ID. - Remove([u8; 32]), + /// Notify and remove one device; `force` permits cleanup if notification fails. + Remove { + statement_account_id: [u8; 32], + force: bool, + }, } /// Operation selected through `/approval`. @@ -195,14 +198,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)), @@ -389,6 +401,16 @@ 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)) + { + return fixed_argument_completions( + &format!("/devices --remove {statement_account_id}"), + force_prefix.trim_start(), + &[("--force", "remove locally if notification fails")], + ); + } return fixed_argument_completions( "/devices", prefix, @@ -783,7 +805,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 @@ -880,11 +903,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"), @@ -953,6 +989,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()); @@ -1103,6 +1142,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..0a06b5ecd 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); 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 63ba6a9be..3ccb32541 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("v2/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 versioned_state_starts_fresh_and_preserves_previous_state() { for source in ["argument", "environment", "default"] { @@ -124,6 +153,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)] @@ -311,32 +341,11 @@ 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("v2/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 pairings_before = + std::fs::read(profile.join("paired-hosts.json")).expect("read paired hosts"); let listed = command() .args(["signing-host", "--frame-listen", "127.0.0.1:0"]) @@ -361,11 +370,42 @@ 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")); + + assert_eq!( + std::fs::read(profile.join("paired-hosts.json")).expect("read paired hosts"), + pairings_before, + ); +} + +#[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")); let stored: serde_json::Value = serde_json::from_slice( &std::fs::read(profile.join("paired-hosts.json")).expect("read paired hosts"), @@ -373,12 +413,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 b5b79c34a..96b2de495 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -35,7 +35,8 @@ use crate::runtime::sso_service::Dispatch; use crate::runtime::{ ChatConnection, DEFAULT_REMOTE_AUTHORITY_RESPONSE_TIMEOUT, LocalActivation, PairedSsoPeer, PairingHostRole, ProductAuthority, ProductRuntimeHost, ResponderExit, RuntimeServices, - SigningHostRole, SigningHostSsoService, establish_pairing, respond_to_pairing, resume_pairing, + SigningHostRole, SigningHostSsoService, disconnect_paired_host, establish_pairing, + respond_to_pairing, resume_pairing, }; use crate::subscription::{HostInitiatedSubscriptionManager, Spawner}; use crate::transport::Transport; @@ -772,6 +773,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 @@ -1488,6 +1500,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; @@ -1506,6 +1526,28 @@ 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], + "testnet".to_string(), + ) + .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( @@ -2328,4 +2370,92 @@ mod tests { assert_eq!(payload.responding_to, "m3"); assert!(payload.payload.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], "testnet").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"); + 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 [submit] = submits.as_slice() else { + panic!("expected one disconnect submission, got {submits:?}"); + }; + let statement_hex = submit["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"); + assert_eq!( + incoming.messages, + vec![RemoteMessage { + message_id: incoming.request_id, + data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), + }], + ); + } + + #[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 bc7ba5dd3..f63f87ec8 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -52,8 +52,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, SigningHostSsoService, establish_pairing, - respond_to_pairing, resume_pairing, + LocalActivation, SigningHost as SigningHostRole, SigningHostSsoService, disconnect_paired_host, + establish_pairing, respond_to_pairing, resume_pairing, }; pub use signing_host::{PairedSsoPeer, ResponderExit}; use tracing::{instrument, warn}; diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index ece69f522..47acb055e 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -34,7 +34,9 @@ use subxt::utils::{AccountId32, MultiSignature}; pub use allowance_renewal::StatementRenewalTarget; pub(crate) use local_activation::LocalActivation; pub use sso_responder::{PairedSsoPeer, ResponderExit}; -pub(crate) use sso_responder::{establish_pairing, respond_to_pairing, resume_pairing}; +pub(crate) use sso_responder::{ + disconnect_paired_host, establish_pairing, respond_to_pairing, resume_pairing, +}; pub(crate) use sso_service::SigningHostSsoService; 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 d72167d0a..d558c6273 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 @@ -31,8 +31,8 @@ use crate::host_logic::product_account::{ }; use crate::host_logic::session::SsoSessionInfo; use crate::host_logic::sso::messages::{ - IncomingSsoRequest, OnExistingAllowancePolicy, RemoteMessageData, SsoResponseCode, - build_outgoing_request_statement, build_signed_session_response_statement, + IncomingSsoRequest, OnExistingAllowancePolicy, RemoteMessage, RemoteMessageData, + SsoResponseCode, build_outgoing_request_statement, build_signed_session_response_statement, decode_incoming_sso_request, v1, }; use crate::host_logic::sso::pairing::{ @@ -47,7 +47,7 @@ use crate::host_logic::statement_store::{ }; use crate::runtime::authority::{AuthorityError, AuthoritySession}; use crate::runtime::services::RuntimeServices; -use crate::runtime::sso_remote::fresh_statement_expiry; +use crate::runtime::sso_remote::{fresh_statement_expiry, sso_message_id}; use crate::runtime::sso_service::Dispatch; #[cfg(not(target_arch = "wasm32"))] use crate::runtime::statement_allowance::StatementAllowanceError; @@ -324,6 +324,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, signing_host.network_suffix(), 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], network_suffix: &str,