crates/cli/git-ents/src/commands/login.rs
login.rshistorycomment on this file
| 1 | //! `git ents login <url> <code>`: prove membership to a hosted web |
| 2 | //! session (`roots.web-signin`) — the automated replacement for the |
| 3 | //! pre-redo forge's paste-the-signature sign-in. |
| 4 | //! |
| 5 | //! The browser's `/login` page on the hosted root displays a one-time |
| 6 | //! code; this command fetches that code's challenge, **rebuilds the |
| 7 | //! signed payload locally** from the host the member typed — never from |
| 8 | //! bytes the server supplies, so a malicious or misconfigured server |
| 9 | //! cannot get an arbitrary blob signed — signs it with the member's own |
| 10 | //! key under `ents-web`'s login namespace (distinct from git's commit |
| 11 | //! namespace by construction), and posts the signature back. On success |
| 12 | //! the browser session that displayed the code is signed in; nothing |
| 13 | //! secret ever leaves this machine. |
| 14 | //! |
| 15 | //! The HTTP client is `ureq`: synchronous (two requests need no runtime) |
| 16 | //! and rustls/ring, so the hosted root's musl static release cross-build |
| 17 | //! keeps working. |
| 18 | // @relation(roots.web-signin, scope=file) |
| 19 | |
| 20 | use std::path::PathBuf; |
| 21 | |
| 22 | use ents_web::auth; |
| 23 | |
| 24 | use crate::error::{Error, Result}; |
| 25 | use crate::root::LocalRoot; |
| 26 | use crate::sign::Signer; |
| 27 | |
| 28 | /// Run the sign-in: resolve the member key exactly as every mutation |
| 29 | /// command does when run inside a repository (`--key`, else |
| 30 | /// `user.signingkey`, else `~/.ssh/id_ed25519`), falling back to the |
| 31 | /// same non-repository chain when run from anywhere else — a login |
| 32 | /// targets a *hosted* root, so requiring a local clone would be |
| 33 | /// arbitrary. |
| 34 | /// |
| 35 | /// # Errors |
| 36 | /// |
| 37 | /// [`Error::NoSigningKey`]/[`Error::BadSigningKey`] resolving the key; |
| 38 | /// [`Error::NotFound`] for an unknown or expired code, a host mismatch |
| 39 | /// between `url` and the server's own answer, or a server that refuses |
| 40 | /// the signature (each with the server's own explanation). |
| 41 | // @relation(roots.web-signin, scope=function) |
| 42 | pub fn run( |
| 43 | url: &str, |
| 44 | code: &str, |
| 45 | key: Option<PathBuf>, |
| 46 | mut out: impl std::io::Write, |
| 47 | ) -> Result<()> { |
| 48 | let signer = resolve_signer(key)?; |
| 49 | let url = url.trim_end_matches('/'); |
| 50 | let host = host_of(url)?; |
| 51 | let code = auth::normalize_code(code); |
| 52 | |
| 53 | let agent = ureq::agent(); |
| 54 | let challenge = agent |
| 55 | .get(format!("{url}/login/challenge/{code}")) |
| 56 | .call() |
| 57 | .map_err(|source| http_error(url, &source))? |
| 58 | .body_mut() |
| 59 | .read_to_string() |
| 60 | .map_err(|source| http_error(url, &source))?; |
| 61 | let served_host = line_value(&challenge, "host"); |
| 62 | let nonce = line_value(&challenge, "nonce"); |
| 63 | let (Some(served_host), Some(nonce)) = (served_host, nonce) else { |
| 64 | return Err(Error::NotFound { |
| 65 | what: format!("a challenge in {url}'s answer — is this a git-ents hosted root?"), |
| 66 | }); |
| 67 | }; |
| 68 | // The typed URL is the trust anchor (`roots.web-signin`): a server |
| 69 | // answering for a different host gets nothing signed. |
| 70 | if served_host != host { |
| 71 | return Err(Error::NotFound { |
| 72 | what: format!( |
| 73 | "host agreement: you addressed {host}, the server answers for {served_host}" |
| 74 | ), |
| 75 | }); |
| 76 | } |
| 77 | |
| 78 | let payload = auth::challenge_payload(&host, &code, nonce); |
| 79 | let signature = signer.sign_in_namespace(auth::LOGIN_NAMESPACE, payload.as_bytes()); |
| 80 | let public_key = signer.public_openssh(); |
| 81 | let _ = writeln!(out, "proving membership to {url} as {public_key}"); |
| 82 | |
| 83 | let response = agent |
| 84 | .post(format!("{url}/login/challenge/{code}")) |
| 85 | .send_form([ |
| 86 | ("public_key", public_key.as_str()), |
| 87 | ("signature", signature.as_str()), |
| 88 | ]); |
| 89 | match response { |
| 90 | Ok(mut response) => { |
| 91 | let body = response.body_mut().read_to_string().unwrap_or_default(); |
| 92 | let member = line_value(&body, "member").unwrap_or("<unknown>"); |
| 93 | let _ = writeln!( |
| 94 | out, |
| 95 | "signed in: the browser session is now authenticated as {member}" |
| 96 | ); |
| 97 | Ok(()) |
| 98 | } |
| 99 | Err(ureq::Error::StatusCode(status)) => Err(Error::NotFound { |
| 100 | what: match status { |
| 101 | 401 => format!( |
| 102 | "membership: {url} refused the signature — is {public_key} enrolled and \ |
| 103 | active there?" |
| 104 | ), |
| 105 | 404 | 410 => format!( |
| 106 | "a live sign-in code: {code} is unknown, expired, or already used; reload \ |
| 107 | the sign-in page for a fresh one" |
| 108 | ), |
| 109 | other => format!("a sign-in answer from {url} (HTTP {other})"), |
| 110 | }, |
| 111 | }), |
| 112 | Err(source) => Err(http_error(url, &source)), |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | /// Resolve the signing key with [`crate::commands::signer`]'s chain when |
| 117 | /// inside a repository, else the same chain minus the repository config. |
| 118 | fn resolve_signer(key: Option<PathBuf>) -> Result<Signer> { |
| 119 | match LocalRoot::discover(".") { |
| 120 | Ok(root) => crate::commands::signer(&root, key), |
| 121 | Err(_not_a_repo) => { |
| 122 | if let Some(path) = key { |
| 123 | return Signer::load(&path); |
| 124 | } |
| 125 | let home = std::env::var_os("HOME").map(PathBuf::from); |
| 126 | let default = home |
| 127 | .map(|home| home.join(".ssh").join("id_ed25519")) |
| 128 | .filter(|path| path.exists()); |
| 129 | match default { |
| 130 | Some(path) => Signer::load(&path), |
| 131 | None => Err(Error::NoSigningKey), |
| 132 | } |
| 133 | } |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | /// The host (and non-default port) component of an `https://` or |
| 138 | /// `http://` base URL — the exact string bound into the signed payload. |
| 139 | fn host_of(url: &str) -> Result<String> { |
| 140 | let rest = url |
| 141 | .strip_prefix("https://") |
| 142 | .or_else(|| url.strip_prefix("http://")) |
| 143 | .ok_or_else(|| Error::NotFound { |
| 144 | what: format!("an http(s):// URL (got {url})"), |
| 145 | })?; |
| 146 | let host = rest.split('/').next().unwrap_or_default(); |
| 147 | if host.is_empty() { |
| 148 | return Err(Error::NotFound { |
| 149 | what: format!("a host in {url}"), |
| 150 | }); |
| 151 | } |
| 152 | Ok(host.to_owned()) |
| 153 | } |
| 154 | |
| 155 | /// The value of a `key=value` line in the server's plain-text answers. |
| 156 | fn line_value<'a>(body: &'a str, key: &str) -> Option<&'a str> { |
| 157 | body.lines() |
| 158 | .find_map(|line| line.strip_prefix(key)?.strip_prefix('=')) |
| 159 | } |
| 160 | |
| 161 | fn http_error(url: &str, source: &dyn std::fmt::Display) -> Error { |
| 162 | Error::NotFound { |
| 163 | what: format!("a reachable hosted root at {url}: {source}"), |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | #[cfg(test)] |
| 168 | mod tests { |
| 169 | #![allow(clippy::expect_used, clippy::unwrap_used, reason = "unit test")] |
| 170 | |
| 171 | use rstest::rstest; |
| 172 | |
| 173 | use super::*; |
| 174 | |
| 175 | #[rstest] |
| 176 | #[case::https("https://git.ents.cloud", "git.ents.cloud")] |
| 177 | #[case::trailing_path("https://git.ents.cloud/x", "git.ents.cloud")] |
| 178 | #[case::port("http://127.0.0.1:4880", "127.0.0.1:4880")] |
| 179 | fn host_of_extracts_the_authority(#[case] url: &str, #[case] expected: &str) { |
| 180 | assert_eq!(host_of(url).expect("parses"), expected); |
| 181 | } |
| 182 | |
| 183 | #[rstest] |
| 184 | fn host_of_refuses_a_bare_name() { |
| 185 | host_of("git.ents.cloud").unwrap_err(); |
| 186 | } |
| 187 | |
| 188 | #[rstest] |
| 189 | fn line_value_reads_the_servers_plain_answers() { |
| 190 | let body = "host=ents.test\ncode=ABCD2345\nnonce=00ff\n"; |
| 191 | assert_eq!(line_value(body, "host"), Some("ents.test")); |
| 192 | assert_eq!(line_value(body, "nonce"), Some("00ff")); |
| 193 | assert_eq!(line_value(body, "member"), None); |
| 194 | } |
| 195 | } |