use std::collections::BTreeMap;
use std::path::PathBuf;
use secrecy::{ExposeSecret, SecretString};
use crate::core::auth::keyring::{PROXY_USERNAME_PREFIX, SERVICE_NAME};
use crate::core::auth::{CredentialStore, FileCredentialStore, KeyringCredentialStore};
use crate::core::error::{OlError, ERR_FILE_FALLBACK_ERROR};
pub const PROXY_CREDENTIALS_FILE: &str = "proxy-credentials.enc";
const MASK: &str = "*****";
fn default_port(scheme: &str) -> Option<u16> {
match scheme {
"http" => Some(80),
"https" => Some(443),
"socks5" | "socks5h" => Some(1080),
_ => None,
}
}
fn parts(url: &str) -> Option<(&str, Option<&str>, &str, &str)> {
let (scheme, rest) = url.split_once("://")?;
let (authority, path) = match rest.find('/') {
Some(i) => (&rest[..i], &rest[i..]),
None => (rest, ""),
};
match authority.rsplit_once('@') {
Some((userinfo, host)) => Some((scheme, Some(userinfo), host, path)),
None => Some((scheme, None, authority, path)),
}
}
fn split_host_port(authority: &str) -> (&str, Option<&str>) {
if let Some(rest) = authority.strip_prefix('[') {
if let Some((host, tail)) = rest.split_once(']') {
return (host, tail.strip_prefix(':'));
}
}
match authority.rsplit_once(':') {
Some((h, p)) => (h, Some(p)),
None => (authority, None),
}
}
pub fn authority_key(url: &str) -> Option<String> {
let (scheme, _userinfo, authority, _path) = parts(url)?;
if authority.is_empty() {
return None;
}
let (host, port) = split_host_port(authority);
if host.is_empty() {
return None;
}
let host = host.to_ascii_lowercase();
let port = port
.and_then(|p| p.parse::<u16>().ok())
.or_else(|| default_port(&scheme.to_ascii_lowercase()))?;
Some(format!("{host}:{port}"))
}
pub fn keyring_username(authority: &str) -> String {
format!("{PROXY_USERNAME_PREFIX}{authority}")
}
pub fn mask_userinfo(url: &str) -> String {
let Some((scheme, userinfo, host, path)) = parts(url) else {
return match url.rsplit_once('@') {
Some((userinfo, rest)) => mask_authority(userinfo, rest, ""),
None => url.to_string(),
};
};
let Some(userinfo) = userinfo else {
return url.to_string();
};
format!("{scheme}://{}", mask_authority(userinfo, host, path))
}
fn mask_authority(userinfo: &str, host: &str, path: &str) -> String {
match userinfo.split_once(':') {
Some((user, _password)) => format!("{user}:{MASK}@{host}{path}"),
None => format!("{userinfo}@{host}{path}"),
}
}
pub struct ProxyCredentialFile {
inner: FileCredentialStore,
}
impl ProxyCredentialFile {
pub fn new(path: PathBuf, agent_id: String) -> Self {
Self {
inner: FileCredentialStore::new(path, agent_id),
}
}
pub fn get(&self, authority: &str) -> Option<SecretString> {
self.read_map()
.ok()?
.get(authority)
.map(|p| SecretString::from(p.clone()))
}
pub fn set(&self, authority: &str, password: &SecretString) -> Result<(), OlError> {
let mut map = self.read_map().unwrap_or_default();
map.insert(authority.to_string(), password.expose_secret().to_string());
self.write_map(&map)
}
pub fn remove(&self, authority: &str) -> Result<(), OlError> {
let mut map = self.read_map().unwrap_or_default();
if map.remove(authority).is_none() {
return Ok(());
}
self.write_map(&map)
}
fn read_map(&self) -> Result<BTreeMap<String, String>, OlError> {
let raw = self.inner.retrieve()?;
serde_json::from_str(raw.expose_secret()).map_err(|e| {
OlError::new(
ERR_FILE_FALLBACK_ERROR,
format!("{PROXY_CREDENTIALS_FILE} is not a valid credential map: {e}"),
)
.with_suggestion(
"Delete the file and re-enter the proxy password with 'openlatch proxy set'.",
)
})
}
fn write_map(&self, map: &BTreeMap<String, String>) -> Result<(), OlError> {
let json = serde_json::to_string(map).map_err(|e| {
OlError::new(
ERR_FILE_FALLBACK_ERROR,
format!("could not serialize the proxy credential map: {e}"),
)
})?;
self.inner.store(SecretString::from(json))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PasswordSource {
Env,
Keychain,
File,
}
impl PasswordSource {
pub fn as_str(self) -> &'static str {
match self {
Self::Env => "env",
Self::Keychain => "keychain",
Self::File => "file",
}
}
}
pub async fn resolve_password(
authority: &str,
env: Option<&str>,
file: Option<&ProxyCredentialFile>,
) -> Option<(SecretString, PasswordSource)> {
if let Some(value) = env.filter(|v| !v.is_empty()) {
return Some((SecretString::from(value.to_string()), PasswordSource::Env));
}
let username = keyring_username(authority);
let store = KeyringCredentialStore::for_identity(SERVICE_NAME, &username);
if let Ok(secret) = store.retrieve_async().await {
return Some((secret, PasswordSource::Keychain));
}
file.and_then(|f| f.get(authority))
.map(|secret| (secret, PasswordSource::File))
}
pub fn credential_authority(url: &str) -> String {
authority_key(url).unwrap_or_default()
}
pub struct ProxyCredentialStore {
keyring_service: String,
file: ProxyCredentialFile,
}
impl ProxyCredentialStore {
pub fn new(ol_dir: &std::path::Path, agent_id: String) -> Self {
Self {
keyring_service: SERVICE_NAME.to_string(),
file: ProxyCredentialFile::new(ol_dir.join(PROXY_CREDENTIALS_FILE), agent_id),
}
}
fn keyring_for(&self, authority: &str) -> KeyringCredentialStore {
KeyringCredentialStore::for_identity(&self.keyring_service, &keyring_username(authority))
}
pub fn store(&self, authority: &str, password: &SecretString) -> Result<(), OlError> {
let keyring_ok = self
.keyring_for(authority)
.store(SecretString::from(password.expose_secret().to_string()))
.is_ok();
match self.file.set(authority, password) {
Ok(()) => Ok(()),
Err(e) if keyring_ok => {
tracing::debug!(
"proxy credential stored in the OS keychain; the file fallback declined: {e}"
);
Ok(())
}
Err(e) => Err(e),
}
}
pub fn retrieve(&self, authority: &str) -> Option<SecretString> {
if let Ok(secret) = self.keyring_for(authority).retrieve() {
return Some(secret);
}
self.file.get(authority)
}
pub fn clear(&self, authority: &str) {
let _ = self.keyring_for(authority).delete();
let _ = self.file.remove(authority);
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn the_authority_key_fills_in_the_scheme_default_port() {
assert_eq!(
authority_key("http://proxy.corp").as_deref(),
Some("proxy.corp:80")
);
assert_eq!(
authority_key("http://proxy.corp:80").as_deref(),
Some("proxy.corp:80")
);
assert_eq!(
authority_key("https://proxy.corp").as_deref(),
Some("proxy.corp:443")
);
assert_eq!(
authority_key("socks5://proxy.corp").as_deref(),
Some("proxy.corp:1080")
);
assert_eq!(
authority_key("socks5h://proxy.corp").as_deref(),
Some("proxy.corp:1080")
);
}
#[test]
fn the_authority_key_is_case_folded_on_the_host_only() {
assert_eq!(
authority_key("http://PROXY.Corp.Example:8080").as_deref(),
Some("proxy.corp.example:8080")
);
}
#[test]
fn the_authority_key_ignores_userinfo_and_path() {
assert_eq!(
authority_key("http://alice:hunter2@proxy.corp:8080/pac").as_deref(),
Some("proxy.corp:8080")
);
}
#[test]
fn an_ipv6_literal_keeps_its_port() {
assert_eq!(
authority_key("http://[::1]:3128").as_deref(),
Some("::1:3128")
);
}
#[test]
fn a_url_with_no_scheme_or_host_has_no_key() {
assert_eq!(authority_key("proxy.corp:8080"), None);
assert_eq!(authority_key("http://"), None);
}
#[test]
fn a_changed_authority_is_a_different_key() {
let old = authority_key("http://old-proxy.corp:8080").expect("key");
let new = authority_key("http://new-proxy.corp:8080").expect("key");
assert_ne!(old, new);
assert_ne!(keyring_username(&old), keyring_username(&new));
assert_ne!(keyring_username(&old), "api-key");
assert!(keyring_username(&old).starts_with("proxy:"));
}
#[test]
fn masking_keeps_the_username_and_drops_the_password() {
assert_eq!(
mask_userinfo("http://alice:hunter2@proxy.corp:8080"),
"http://alice:*****@proxy.corp:8080"
);
assert_eq!(
mask_userinfo("https://dom%5Calice:p%40ss@proxy.corp:8080/path"),
"https://dom%5Calice:*****@proxy.corp:8080/path"
);
}
#[test]
fn masking_leaves_a_url_without_a_password_alone() {
assert_eq!(
mask_userinfo("http://proxy.corp:8080"),
"http://proxy.corp:8080"
);
assert_eq!(
mask_userinfo("http://alice@proxy.corp:8080"),
"http://alice@proxy.corp:8080"
);
assert_eq!(mask_userinfo("not a url"), "not a url");
}
#[test]
fn masking_covers_a_value_with_no_scheme_at_all() {
assert_eq!(
mask_userinfo("alice:hunter2@proxy.corp:8080"),
"alice:*****@proxy.corp:8080"
);
assert_eq!(mask_userinfo("proxy.corp:8080"), "proxy.corp:8080");
}
#[test]
fn masking_handles_an_at_sign_inside_the_password() {
assert_eq!(
mask_userinfo("http://alice:p@ssw0rd@proxy.corp:8080"),
"http://alice:*****@proxy.corp:8080"
);
}
#[test]
fn the_file_store_round_trips_one_authority() {
let dir = tempdir().expect("tempdir");
let store = ProxyCredentialFile::new(
dir.path().join(PROXY_CREDENTIALS_FILE),
"agt_proxy_test".to_string(),
);
assert!(store.get("proxy.corp:8080").is_none());
store
.set(
"proxy.corp:8080",
&SecretString::from("hunter2".to_string()),
)
.expect("set");
let got = store.get("proxy.corp:8080").expect("stored password");
assert_eq!(got.expose_secret(), "hunter2");
}
#[test]
fn the_file_store_keeps_one_entry_per_authority() {
let dir = tempdir().expect("tempdir");
let store = ProxyCredentialFile::new(
dir.path().join(PROXY_CREDENTIALS_FILE),
"agt_proxy_test".to_string(),
);
store
.set("a.corp:8080", &SecretString::from("pass-a".to_string()))
.expect("set a");
store
.set("b.corp:8080", &SecretString::from("pass-b".to_string()))
.expect("set b");
assert_eq!(
store.get("a.corp:8080").expect("a").expose_secret(),
"pass-a"
);
assert_eq!(
store.get("b.corp:8080").expect("b").expose_secret(),
"pass-b"
);
assert!(store.get("c.corp:8080").is_none());
}
#[test]
fn the_file_store_removes_one_entry_without_disturbing_the_rest() {
let dir = tempdir().expect("tempdir");
let store = ProxyCredentialFile::new(
dir.path().join(PROXY_CREDENTIALS_FILE),
"agt_proxy_test".to_string(),
);
store
.set("a.corp:8080", &SecretString::from("pass-a".to_string()))
.expect("set a");
store
.set("b.corp:8080", &SecretString::from("pass-b".to_string()))
.expect("set b");
store.remove("a.corp:8080").expect("remove a");
store
.remove("missing.corp:8080")
.expect("remove is a no-op");
assert!(store.get("a.corp:8080").is_none());
assert_eq!(
store.get("b.corp:8080").expect("b").expose_secret(),
"pass-b"
);
}
#[test]
fn the_file_on_disk_does_not_contain_the_password() {
let dir = tempdir().expect("tempdir");
let path = dir.path().join(PROXY_CREDENTIALS_FILE);
let store = ProxyCredentialFile::new(path.clone(), "agt_proxy_test".to_string());
store
.set(
"proxy.corp:8080",
&SecretString::from("hunter2-plaintext".to_string()),
)
.expect("set");
let bytes = std::fs::read(&path).expect("read the credential file");
assert!(
!bytes
.windows(b"hunter2-plaintext".len())
.any(|w| w == b"hunter2-plaintext"),
"the proxy password must not appear in the file's bytes"
);
}
#[test]
fn a_file_written_under_a_different_agent_id_reads_as_absent() {
let dir = tempdir().expect("tempdir");
let path = dir.path().join(PROXY_CREDENTIALS_FILE);
ProxyCredentialFile::new(path.clone(), "agt_one".to_string())
.set(
"proxy.corp:8080",
&SecretString::from("hunter2".to_string()),
)
.expect("set");
let other = ProxyCredentialFile::new(path, "agt_two".to_string());
assert!(
other.get("proxy.corp:8080").is_none(),
"an undecryptable file is 'no password here', never a panic"
);
}
#[tokio::test]
async fn env_beats_every_store() {
let dir = tempdir().expect("tempdir");
let file = ProxyCredentialFile::new(
dir.path().join(PROXY_CREDENTIALS_FILE),
"agt_proxy_test".to_string(),
);
file.set(
"proxy.corp:8080",
&SecretString::from("file-password".to_string()),
)
.expect("set");
let (secret, source) =
resolve_password("proxy.corp:8080", Some("env-password"), Some(&file))
.await
.expect("resolved");
assert_eq!(secret.expose_secret(), "env-password");
assert_eq!(source, PasswordSource::Env);
}
#[tokio::test]
async fn the_file_tier_answers_when_the_keychain_does_not() {
let dir = tempdir().expect("tempdir");
let file = ProxyCredentialFile::new(
dir.path().join(PROXY_CREDENTIALS_FILE),
"agt_proxy_test".to_string(),
);
file.set(
"proxy.corp:8080",
&SecretString::from("file-password".to_string()),
)
.expect("set");
let (secret, source) = resolve_password("proxy.corp:8080", None, Some(&file))
.await
.expect("resolved");
assert_eq!(secret.expose_secret(), "file-password");
assert_eq!(source, PasswordSource::File);
}
#[tokio::test]
async fn no_password_anywhere_is_none_not_an_error() {
assert!(resolve_password("proxy.corp:8080", None, None)
.await
.is_none());
}
#[tokio::test]
async fn a_changed_authority_inherits_nothing() {
let dir = tempdir().expect("tempdir");
let file = ProxyCredentialFile::new(
dir.path().join(PROXY_CREDENTIALS_FILE),
"agt_proxy_test".to_string(),
);
file.set("old.corp:8080", &SecretString::from("old-pass".to_string()))
.expect("set");
assert!(resolve_password("new.corp:8080", None, Some(&file))
.await
.is_none());
}
}