Skip to main content

mail4agent_messenger_shell/cli/
element_open.rs

1//! `m4a-agent element-open (--as <nick>|--session <id>) --element <url>`: open an Element Web in
2//! the browser, logged in as this identity, with no password.
3//!
4//! The command signs a fresh challenge with the identity key at the product door, receives a
5//! one-time, short-lived Matrix login token (`m.login.token`), and hands Element the page
6//! `<element>/?loginToken=<token>` through the browser opener. The token is never printed and never
7//! written to a file; the identity's own session is untouched. The opener is the command named by
8//! `M4A_BROWSER` (the URL is its only argument), else `xdg-open` (`open` on macOS).
9//!
10//! Element must be configured for this server (its `default_server_config` or `default_hs_url`).
11//! The login gives that device access to the account's rooms; the history of encrypted rooms stays
12//! unreadable on a new device unless the owner brings keys over (key backup, or verification from a
13//! device that has them).
14//!
15//! The one place the token can be seen is the opener's argument list for the moment the browser
16//! starts, by processes of the same user; it expires in two minutes and works once.
17
18use super::session_arg::{resolve, take_flag};
19
20pub fn run(args: Vec<String>) {
21    match go(args) {
22        Ok(()) => println!("element opened (login token not shown)"),
23        Err(e) => {
24            eprintln!("element-open: {e}");
25            std::process::exit(if e.starts_with("usage") { 64 } else { 1 });
26        }
27    }
28}
29
30const USAGE: &str = "usage: m4a-agent element-open (--as <nick>|--session <id>) --element <url>   (env: M4A_STORE_ROOT, M4A_PRODUCT_URL, M4A_TIER, M4A_BROWSER)";
31
32/// The page to open, from the Element base and a token. The token is percent-encoded.
33pub(crate) fn page(element: &str, token: &str) -> String {
34    let enc: String = token.bytes().map(|b| if b.is_ascii_alphanumeric() || b"-._~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }).collect();
35    format!("{}/?loginToken={enc}", element.trim_end_matches('/'))
36}
37
38fn go(mut args: Vec<String>) -> Result<(), String> {
39    let element = take_flag(&mut args, "--element")?.or_else(|| std::env::var("M4A_ELEMENT_URL").ok().filter(|v| !v.is_empty())).ok_or(USAGE)?;
40    let target = resolve(&mut args)?;
41    if !args.is_empty() || !(element.starts_with("https://") || element.starts_with("http://")) {
42        return Err(USAGE.into());
43    }
44    let url = std::env::var("M4A_PRODUCT_URL").or_else(|_| std::env::var(crate::HOMESERVER_URL_ENV)).ok().filter(|v| !v.is_empty()).ok_or("M4A_PRODUCT_URL is not set")?;
45    let token = login_token(&target.store_root, &target.session_id, &url)?;
46    let opener = std::env::var("M4A_BROWSER").ok().filter(|v| !v.is_empty()).unwrap_or_else(|| if cfg!(target_os = "macos") { "open".into() } else { "xdg-open".into() });
47    let st = std::process::Command::new(&opener).arg(page(&element, &token)).stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().map_err(|_| format!("the browser opener {opener:?} could not be started (set M4A_BROWSER)"))?;
48    if st.success() {
49        Ok(())
50    } else {
51        Err("the browser opener failed".into())
52    }
53}
54
55fn login_token(store_root: &std::path::Path, session_id: &str, url: &str) -> Result<String, String> {
56    use m4a_agent::backend::Backend;
57    let fail = |e: m4a_agent::AgentError| e.to_string();
58    let ids = m4a_agent::IdentityStore::new(crate::store_key::vault(store_root).map_err(|e| e.to_string())?);
59    let backend = m4a_agent::backend::server::ServerBackend::new(url).map_err(fail)?;
60    let id = ids.resolve(session_id, m4a_agent::BackendKind::Server, backend.server_ref()).map_err(fail)?;
61    if !id.enrolled {
62        return Err("this identity is not enrolled on that server yet".into());
63    }
64    let wire = m4a_agent::backend::wire::HttpWire::new(url).map_err(fail)?;
65    m4a_agent::keyauth::login_token(&wire, &ids, &id).map_err(fail)
66}
67
68#[cfg(test)]
69mod tests {
70    use super::page;
71
72    #[test]
73    fn the_page_carries_the_token_in_the_query_encoded() {
74        assert_eq!(page("https://chat.example/", "abc-DEF_1.2~x"), "https://chat.example/?loginToken=abc-DEF_1.2~x");
75        assert_eq!(page("http://localhost:8080", "a b/c"), "http://localhost:8080/?loginToken=a%20b%2Fc");
76    }
77}