git-ents.gitmain
⌘K
foforge
login.rs195 lines · 7.1 KB · rusthistorycomment 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
20use std::path::PathBuf;
21
22use ents_web::auth;
23
24use crate::error::{Error, Result};
25use crate::root::LocalRoot;
26use 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)
42pub 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.
118fn 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.
139fn 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.
156fn 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
161fn 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)]
168mod 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}