use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use rcgen::{date_time_ymd, BasicConstraints, CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose, IsCa, Issuer, KeyPair, KeyUsagePurpose};
use rustls::pki_types::pem::PemObject;
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::error::{StreamError, StreamResult};
pub const CA_CERT_FILE: &str = "ca.pem";
pub const CA_KEY_FILE: &str = "ca.key.pem";
pub const CLIENTS_FILE: &str = "clients.json";
pub const CLIENT_YEARS: i32 = 2;
pub const CA_YEARS: i32 = 20;
pub const SERVER_YEARS: i32 = 1;
pub const MAX_NAME_CHARS: usize = 64;
pub const ANONYMOUS: &str = "anonymous";
const CA_NAME: &str = "dspk serve authority";
const SERVER_NAME: &str = "dspk serve";
const SECS_PER_YEAR: u64 = 31_556_952;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Permission {
Read,
Control,
}
impl std::fmt::Display for Permission {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Read => "read",
Self::Control => "control",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Peer {
pub name: String,
pub permission: Permission,
}
impl Peer {
pub fn anonymous() -> Self {
Self { name: ANONYMOUS.into(), permission: Permission::Read }
}
pub fn can_control(&self) -> bool {
self.permission == Permission::Control
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClientRecord {
pub name: String,
pub permission: Permission,
pub fingerprint: String,
pub issued_unix: u64,
pub expires_year: i32,
pub revoked_unix: Option<u64>,
}
impl ClientRecord {
pub fn active(&self) -> bool {
self.revoked_unix.is_none()
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct Registry {
clients: Vec<ClientRecord>,
}
pub struct Enrollment {
pub record: ClientRecord,
pub pem: String,
}
impl Enrollment {
pub fn write(&self, path: &Path) -> StreamResult<()> {
write_private(path, self.pem.as_bytes(), true)
}
}
#[derive(Clone)]
pub struct ClientIdentity {
pub certificate: Vec<u8>,
pub private_key: Vec<u8>,
pub authorities: Vec<Vec<u8>>,
}
impl std::fmt::Debug for ClientIdentity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ClientIdentity").field("fingerprint", &fingerprint(&self.certificate)).finish_non_exhaustive()
}
}
impl ClientIdentity {
pub fn from_file(path: &Path) -> StreamResult<Self> {
let bytes = std::fs::read(path)?;
Self::from_pem(&bytes)
}
pub fn from_pem(bytes: &[u8]) -> StreamResult<Self> {
let certs: Vec<Vec<u8>> = CertificateDer::pem_slice_iter(bytes).filter_map(Result::ok).map(|c| c.to_vec()).collect();
let (certificate, authorities) = certs.split_first().ok_or_else(|| StreamError::Tls("no certificate in the identity file".into()))?;
if authorities.is_empty() {
return Err(StreamError::Tls("no authority (CA) certificate in the identity file".into()));
}
let key = PrivateKeyDer::from_pem_slice(bytes).map_err(|e| StreamError::Tls(format!("no private key in the identity file: {e}")))?;
let PrivateKeyDer::Pkcs8(key) = key else { return Err(StreamError::Tls("the identity's key is not PKCS #8".into())) };
Ok(Self { certificate: certificate.clone(), private_key: key.secret_pkcs8_der().to_vec(), authorities: authorities.to_vec() })
}
}
pub struct Authority {
dir: PathBuf,
ca_pem: String,
ca_der: CertificateDer<'static>,
ca_key: KeyPair,
}
impl std::fmt::Debug for Authority {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Authority").field("dir", &self.dir).finish_non_exhaustive()
}
}
impl Authority {
pub fn open_or_create(dir: &Path) -> StreamResult<Self> {
std::fs::create_dir_all(dir)?;
restrict(dir, 0o700)?;
let (cert_path, key_path) = (dir.join(CA_CERT_FILE), dir.join(CA_KEY_FILE));
match (cert_path.exists(), key_path.exists()) {
(true, true) => {
let ca_pem = std::fs::read_to_string(&cert_path)?;
let ca_key = KeyPair::from_pem(&std::fs::read_to_string(&key_path)?).map_err(tls)?;
let ca_der = CertificateDer::from_pem_slice(ca_pem.as_bytes()).map_err(|e| StreamError::Tls(format!("{}: {e}", cert_path.display())))?.into_owned();
Ok(Self { dir: dir.to_path_buf(), ca_pem, ca_der, ca_key })
}
(false, false) => {
let ca_key = KeyPair::generate().map_err(tls)?;
let mut params = ca_params();
let year = this_year();
(params.not_before, params.not_after) = (date_time_ymd(year - 1, 1, 1), date_time_ymd(year + CA_YEARS, 1, 1));
let cert = params.self_signed(&ca_key).map_err(tls)?;
write_private(&key_path, ca_key.serialize_pem().as_bytes(), true)?;
let ca_pem = cert.pem();
std::fs::write(&cert_path, &ca_pem)?;
Ok(Self { dir: dir.to_path_buf(), ca_pem, ca_der: cert.der().clone(), ca_key })
}
_ => Err(StreamError::Tls(format!("{} holds only one of {CA_CERT_FILE} and {CA_KEY_FILE}: restore the other or remove both", dir.display()))),
}
}
pub fn dir(&self) -> &Path {
&self.dir
}
pub fn ca_certificate(&self) -> &[u8] {
&self.ca_der
}
fn issuer(&self) -> Issuer<'static, &KeyPair> {
Issuer::new(ca_params(), &self.ca_key)
}
pub fn server_certificate(&self, names: &[String]) -> StreamResult<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)> {
let key = KeyPair::generate().map_err(tls)?;
let mut params = CertificateParams::new(names.to_vec()).map_err(tls)?;
params.distinguished_name = common_name(SERVER_NAME);
params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth];
params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
let year = this_year();
(params.not_before, params.not_after) = (date_time_ymd(year - 1, 1, 1), date_time_ymd(year + SERVER_YEARS + 1, 1, 1));
let cert = params.signed_by(&key, &self.issuer()).map_err(tls)?;
let key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key.serialize_der()));
Ok((vec![cert.der().clone(), self.ca_der.clone()], key))
}
pub fn enroll(&self, name: &str, permission: Permission) -> StreamResult<Enrollment> {
check_name(name)?;
let mut registry = self.registry()?;
if registry.clients.iter().any(|c| c.name == name && c.active()) {
return Err(StreamError::Tls(format!("{name} is already enrolled: revoke it first to issue a new certificate")));
}
let key = KeyPair::generate().map_err(tls)?;
let mut params = CertificateParams::new(Vec::<String>::new()).map_err(tls)?;
params.distinguished_name = common_name(name);
params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ClientAuth];
params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
let year = this_year();
let expires_year = year + CLIENT_YEARS + 1;
(params.not_before, params.not_after) = (date_time_ymd(year - 1, 1, 1), date_time_ymd(expires_year, 1, 1));
let cert = params.signed_by(&key, &self.issuer()).map_err(tls)?;
let record = ClientRecord { name: name.into(), permission, fingerprint: fingerprint(cert.der()), issued_unix: now_unix(), expires_year, revoked_unix: None };
registry.clients.push(record.clone());
self.save(®istry)?;
let pem = format!("{}{}{}", cert.pem(), key.serialize_pem(), self.ca_pem);
Ok(Enrollment { record, pem })
}
pub fn revoke(&self, name: &str) -> StreamResult<ClientRecord> {
let mut registry = self.registry()?;
let Some(record) = registry.clients.iter_mut().find(|c| c.name == name && c.active()) else {
return Err(StreamError::Tls(format!("no active client named {name}")));
};
record.revoked_unix = Some(now_unix());
let revoked = record.clone();
self.save(®istry)?;
Ok(revoked)
}
pub fn clients(&self) -> StreamResult<Vec<ClientRecord>> {
Ok(self.registry()?.clients)
}
pub fn authorize(&self, certificate: &[u8]) -> Option<Peer> {
let fp = fingerprint(certificate);
let registry = self.registry().map_err(|e| tracing::warn!("reading the client registry: {e}")).ok()?;
registry.clients.into_iter().find(|c| c.fingerprint == fp && c.active()).map(|c| Peer { name: c.name, permission: c.permission })
}
fn registry(&self) -> StreamResult<Registry> {
match std::fs::read(self.dir.join(CLIENTS_FILE)) {
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| StreamError::Tls(format!("{CLIENTS_FILE}: {e}"))),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Registry::default()),
Err(e) => Err(e.into()),
}
}
fn save(&self, registry: &Registry) -> StreamResult<()> {
let json = serde_json::to_vec_pretty(registry).map_err(|e| StreamError::Tls(e.to_string()))?;
let tmp = self.dir.join(format!("{CLIENTS_FILE}.tmp"));
let _ = std::fs::remove_file(&tmp);
write_private(&tmp, &json, true)?;
std::fs::rename(&tmp, self.dir.join(CLIENTS_FILE))?;
Ok(())
}
}
fn ca_params() -> CertificateParams {
let mut p = CertificateParams::default();
p.distinguished_name = common_name(CA_NAME);
p.is_ca = IsCa::Ca(BasicConstraints::Constrained(0));
p.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign, KeyUsagePurpose::DigitalSignature];
p
}
fn common_name(name: &str) -> DistinguishedName {
let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, name);
dn
}
fn check_name(name: &str) -> StreamResult<()> {
let ok = !name.is_empty() && name.len() <= MAX_NAME_CHARS && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) && !name.starts_with('.');
if ok { Ok(()) } else { Err(StreamError::Tls(format!("client name {name:?}: use 1–{MAX_NAME_CHARS} letters, digits, '.', '_' or '-', not starting with '.'"))) }
}
pub fn fingerprint(der: &[u8]) -> String {
Sha256::digest(der).iter().map(|b| format!("{b:02x}")).collect()
}
fn now_unix() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())
}
fn this_year() -> i32 {
1970 + (now_unix() / SECS_PER_YEAR) as i32
}
fn tls(e: impl std::fmt::Display) -> StreamError {
StreamError::Tls(e.to_string())
}
fn write_private(path: &Path, bytes: &[u8], create_new: bool) -> StreamResult<()> {
let mut options = std::fs::OpenOptions::new();
options.write(true);
if create_new {
options.create_new(true);
} else {
options.create(true).truncate(true);
}
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
let mut file = options.open(path).map_err(|e| std::io::Error::new(e.kind(), format!("{}: {e}", path.display())))?;
file.write_all(bytes)?;
file.sync_all()?;
Ok(())
}
fn restrict(path: &Path, mode: u32) -> StreamResult<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))?;
}
#[cfg(not(unix))]
let _ = (path, mode);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("dspk-stream-auth-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
dir
}
#[test]
fn enroll_authorize_revoke() {
let dir = scratch("cycle");
let auth = Authority::open_or_create(&dir).unwrap();
let alice = auth.enroll("alice", Permission::Control).unwrap();
let id = ClientIdentity::from_pem(alice.pem.as_bytes()).unwrap();
assert_eq!(id.authorities, vec![auth.ca_certificate().to_vec()]);
assert_eq!(auth.authorize(&id.certificate), Some(Peer { name: "alice".into(), permission: Permission::Control }));
assert!(auth.enroll("alice", Permission::Read).is_err(), "an active name is not issued twice");
let again = Authority::open_or_create(&dir).unwrap();
assert_eq!(again.ca_certificate(), auth.ca_certificate());
assert!(again.authorize(&id.certificate).is_some());
auth.revoke("alice").unwrap();
assert_eq!(again.authorize(&id.certificate), None, "revoked: refused, read fresh");
assert!(auth.enroll("alice", Permission::Read).is_ok(), "a revoked name can be enrolled again");
assert_eq!(auth.clients().unwrap().len(), 2);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn names_files_and_permissions() {
for bad in ["", ".hidden", "a/b", "../x", "a b", &"x".repeat(MAX_NAME_CHARS + 1)] {
assert!(check_name(bad).is_err(), "{bad:?}");
}
assert!(check_name("lab-pc_2.local").is_ok());
let dir = scratch("files");
let auth = Authority::open_or_create(&dir).unwrap();
let e = auth.enroll("bob", Permission::Read).unwrap();
let out = dir.join("bob.pem");
e.write(&out).unwrap();
assert!(e.write(&out).is_err(), "never overwritten");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&dir), 0o700);
assert_eq!(mode(&dir.join(CA_KEY_FILE)), 0o600);
assert_eq!(mode(&out), 0o600);
assert_eq!(mode(&dir.join(CLIENTS_FILE)), 0o600);
}
std::fs::remove_file(dir.join(CA_KEY_FILE)).unwrap();
assert!(Authority::open_or_create(&dir).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
}