192 lines
6.4 KiB
Rust
192 lines
6.4 KiB
Rust
//! Native persistence boundary for the Linux client.
|
|
//!
|
|
//! Settings are deliberately limited to non-secret connection metadata. The
|
|
//! session credential is stored as a `user` key in the Linux kernel keyring
|
|
//! through the `keyctl` utility. If keyring access fails, callers receive an
|
|
//! error and must ask for credentials again; no plaintext fallback is allowed.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest, Sha256};
|
|
use std::{
|
|
fs, io,
|
|
io::Write,
|
|
path::{Path, PathBuf},
|
|
process::{Command, Stdio},
|
|
};
|
|
|
|
pub const KEYRING_SERVICE: &str = "trixie.server.session";
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct Settings {
|
|
pub server_address: String,
|
|
pub username: String,
|
|
}
|
|
|
|
impl Settings {
|
|
pub fn load(path: impl AsRef<Path>) -> io::Result<Self> {
|
|
let bytes = fs::read(path)?;
|
|
serde_json::from_slice(&bytes)
|
|
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
|
|
}
|
|
|
|
pub fn save(&self, path: impl AsRef<Path>) -> io::Result<()> {
|
|
let path = path.as_ref();
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent)?;
|
|
}
|
|
let bytes = serde_json::to_vec_pretty(self)
|
|
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
|
|
let temporary = path.with_extension("json.tmp");
|
|
fs::write(&temporary, bytes)?;
|
|
fs::rename(temporary, path)
|
|
}
|
|
}
|
|
|
|
pub fn default_settings_path() -> PathBuf {
|
|
let base = std::env::var_os("XDG_CONFIG_HOME")
|
|
.map(PathBuf::from)
|
|
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
|
|
.unwrap_or_else(|| PathBuf::from("."));
|
|
base.join("trixie").join("settings.json")
|
|
}
|
|
|
|
fn key_description(settings: &Settings) -> String {
|
|
let mut digest = Sha256::new();
|
|
digest.update(settings.server_address.as_bytes());
|
|
digest.update([0]);
|
|
digest.update(settings.username.as_bytes());
|
|
let digest = digest.finalize();
|
|
format!("{KEYRING_SERVICE}:{}", hex_encode(&digest))
|
|
}
|
|
|
|
fn hex_encode(bytes: &[u8]) -> String {
|
|
const HEX: &[u8; 16] = b"0123456789abcdef";
|
|
let mut result = String::with_capacity(bytes.len() * 2);
|
|
for byte in bytes {
|
|
result.push(HEX[(byte >> 4) as usize] as char);
|
|
result.push(HEX[(byte & 0x0f) as usize] as char);
|
|
}
|
|
result
|
|
}
|
|
|
|
fn keyctl(args: &[&str]) -> io::Result<Vec<u8>> {
|
|
let output = Command::new("keyctl").args(args).output()?;
|
|
if !output.status.success() {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::PermissionDenied,
|
|
"Linux kernel keyring access failed",
|
|
));
|
|
}
|
|
Ok(output.stdout)
|
|
}
|
|
|
|
pub fn store_session(settings: &Settings, session: &str) -> io::Result<()> {
|
|
if session.is_empty() {
|
|
return Err(io::Error::new(io::ErrorKind::InvalidInput, "empty session"));
|
|
}
|
|
let description = key_description(settings);
|
|
write_key_payload(key_write_command(&description), session.as_bytes())
|
|
}
|
|
|
|
// Secret payloads must never be arguments: process listings can expose argv.
|
|
fn key_write_command(description: &str) -> Command {
|
|
let mut command = Command::new("keyctl");
|
|
command.args(["padd", "user", description, "@s"]);
|
|
command
|
|
}
|
|
|
|
fn write_key_payload(mut command: Command, payload: &[u8]) -> io::Result<()> {
|
|
let mut child = command
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.spawn()?;
|
|
let result = child.stdin.take().expect("piped stdin").write_all(payload);
|
|
// Closing stdin above signals EOF. Reap the child even if writing failed.
|
|
let status = child.wait()?;
|
|
result?;
|
|
if status.success() {
|
|
Ok(())
|
|
} else {
|
|
Err(io::Error::new(
|
|
io::ErrorKind::PermissionDenied,
|
|
"Linux kernel keyring access failed",
|
|
))
|
|
}
|
|
}
|
|
|
|
pub fn load_session(settings: &Settings) -> io::Result<Option<String>> {
|
|
let description = key_description(settings);
|
|
let id = match keyctl(&["search", "@s", "user", &description]) {
|
|
Ok(value) => String::from_utf8_lossy(&value).trim().to_string(),
|
|
Err(_) => return Ok(None),
|
|
};
|
|
if id.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
let value = keyctl(&["pipe", &id])?;
|
|
String::from_utf8(value)
|
|
.map(Some)
|
|
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
|
|
}
|
|
|
|
pub fn clear_session(settings: &Settings) -> io::Result<()> {
|
|
let description = key_description(settings);
|
|
let id = String::from_utf8_lossy(&keyctl(&["search", "@s", "user", &description])?)
|
|
.trim()
|
|
.to_string();
|
|
if id.is_empty() {
|
|
return Ok(());
|
|
}
|
|
keyctl(&["unlink", &id, "@s"]).map(|_| ())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn key_write_arguments_contain_only_metadata() {
|
|
let command = key_write_command("test-description");
|
|
let args: Vec<_> = command.get_args().collect();
|
|
assert_eq!(args, ["padd", "user", "test-description", "@s"]);
|
|
}
|
|
|
|
#[test]
|
|
fn payload_is_delivered_via_stdin_and_failures_are_reported() {
|
|
let mut receiver = Command::new("sh");
|
|
receiver.args(["-c", "test \"$(cat)\" = fixture-payload"]);
|
|
write_key_payload(receiver, b"fixture-payload").unwrap();
|
|
let mut failure = Command::new("sh");
|
|
failure.args(["-c", "cat >/dev/null; exit 1"]);
|
|
assert!(write_key_payload(failure, b"fixture-payload").is_err());
|
|
}
|
|
|
|
fn settings() -> Settings {
|
|
Settings {
|
|
server_address: "https://trixie.example".into(),
|
|
username: "admin".into(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn settings_round_trip_contains_no_password_field() {
|
|
let directory = tempfile::tempdir().expect("temporary directory");
|
|
let path = directory.path().join("settings.json");
|
|
settings().save(&path).expect("save settings");
|
|
let text = fs::read_to_string(&path).expect("read settings");
|
|
assert!(!text.contains("password"));
|
|
assert_eq!(Settings::load(path).expect("load settings"), settings());
|
|
}
|
|
|
|
#[test]
|
|
fn key_description_is_stable_but_scoped_to_connection_and_user() {
|
|
let first = key_description(&settings());
|
|
assert_eq!(first, key_description(&settings()));
|
|
let mut different = settings();
|
|
different.username = "moderator".into();
|
|
assert_ne!(first, key_description(&different));
|
|
assert!(first.starts_with(KEYRING_SERVICE));
|
|
}
|
|
}
|