mod hmac;
use std::fmt::{self, Write as _};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, PoisonError};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use anyhow::{Context as _, Result, bail};
use openmoat_hosts::Host;
use openmoat_proxy::constant_time_eq;
use zeroize::Zeroizing;
use crate::home::Home;
pub const KEY_FILE: &str = "proxy.key";
pub const TTL: Duration = Duration::from_hours(24);
const DOMAIN: &[u8] = b"openmoat-proxy-session-v1\n";
const VERSION: &str = "v1";
const MAX_SESSION_LEN: usize = 128;
const SKEW_S: u64 = 60;
pub struct Key(Zeroizing<[u8; 32]>);
impl fmt::Debug for Key {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("Key(..)")
}
}
impl Key {
pub fn load_or_create(home: &Home) -> Result<Self> {
let path = key_path(home);
if !path.exists() {
let mut raw = Zeroizing::new([0u8; 32]);
getrandom::fill(raw.as_mut_slice())
.map_err(|e| anyhow::Error::msg(e.to_string()))
.context("reading random bytes for the proxy session key")?;
let mut text = Zeroizing::new(String::with_capacity(65));
hex_into(&mut text, raw.as_slice());
text.push('\n');
crate::home::write_private_new(&path, text.as_bytes())?;
}
Self::load(&path)
}
pub fn load(path: &Path) -> Result<Self> {
let text = Zeroizing::new(
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?,
);
let text = text.trim();
let mut key = Zeroizing::new([0u8; 32]);
if text.len() != 64 || !text.bytes().all(|b| b.is_ascii_hexdigit()) {
bail!("{} is not 64 hex characters", path.display());
}
for (i, pair) in text.as_bytes().chunks(2).enumerate() {
let pair = std::str::from_utf8(pair).context("key is not ASCII")?;
key[i] = u8::from_str_radix(pair, 16).context("key is not hex")?;
}
Ok(Self(key))
}
fn sign(&self, user: &str) -> Zeroizing<String> {
let mut message = Vec::with_capacity(DOMAIN.len() + user.len());
message.extend_from_slice(DOMAIN);
message.extend_from_slice(user.as_bytes());
let mac = Zeroizing::new(hmac::sha256(self.0.as_slice(), &message));
let mut out = Zeroizing::new(String::with_capacity(64));
hex_into(&mut out, mac.as_slice());
out
}
}
pub fn key_path(home: &Home) -> PathBuf {
home.root().join(KEY_FILE)
}
pub struct Issued {
user: String,
password: Zeroizing<String>,
pub expires: u64,
}
impl fmt::Debug for Issued {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Issued")
.field("user", &self.user)
.finish_non_exhaustive()
}
}
impl Issued {
pub fn proxy_url(&self, port: u16) -> Zeroizing<String> {
Zeroizing::new(format!(
"http://{}:{}@127.0.0.1:{port}",
self.user,
self.password.as_str()
))
}
}
fn plain_session(session: &str) -> bool {
!session.is_empty()
&& session.len() <= MAX_SESSION_LEN
&& session
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
}
pub fn issue(key: &Key, host: Host, session: &str, now: u64) -> Result<Issued> {
if !plain_session(session) || openmoat_hosts::is_unknown_session(session) {
bail!(
"the session id {session:?} cannot carry a proxy credential: it must be 1 to 128 \
ASCII letters, digits, `.`, `_` or `-`"
);
}
let expires = now.saturating_add(TTL.as_secs());
let user = format!("{VERSION}.{}.{expires}.{session}", host.id());
let password = key.sign(&user);
Ok(Issued {
user,
password,
expires,
})
}
pub fn verify(key: &Key, user: &[u8], password: &[u8], now: u64) -> Option<String> {
let user = std::str::from_utf8(user).ok()?;
let mut parts = user.splitn(4, '.');
let (version, host, expires, session) =
(parts.next()?, parts.next()?, parts.next()?, parts.next()?);
if version != VERSION || host.parse::<Host>().is_err() || !plain_session(session) {
return None;
}
let expires: u64 = expires.parse().ok()?;
let expected = key.sign(user);
let signed = constant_time_eq(expected.as_bytes(), password);
let current = now < expires && expires <= now.saturating_add(TTL.as_secs() + SKEW_S);
(signed && current).then(|| session.to_owned())
}
pub fn now_s() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
type Stamp = (u64, Option<SystemTime>);
fn stamp(path: &Path) -> Option<Stamp> {
let meta = std::fs::metadata(path).ok()?;
Some((meta.len(), meta.modified().ok()))
}
pub const KEY_RECHECK: Duration = Duration::from_secs(1);
struct Loaded {
checked: Instant,
stamp: Option<Stamp>,
key: Option<Key>,
}
pub struct Verifier {
path: PathBuf,
recheck: Duration,
loaded: Mutex<Loaded>,
}
impl fmt::Debug for Verifier {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Verifier")
.field("path", &self.path)
.finish_non_exhaustive()
}
}
impl Verifier {
pub fn open(home: &Home) -> Result<Self> {
Self::open_with(home, KEY_RECHECK)
}
fn open_with(home: &Home, recheck: Duration) -> Result<Self> {
let key = Key::load_or_create(home)?;
let path = key_path(home);
Ok(Self {
loaded: Mutex::new(Loaded {
checked: Instant::now(),
stamp: stamp(&path),
key: Some(key),
}),
recheck,
path,
})
}
}
impl openmoat_proxy::Verify for Verifier {
fn verify(&self, user: &[u8], password: &[u8]) -> Option<String> {
let now = now_s();
let mut loaded = self.loaded.lock().unwrap_or_else(PoisonError::into_inner);
if loaded.checked.elapsed() >= self.recheck {
let current = stamp(&self.path);
if current != loaded.stamp {
loaded.key = current.and_then(|_| Key::load(&self.path).ok());
loaded.stamp = current;
}
loaded.checked = Instant::now();
}
verify(loaded.key.as_ref()?, user, password, now)
}
}
fn hex_into(out: &mut String, bytes: &[u8]) {
for byte in bytes {
let _ = write!(out, "{byte:02x}");
}
}
#[cfg(test)]
mod tests;