iris-chat 0.1.54

Iris Chat command line client and shared encrypted chat core
Documentation
use super::remote_signer_rpc::SignerRpc;
use super::remote_signer_uri::{SignerConnection, PERMISSIONS};
use super::*;
use tokio::sync::{mpsc, oneshot};

pub(super) struct RemoteSignRequest {
    pub(super) request_id: String,
    pub(super) unsigned_event_json: String,
}

pub(super) async fn run(
    mut rpc: SignerRpc,
    relays: Vec<RelayUrl>,
    connection: Option<SignerConnection>,
    challenge: String,
    mut commands: mpsc::Receiver<RemoteSignRequest>,
    cancel: oneshot::Receiver<()>,
) {
    let result = tokio::select! {
        _ = cancel => None,
        result = authorize(&mut rpc, &relays, connection, &challenge, &mut commands) => Some(result),
    };
    if let Some(Err(error)) = result {
        // Never surface encrypted payloads, secrets, or full connection URLs.
        let message = if error.to_string().starts_with("Signer declined") {
            "Signer declined the request."
        } else if error.to_string().starts_with("Sign-in timed out") {
            "Sign-in timed out. Try again."
        } else {
            "Could not connect to the signer. Try again."
        };
        let _ = rpc.tx.send(CoreMsg::Internal(Box::new(
            InternalEvent::RemoteSignerFailed {
                token: rpc.token.clone(),
                message: message.into(),
            },
        )));
    }
    rpc.client.shutdown().await;
}

async fn authorize(
    rpc: &mut SignerRpc,
    relays: &[RelayUrl],
    connection: Option<SignerConnection>,
    challenge: &str,
    commands: &mut mpsc::Receiver<RemoteSignRequest>,
) -> anyhow::Result<()> {
    rpc.subscribe(relays).await?;
    if let Some(connection) = connection {
        let result = tokio::time::timeout(
            Duration::from_secs(90),
            rpc.request(
                "connect",
                vec![
                    connection.signer.to_hex(),
                    connection.secret.clone(),
                    PERMISSIONS.into(),
                    serde_json::json!({"name":"Iris Chat", "url":"https://iris.to"}).to_string(),
                ],
            ),
        )
        .await
        .map_err(|_| anyhow::anyhow!("Sign-in timed out. Try again."))??;
        let value = result.as_str().unwrap_or_default();
        anyhow::ensure!(
            value == "ack" || (!connection.secret.is_empty() && value == connection.secret),
            "Invalid signer confirmation."
        );
    } else {
        rpc.progress(crate::RemoteSignerPhase::WaitingForSigner, None);
        loop {
            let (author, response) = rpc.next_response().await?;
            if response.get("error").is_some_and(|error| !error.is_null())
                || response.get("result").and_then(serde_json::Value::as_str) != Some(challenge)
            {
                continue;
            }
            rpc.signer = Some(author);
            break;
        }
    }
    // A displayed code remains usable until the user cancels. Only the actual
    // approval exchange is timed, after an authenticated phone has responded.
    tokio::time::timeout(
        Duration::from_secs(180),
        finish_authorization(rpc, commands),
    )
    .await
    .map_err(|_| anyhow::anyhow!("Sign-in timed out. Try again."))?
}

async fn finish_authorization(
    rpc: &mut SignerRpc,
    commands: &mut mpsc::Receiver<RemoteSignRequest>,
) -> anyhow::Result<()> {
    rpc.progress(crate::RemoteSignerPhase::WaitingForApproval, None);
    rpc.switch_relays().await?;
    let result = rpc.request("get_public_key", Vec::new()).await?;
    let owner = PublicKey::from_hex(result.as_str().unwrap_or_default())?;
    let _ = rpc.tx.send(CoreMsg::Internal(Box::new(
        InternalEvent::RemoteSignerConnected {
            token: rpc.token.clone(),
            owner_pubkey_hex: owner.to_hex(),
        },
    )));
    let request = commands
        .recv()
        .await
        .ok_or_else(|| anyhow::anyhow!("Sign-in cancelled."))?;
    rpc.progress(crate::RemoteSignerPhase::WaitingForApproval, None);
    let result = rpc
        .request("sign_event", vec![request.unsigned_event_json])
        .await?;
    let signed_event_json = result
        .as_str()
        .ok_or_else(|| anyhow::anyhow!("Invalid signer response."))?
        .to_string();
    // This optional extension is encrypted to the approved signer transport. Ordinary
    // external signers can reject or ignore it; the device then keeps its join boundary.
    let link_info = match tokio::time::timeout(
        Duration::from_secs(3),
        rpc.request("iris_get_link_info", Vec::new()),
    )
    .await
    {
        Ok(Ok(value)) => value
            .as_str()
            .filter(|value| value.len() <= 4096)
            .and_then(|value| {
                serde_json::from_str::<super::device_link_signer::DeviceLinkInfo>(value).ok()
            })
            .filter(|info| info.v == 1 && info.link_id == rpc.keys.public_key().to_hex()),
        _ => None,
    };
    let _ = rpc.tx.send(CoreMsg::Internal(Box::new(
        InternalEvent::RemoteSignerSigned {
            token: rpc.token.clone(),
            request_id: request.request_id,
            signed_event_json,
            link_info,
        },
    )));
    Ok(())
}