use std::ffi::OsString;
use std::io;
use std::path::{Path, PathBuf};
use zeroize::Zeroizing;
use super::device_key::DeviceKey;
use super::preshared_key::PresharedKey;
use crate::config::name::ProfileName;
use crate::config::paths::ConfigDir;
use crate::config::private_fs;
pub const KEY_STORE_ENV: &str = "GITAWAY_KEY_STORE";
const KEYCHAIN_SERVICE: &str = "gitaway";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Backend {
Keychain,
File,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum KeyError {
#[error(
"{} couldn't {action} the device key: {reason}.\n\
To keep device keys in files instead, set {KEY_STORE_ENV}=file",
keychain_name()
)]
Keychain {
action: &'static str,
reason: String,
},
#[error("{}: {source}", path.display())]
Io {
path: PathBuf,
source: io::Error,
},
#[error(
"{} can be read by other users, so it isn't used. Restrict it with `chmod 600 {}`",
path.display(),
path.display()
)]
Unprotected {
path: PathBuf,
},
#[error("the device key for profile `{name}` in {location} is damaged")]
Damaged {
name: ProfileName,
location: String,
},
#[error("{KEY_STORE_ENV} must be `keychain` or `file`, not `{0}`")]
BadSetting(String),
#[error("{KEY_STORE_ENV}=keychain, but no keychain is available here")]
NoKeychain,
}
#[derive(Debug)]
pub struct DeviceSecrets {
pub key: DeviceKey,
pub preshared_key: Option<PresharedKey>,
}
impl From<DeviceKey> for DeviceSecrets {
fn from(key: DeviceKey) -> Self {
Self::new(key)
}
}
const PRESHARED_PREFIX: &str = "preshared=";
impl DeviceSecrets {
pub fn new(key: DeviceKey) -> Self {
Self {
key,
preshared_key: None,
}
}
pub(crate) fn duplicate(&self) -> Self {
Self {
key: self.key.duplicate(),
preshared_key: self.preshared_key.as_ref().map(PresharedKey::duplicate),
}
}
fn to_text(&self) -> Zeroizing<String> {
let mut text = Zeroizing::new(self.key.to_base64().as_str().to_owned());
if let Some(preshared) = &self.preshared_key {
text.push('\n');
text.push_str(PRESHARED_PREFIX);
text.push_str(&preshared.to_base64());
}
text
}
fn from_text(text: &str) -> Option<Self> {
let mut lines = text.lines().map(str::trim).filter(|line| !line.is_empty());
let key = DeviceKey::from_base64(lines.next()?).ok()?;
let preshared_key = match lines.next() {
None => None,
Some(line) => {
Some(PresharedKey::from_base64(line.strip_prefix(PRESHARED_PREFIX)?).ok()?)
}
};
if lines.next().is_some() {
return None;
}
Some(Self { key, preshared_key })
}
}
#[derive(Debug, Clone)]
pub struct KeyStore {
backend: Backend,
dir: ConfigDir,
fell_back: bool,
}
impl KeyStore {
pub fn from_env(dir: ConfigDir) -> Result<Self, KeyError> {
Self::open(dir, std::env::var_os(KEY_STORE_ENV))
}
pub fn open(dir: ConfigDir, setting: Option<OsString>) -> Result<Self, KeyError> {
let setting = setting.map(|s| s.to_string_lossy().into_owned());
let keychain_available = || keyring::Entry::store_status().is_ok();
let (backend, fell_back) = match setting.as_deref() {
None | Some("") if keychain_available() => (Backend::Keychain, false),
None | Some("") => (Backend::File, true),
Some("keychain") if keychain_available() => (Backend::Keychain, false),
Some("keychain") => return Err(KeyError::NoKeychain),
Some("file") => (Backend::File, false),
Some(other) => return Err(KeyError::BadSetting(other.to_owned())),
};
Ok(Self {
backend,
dir,
fell_back,
})
}
pub fn backend(&self) -> Backend {
self.backend
}
pub fn fell_back_to_file(&self) -> bool {
self.fell_back
}
pub fn location(&self, name: &ProfileName) -> String {
match self.backend {
Backend::Keychain => keychain_name().to_owned(),
Backend::File => self.dir.key_file(name).display().to_string(),
}
}
pub fn load(&self, name: &ProfileName) -> Result<Option<DeviceSecrets>, KeyError> {
if self.backend == Backend::Keychain
&& let Some(text) = keychain_get(name)?
{
return parse(&text, name, keychain_name()).map(Some);
}
let path = self.dir.key_file(name);
match read_key_file(&path)? {
Some(text) => parse(&text, name, &path.display().to_string()).map(Some),
None => Ok(None),
}
}
pub fn save(&self, name: &ProfileName, secrets: &DeviceSecrets) -> Result<(), KeyError> {
let text = secrets.to_text();
match self.backend {
Backend::Keychain => {
keychain_entry(name, "save")?
.set_password(&text)
.map_err(|e| keychain_error("save", e))?;
remove_key_file(&self.dir.key_file(name))?;
}
Backend::File => {
let path = self.dir.key_file(name);
let contents = Zeroizing::new(format!("{}\n", text.as_str()));
private_fs::write_atomic(&path, contents.as_bytes())
.map_err(|source| KeyError::Io { path, source })?;
}
}
Ok(())
}
pub fn delete(&self, name: &ProfileName) -> Result<bool, KeyError> {
let mut deleted = false;
if self.backend == Backend::Keychain {
match keychain_entry(name, "delete")?.delete_credential() {
Ok(()) => deleted = true,
Err(keyring::Error::NoEntry) => {}
Err(e) => return Err(keychain_error("delete", e)),
}
}
deleted |= remove_key_file(&self.dir.key_file(name))?;
Ok(deleted)
}
}
fn parse(text: &str, name: &ProfileName, location: &str) -> Result<DeviceSecrets, KeyError> {
DeviceSecrets::from_text(text).ok_or_else(|| KeyError::Damaged {
name: name.clone(),
location: location.to_owned(),
})
}
fn keychain_name() -> &'static str {
if cfg!(target_os = "macos") {
"the macOS Keychain"
} else if cfg!(windows) {
"the Windows Credential Manager"
} else {
"the Secret Service keyring"
}
}
fn keychain_entry(name: &ProfileName, action: &'static str) -> Result<keyring::Entry, KeyError> {
keyring::Entry::new(KEYCHAIN_SERVICE, name.as_str()).map_err(|e| keychain_error(action, e))
}
fn keychain_get(name: &ProfileName) -> Result<Option<Zeroizing<String>>, KeyError> {
match keychain_entry(name, "read")?.get_password() {
Ok(text) => Ok(Some(Zeroizing::new(text))),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(keychain_error("read", e)),
}
}
fn keychain_error(action: &'static str, error: keyring::Error) -> KeyError {
let reason = match error {
keyring::Error::NoStorageAccess(e) => format!("access was refused ({e})"),
keyring::Error::PlatformFailure(e) => e.to_string(),
keyring::Error::BadEncoding(_) | keyring::Error::BadDataFormat(..) => {
"the stored value isn't readable".to_owned()
}
keyring::Error::NoDefaultStore => "no keychain is available".to_owned(),
other => other.to_string(),
};
KeyError::Keychain { action, reason }
}
fn read_key_file(path: &Path) -> Result<Option<Zeroizing<String>>, KeyError> {
let io_err = |source| KeyError::Io {
path: path.to_owned(),
source,
};
let metadata = match std::fs::metadata(path) {
Ok(metadata) => metadata,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(io_err(e)),
};
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if metadata.permissions().mode() & 0o077 != 0 {
return Err(KeyError::Unprotected {
path: path.to_owned(),
});
}
}
#[cfg(not(unix))]
let _ = metadata;
std::fs::read_to_string(path)
.map(|text| Some(Zeroizing::new(text)))
.map_err(io_err)
}
fn remove_key_file(path: &Path) -> Result<bool, KeyError> {
match std::fs::remove_file(path) {
Ok(()) => Ok(true),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
Err(source) => Err(KeyError::Io {
path: path.to_owned(),
source,
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn file_store() -> (TempDir, KeyStore) {
let temp = TempDir::new().unwrap();
let dir = ConfigDir::new(Some(temp.path().into()), None).unwrap();
let store = KeyStore::open(dir, Some("file".into())).unwrap();
(temp, store)
}
fn name(s: &str) -> ProfileName {
s.parse().unwrap()
}
#[test]
fn file_keys_can_be_saved_loaded_replaced_and_deleted() {
let (_temp, store) = file_store();
let home = name("home");
assert!(store.load(&home).unwrap().is_none());
let first = DeviceKey::generate();
let first_public = first.public_key();
store.save(&home, &DeviceSecrets::new(first)).unwrap();
let loaded = store.load(&home).unwrap().unwrap();
assert_eq!(loaded.key.public_key(), first_public);
assert!(loaded.preshared_key.is_none());
let second = DeviceSecrets {
key: DeviceKey::generate(),
preshared_key: Some(PresharedKey::from_base64(PRESHARED).unwrap()),
};
store.save(&home, &second).unwrap();
let loaded = store.load(&home).unwrap().unwrap();
assert_eq!(loaded.key.public_key(), second.key.public_key());
assert_eq!(
loaded.preshared_key.unwrap().to_base64().as_str(),
PRESHARED
);
assert!(store.delete(&home).unwrap());
assert!(store.load(&home).unwrap().is_none());
assert!(!store.delete(&home).unwrap());
}
#[test]
fn keys_are_per_profile() {
let (_temp, store) = file_store();
let secrets = DeviceSecrets::new(DeviceKey::generate());
store.save(&name("home"), &secrets).unwrap();
assert!(store.load(&name("office")).unwrap().is_none());
}
#[cfg(unix)]
#[test]
fn key_files_are_private_and_readable_ones_are_refused() {
use std::os::unix::fs::PermissionsExt;
let (_temp, store) = file_store();
let home = name("home");
store
.save(&home, &DeviceSecrets::new(DeviceKey::generate()))
.unwrap();
let path = store.dir.key_file(&home);
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&path), 0o600);
assert_eq!(mode(path.parent().unwrap()), 0o700);
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
assert!(matches!(
store.load(&home),
Err(KeyError::Unprotected { .. })
));
}
const PRESHARED: &str = "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=";
#[test]
fn a_key_file_written_before_preshared_keys_still_reads() {
let (_temp, store) = file_store();
let home = name("home");
let key = DeviceKey::generate();
let text = format!("{}\n", key.to_base64().as_str());
private_fs::write_atomic(&store.dir.key_file(&home), text.as_bytes()).unwrap();
let loaded = store.load(&home).unwrap().unwrap();
assert_eq!(loaded.key.public_key(), key.public_key());
assert!(loaded.preshared_key.is_none());
}
#[test]
fn stored_text_with_a_bad_second_or_extra_line_is_damaged() {
let key = DeviceKey::generate().to_base64();
let key = key.as_str();
for text in [
format!("{key}\n{PRESHARED}"),
format!("{key}\npreshared=AAAA"),
format!("{key}\npreshared={PRESHARED}\nmore"),
] {
assert!(DeviceSecrets::from_text(&text).is_none(), "{text}");
}
let good = format!("{key}\npreshared={PRESHARED}\n");
assert!(
DeviceSecrets::from_text(&good)
.unwrap()
.preshared_key
.is_some()
);
}
#[test]
fn a_damaged_key_file_is_an_error_without_its_contents() {
let (_temp, store) = file_store();
let home = name("home");
let path = store.dir.key_file(&home);
private_fs::write_atomic(&path, b"secret-looking-junk").unwrap();
let message = store.load(&home).unwrap_err().to_string();
assert!(message.contains("damaged"), "{message}");
assert!(!message.contains("secret-looking-junk"), "{message}");
}
#[test]
fn the_setting_is_validated() {
let temp = TempDir::new().unwrap();
let dir = ConfigDir::new(Some(temp.path().into()), None).unwrap();
assert!(matches!(
KeyStore::open(dir.clone(), Some("vault".into())),
Err(KeyError::BadSetting(value)) if value == "vault"
));
let store = KeyStore::open(dir, Some("file".into())).unwrap();
assert_eq!(store.backend(), Backend::File);
assert!(!store.fell_back_to_file());
}
}