//! Per-user credentials for hubs: one sign-in serves every repository on a //! hub, and nothing secret is written into a checkout. //! //! The store is `credentials.toml` in the user's oxy directory (see //! [`crate::repo::default_home`]), one section per hub: //! //! ```text //! [https://oxygenhub.io] //! account = "andymccall" //! token = "oxyhub_…" //! ``` //! //! A hub is a scheme, host and port: a token is only ever sent to the hub //! it was stored for. The file is written readable by its owner only, and //! is ignored with a warning when anyone else can read it. With //! `auth.helper` set in the user's `config.toml`, tokens go to that helper //! instead (Git's credential-helper protocol, so `libsecret`, //! `osxkeychain` and `manager` keep them in the system keyring) and the //! file keeps only what is not secret. //! //! A token is sent over `https`, or over plain `http` to this machine only //! ([`token_allowed`]); hub transports drop it on any redirect. use crate::protocol::RemoteOptions; use std::collections::{BTreeMap, HashMap}; use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::{Mutex, OnceLock}; /// The user name oxy's tokens are stored under in a credential helper, so /// they stay apart from Git credentials for the same host. const HELPER_USER: &str = "oxy-token"; /// The hub of a URL: `scheme://host[:port]`, lower case, for `http` and /// `https` URLs only. pub fn hub_of(url: &str) -> Option { let (scheme, rest) = url.split_once("://")?; let scheme = scheme.to_ascii_lowercase(); if scheme != "http" && scheme != "https" { return None; } let host = rest.split(['/', '?', '#']).next()?; // Never key a hub on credentials embedded in the URL. let host = host.rsplit('@').next()?; if host.is_empty() { return None; } Some(format!("{scheme}://{}", host.to_ascii_lowercase())) } /// Whether a token may be sent to `url`: always over `https`, and over /// plain `http` only to this machine (a hub under test, or a runner's own). pub fn token_allowed(url: &str) -> bool { let Some(hub) = hub_of(url) else { return true; }; match hub.split_once("://") { Some(("https", _)) => true, Some((_, host)) => { let name = match host.strip_prefix('[') { Some(v6) => v6.split(']').next().unwrap_or(""), None => host.rsplit_once(':').map_or(host, |(h, _)| h), }; name == "localhost" || name == "::1" || name .parse::() .is_ok_and(|a| a.is_loopback()) } None => false, } } /// What the store holds for one hub. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct HubEntry { /// `scheme://host[:port]`. pub hub: String, /// The account the token signs in as, recorded at sign-in. pub account: Option, /// The token, when it is in the file (not with a helper). pub token: Option, /// Extra roots to trust for this hub (a self-signed hub). pub ca: Option, /// An agent's grant token for keyless signing (oxygenhub#241), when it /// is in the file (not with a helper). pub grant: Option, } /// Where a saved token went. #[derive(Clone, Debug, PartialEq, Eq)] pub enum Stored { /// The credentials file, owner-only. File(PathBuf), /// A credential helper, by its `auth.helper` value. Helper(String), } /// The credential store of one oxy home directory. #[derive(Clone, Debug)] pub struct Credentials { home: PathBuf, } impl Credentials { /// The store in `home` (an oxy home directory). pub fn at(home: impl Into) -> Credentials { Credentials { home: home.into() } } /// The store in the user's oxy home directory. pub fn user() -> Credentials { Credentials::at(crate::repo::default_home()) } /// `credentials.toml` in the home directory. pub fn path(&self) -> PathBuf { self.home.join("credentials.toml") } /// The configured credential helper (`auth.helper` in the user file). pub fn helper(&self) -> Option { crate::repo::read_config_file(&self.home.join("config.toml")) .ok()? .remove("auth.helper") .filter(|h| !h.trim().is_empty()) } /// Every hub in the file, tokens as stored there (none with a helper). pub fn entries(&self) -> Result, String> { Ok(self.read()?.into_values().collect()) } /// Whether the file can be read by anyone but its owner, in which case /// lookups ignore it. pub fn too_open(&self) -> bool { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; if let Ok(m) = std::fs::metadata(self.path()) { return m.permissions().mode() & 0o077 != 0; } } false } /// The entry for `hub`, its token fetched from the helper if one is set. pub fn get(&self, hub: &str) -> Result, String> { let entry = self.read()?.remove(hub); let helper = self.helper(); let mut entry = match (entry, &helper) { (Some(e), _) => e, // A helper may hold a token stored before the file was cleared. (None, Some(_)) => HubEntry { hub: hub.to_string(), ..HubEntry::default() }, (None, None) => return Ok(None), }; if entry.token.is_none() { if let Some(h) = &helper { entry.token = run_helper(h, "get", hub, None)?; } } if entry.token.is_none() && entry.account.is_none() && entry.ca.is_none() && entry.grant.is_none() { return Ok(None); } Ok(Some(entry)) } /// Save a token (and what is known about it) for `hub`. pub fn save( &self, hub: &str, token: &str, account: Option<&str>, ca: Option<&Path>, ) -> Result { let mut all = self.read()?; let helper = self.helper(); let entry = all.entry(hub.to_string()).or_insert_with(|| HubEntry { hub: hub.to_string(), ..HubEntry::default() }); entry.account = account.map(String::from).or(entry.account.take()); if let Some(ca) = ca { entry.ca = Some(ca.to_path_buf()); } let stored = match &helper { Some(h) => { run_helper(h, "store", hub, Some(token))?; entry.token = None; Stored::Helper(h.clone()) } None => { entry.token = Some(token.to_string()); Stored::File(self.path()) } }; self.write(&all)?; forget_cached(); Ok(stored) } /// Save an agent's grant token for `hub` (keyless signing, spec §14.7): /// in the file, or the helper under its own user name. pub fn save_grant(&self, hub: &str, token: &str) -> Result { let mut all = self.read()?; let entry = all.entry(hub.to_string()).or_insert_with(|| HubEntry { hub: hub.to_string(), ..HubEntry::default() }); let stored = match self.helper() { Some(h) => { run_helper_as(&h, "store", hub, GRANT_USER, Some(token))?; entry.grant = None; Stored::Helper(h) } None => { entry.grant = Some(token.to_string()); Stored::File(self.path()) } }; self.write(&all)?; Ok(stored) } /// The agent grant token kept for `hub`, if any. pub fn grant(&self, hub: &str) -> Result, String> { if let Some(g) = self.read()?.get(hub).and_then(|e| e.grant.clone()) { return Ok(Some(g)); } match self.helper() { Some(h) => run_helper_as(&h, "get", hub, GRANT_USER, None), None => Ok(None), } } /// Remove `hub` from the file and the helper; returns what it held, /// with the token, so the caller can revoke it. pub fn remove(&self, hub: &str) -> Result, String> { let found = self.get(hub)?; let mut all = self.read()?; if all.remove(hub).is_some() { self.write(&all)?; } if let Some(h) = self.helper() { run_helper(&h, "erase", hub, None)?; } forget_cached(); Ok(found) } fn read(&self) -> Result, String> { let path = self.path(); let text = match std::fs::read_to_string(&path) { Ok(t) => t, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()), Err(e) => return Err(format!("reading {}: {e}", path.display())), }; let mut out = BTreeMap::new(); let mut current: Option = None; for (n, line) in text.lines().enumerate() { let line = line.trim(); if line.is_empty() || line.starts_with('#') { continue; } if let Some(hub) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) { if let Some(e) = current.take() { out.insert(e.hub.clone(), e); } current = Some(HubEntry { hub: hub.trim().to_string(), ..HubEntry::default() }); continue; } let bad = || { format!( "{} line {}: expected key = \"value\"", path.display(), n + 1 ) }; let (k, v) = line.split_once('=').ok_or_else(bad)?; let v = unquote(v.trim()).ok_or_else(bad)?; let Some(e) = current.as_mut() else { return Err(format!( "{} line {}: a key before any [hub] section", path.display(), n + 1 )); }; match k.trim() { "account" => e.account = Some(v), "token" => e.token = Some(v), "ca" => e.ca = Some(PathBuf::from(v)), "grant" => e.grant = Some(v), // Unknown keys are kept out of lookups, not fatal. _ => {} } } if let Some(e) = current { out.insert(e.hub.clone(), e); } Ok(out) } fn write(&self, all: &BTreeMap) -> Result<(), String> { let mut text = String::from("# oxy credentials: a token for each hub. Keep this file private.\n"); for e in all.values() { text.push_str(&format!("\n[{}]\n", e.hub)); if let Some(a) = &e.account { text.push_str(&format!("account = {}\n", quote(a))); } if let Some(t) = &e.token { text.push_str(&format!("token = {}\n", quote(t))); } if let Some(c) = &e.ca { text.push_str(&format!("ca = {}\n", quote(&c.to_string_lossy()))); } if let Some(g) = &e.grant { text.push_str(&format!("grant = {}\n", quote(g))); } } let path = self.path(); std::fs::create_dir_all(&self.home) .map_err(|e| format!("creating {}: {e}", self.home.display()))?; crate::durable::write_atomic_private(&path, text.as_bytes()) .map_err(|e| format!("writing {}: {e}", path.display())) } /// The stored token and CA for `url`'s hub, if any: memoized, so a /// helper runs once per hub in a process. A file anyone else can read /// is ignored, with a warning. pub fn lookup(&self, url: &str) -> (Option, Option) { let Some(hub) = hub_of(url) else { return (None, None); }; let key = (self.home.clone(), hub.clone()); if let Some(hit) = cache().lock().unwrap().get(&key) { return hit.clone(); } let found = if self.too_open() { warn_once(&format!( "warning: {} can be read by other users, so oxy ignores it; run chmod 600 on it", self.path().display() )); (None, None) } else { match self.get(&hub) { Ok(Some(e)) => (e.token, e.ca), Ok(None) => (None, None), Err(e) => { warn_once(&format!("warning: credentials for {hub}: {e}")); (None, None) } } }; cache().lock().unwrap().insert(key, found.clone()); found } /// Options for reaching `url` without a configured remote: `$OXY_TOKEN` /// and `$OXY_CA`, else what this store holds for its hub. pub fn options_for(&self, url: &str) -> RemoteOptions { let env_token = std::env::var("OXY_TOKEN").ok().filter(|t| !t.is_empty()); let env_ca = std::env::var("OXY_CA").ok().map(PathBuf::from); let (token, ca) = match (&env_token, &env_ca) { (Some(_), Some(_)) => (None, None), _ => self.lookup(url), }; RemoteOptions { token: env_token.or(token), ca: env_ca.or(ca), ..RemoteOptions::default() } } } type Cache = Mutex, Option)>>; fn cache() -> &'static Cache { static CACHE: OnceLock = OnceLock::new(); CACHE.get_or_init(Default::default) } fn forget_cached() { cache().lock().unwrap().clear(); } pub(crate) fn warn_once(message: &str) { static SEEN: OnceLock>> = OnceLock::new(); if SEEN .get_or_init(Default::default) .lock() .unwrap() .insert(message.to_string()) { eprintln!("{message}"); } } fn quote(v: &str) -> String { let mut out = String::from('"'); for c in v.chars() { match c { '"' => out.push_str("\\\""), '\\' => out.push_str("\\\\"), '\n' => out.push_str("\\n"), c => out.push(c), } } out.push('"'); out } fn unquote(v: &str) -> Option { let inner = v.strip_prefix('"')?.strip_suffix('"')?; let mut out = String::new(); let mut chars = inner.chars(); while let Some(c) = chars.next() { if c == '\\' { match chars.next()? { 'n' => out.push('\n'), c => out.push(c), } } else { out.push(c); } } Some(out) } /// Run a credential helper (Git's protocol): `get`, `store` or `erase` for /// `hub`. `get` returns the password the helper answered with. /// /// `auth.helper` is a helper name (run as `git credential-NAME`), an /// absolute path, or a shell command after `!`, as in Git. fn run_helper( helper: &str, action: &str, hub: &str, token: Option<&str>, ) -> Result, String> { run_helper_as(helper, action, hub, HELPER_USER, token) } /// The helper's user name for an agent's grant token, beside the hub's /// own token. const GRANT_USER: &str = "oxy-agent-grant"; fn run_helper_as( helper: &str, action: &str, hub: &str, user: &str, token: Option<&str>, ) -> Result, String> { let (protocol, host) = hub .split_once("://") .ok_or_else(|| format!("{hub} is not a hub URL"))?; let mut cmd = if let Some(shell) = helper.strip_prefix('!') { let mut c = Command::new("sh"); c.arg("-c").arg(format!("{shell} {action}")); c } else if Path::new(helper).is_absolute() { let mut c = Command::new(helper); c.arg(action); c } else { let mut c = Command::new("git"); c.arg(format!("credential-{helper}")).arg(action); c }; let mut input = format!("protocol={protocol}\nhost={host}\nusername={user}\n"); if let Some(t) = token { input.push_str(&format!("password={t}\n")); } input.push('\n'); let mut child = cmd .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) .spawn() .map_err(|e| format!("running credential helper {helper:?}: {e}"))?; child .stdin .take() .expect("piped") .write_all(input.as_bytes()) .map_err(|e| format!("credential helper {helper:?}: {e}"))?; let out = child .wait_with_output() .map_err(|e| format!("credential helper {helper:?}: {e}"))?; if !out.status.success() { return Err(format!("credential helper {helper:?} {action} failed")); } if action != "get" { return Ok(None); } Ok(String::from_utf8_lossy(&out.stdout) .lines() .find_map(|l| l.strip_prefix("password=")) .map(String::from) .filter(|p| !p.is_empty())) } #[cfg(test)] mod tests { use super::*; /// oxygenhub#241: an agent's grant token is kept beside the hub's own /// token, owner-only, and read back without disturbing it. #[test] fn a_grant_token_is_kept_beside_the_hub_token() { let dir = tempfile::tempdir().unwrap(); let store = Credentials::at(dir.path()); let hub = "https://oxygenhub.io"; assert_eq!(store.grant(hub).unwrap(), None); store.save(hub, "oxyhub_t", Some("andy"), None).unwrap(); store.save_grant(hub, "oxyagent_g").unwrap(); assert_eq!(store.grant(hub).unwrap().as_deref(), Some("oxyagent_g")); let e = store.get(hub).unwrap().unwrap(); assert_eq!(e.token.as_deref(), Some("oxyhub_t")); assert_eq!(e.account.as_deref(), Some("andy")); // A grant alone is an entry too. store .save_grant("https://elsewhere.example", "oxyagent_h") .unwrap(); assert!(store.get("https://elsewhere.example").unwrap().is_some()); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mode = std::fs::metadata(store.path()) .unwrap() .permissions() .mode(); assert_eq!(mode & 0o077, 0, "owner only"); } } #[test] fn hubs_are_scheme_host_and_port() { assert_eq!( hub_of("https://OxygenHub.io/doomsdayone/metalgear").as_deref(), Some("https://oxygenhub.io") ); assert_eq!( hub_of("http://127.0.0.1:7300/a/b").as_deref(), Some("http://127.0.0.1:7300") ); assert_eq!( hub_of("https://user:pw@hub.example/x").as_deref(), Some("https://hub.example") ); assert_eq!(hub_of("/srv/repo"), None); assert_eq!(hub_of("file:///srv/repo"), None); } #[test] fn tokens_go_over_https_or_to_this_machine() { assert!(token_allowed("https://oxygenhub.io/a/b")); assert!(token_allowed("http://127.0.0.1:7300/a/b")); assert!(token_allowed("http://localhost/a")); assert!(token_allowed("http://[::1]:8080/a")); assert!(!token_allowed("http://oxygenhub.io/a/b")); assert!(!token_allowed("http://10.0.0.1/a")); assert!(!token_allowed("http://127.0.0.1.evil.example/a")); } #[test] fn save_get_and_remove_round_trip_privately() { let dir = tempfile::tempdir().unwrap(); let store = Credentials::at(dir.path()); assert_eq!(store.get("https://hub.example").unwrap(), None); let stored = store .save("https://hub.example", "tok\"en", Some("andy"), None) .unwrap(); assert_eq!(stored, Stored::File(store.path())); store .save( "http://127.0.0.1:7300", "local", None, Some(Path::new("/ca.pem")), ) .unwrap(); let e = store.get("https://hub.example").unwrap().unwrap(); assert_eq!(e.token.as_deref(), Some("tok\"en")); assert_eq!(e.account.as_deref(), Some("andy")); assert!(!store.too_open()); assert_eq!( store.lookup("https://hub.example/o/r"), (Some("tok\"en".into()), None) ); // Another host, scheme or port gets nothing. assert_eq!(store.lookup("https://other.example/o/r"), (None, None)); assert_eq!(store.lookup("http://hub.example/o/r"), (None, None)); assert_eq!(store.lookup("https://hub.example:8443/o/r"), (None, None)); let gone = store.remove("https://hub.example").unwrap().unwrap(); assert_eq!(gone.token.as_deref(), Some("tok\"en")); assert_eq!(store.lookup("https://hub.example/o/r"), (None, None)); assert_eq!(store.entries().unwrap().len(), 1); } #[cfg(unix)] #[test] fn a_file_others_can_read_is_ignored() { use std::os::unix::fs::PermissionsExt; let dir = tempfile::tempdir().unwrap(); let store = Credentials::at(dir.path()); store.save("https://open.example", "t", None, None).unwrap(); let mode = std::fs::metadata(store.path()) .unwrap() .permissions() .mode(); assert_eq!(mode & 0o777, 0o600); std::fs::set_permissions(store.path(), std::fs::Permissions::from_mode(0o644)).unwrap(); assert!(store.too_open()); assert_eq!(store.lookup("https://open.example/o/r"), (None, None)); } #[cfg(unix)] #[test] fn a_helper_keeps_the_token_out_of_the_file() { let dir = tempfile::tempdir().unwrap(); let vault = dir.path().join("vault"); // A helper that keeps one password in a file, as a keyring would. let helper = format!( "!f() {{ case $1 in get) [ -f {v} ] && echo password=$(cat {v});; store) sed -n 's/^password=//p' > {v};; erase) rm -f {v};; esac; }}; f", v = vault.display() ); std::fs::write( dir.path().join("config.toml"), format!("auth.helper = \"{helper}\"\n"), ) .unwrap(); let store = Credentials::at(dir.path()); assert!(store.helper().is_some()); let stored = store .save("https://hub.example", "secret", Some("andy"), None) .unwrap(); assert!(matches!(stored, Stored::Helper(_))); let text = std::fs::read_to_string(store.path()).unwrap(); assert!(!text.contains("secret"), "{text}"); assert_eq!( store .get("https://hub.example") .unwrap() .unwrap() .token .as_deref(), Some("secret") ); store.remove("https://hub.example").unwrap(); assert!(!vault.exists()); } }