use std::{
fmt, fs,
path::{Path, PathBuf},
};
use anyhow::Context as _;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use ring::{
rand::{SecureRandom as _, SystemRandom},
signature::{self, Ed25519KeyPair, KeyPair as _, UnparsedPublicKey},
};
use secrecy::{ExposeSecret as _, SecretBox};
use serde::{Deserialize, Serialize};
use crate::{
base::{Constant, PermissionLevel, Res, SessionPath, Void},
protocol::ProtocolError,
};
const SEED_LEN: usize = 32;
const PUBLIC_KEY_LEN: usize = 32;
const SIGNATURE_LEN: usize = 64;
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum AuthError {
#[error("unknown machine key")]
UnknownKey,
#[error("machine key has been revoked")]
RevokedKey,
#[error("signature verification failed")]
BadSignature,
#[error("malformed key or signature: {0}")]
Malformed(String),
#[error("username `{0}` is already taken")]
UsernameTaken(String),
#[error("session handle `{0}` collides with a live session")]
HandleCollision(String),
}
impl From<AuthError> for ProtocolError {
fn from(err: AuthError) -> Self {
let message = err.to_string();
match err {
AuthError::Malformed(_) => Self::MalformedFrame(message),
_ => Self::Unauthorized(message),
}
}
}
pub struct Identity {
secret_seed: SecretBox<[u8; SEED_LEN]>,
public_key: [u8; PUBLIC_KEY_LEN],
}
impl Identity {
pub fn generate() -> Res<Self> {
let mut seed = [0_u8; SEED_LEN];
SystemRandom::new().fill(&mut seed).map_err(|_| anyhow::anyhow!("failed to gather entropy"))?;
Self::from_seed(seed)
}
pub fn from_seed(seed: [u8; SEED_LEN]) -> Res<Self> {
let key_pair = Ed25519KeyPair::from_seed_unchecked(&seed).map_err(|e| anyhow::anyhow!("invalid Ed25519 seed: {e}"))?;
let public_key = key_pair.public_key().as_ref().try_into().context("unexpected public key length")?;
Ok(Self {
secret_seed: SecretBox::new(Box::new(seed)),
public_key,
})
}
#[must_use]
pub fn public_key(&self) -> [u8; PUBLIC_KEY_LEN] {
self.public_key
}
#[must_use]
pub fn public_key_base64(&self) -> String {
encode_key(&self.public_key)
}
pub fn sign(&self, message: &[u8]) -> Res<[u8; SIGNATURE_LEN]> {
let key_pair = Ed25519KeyPair::from_seed_unchecked(self.secret_seed.expose_secret()).map_err(|e| anyhow::anyhow!("failed to load signing key: {e}"))?;
key_pair.sign(message).as_ref().try_into().context("unexpected signature length")
}
fn secret_seed_base64(&self) -> String {
encode_key(self.secret_seed.expose_secret())
}
}
impl fmt::Debug for Identity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Identity").field("public_key", &self.public_key_base64()).field("secret_seed", &"<redacted>").finish()
}
}
pub fn verify(public_key: &[u8], message: &[u8], signature: &[u8]) -> Result<(), AuthError> {
if public_key.len() != PUBLIC_KEY_LEN {
return Err(AuthError::Malformed("public key must be 32 bytes".to_owned()));
}
if signature.len() != SIGNATURE_LEN {
return Err(AuthError::Malformed("signature must be 64 bytes".to_owned()));
}
UnparsedPublicKey::new(&signature::ED25519, public_key).verify(message, signature).map_err(|_| AuthError::BadSignature)
}
pub fn generate_challenge() -> Res<[u8; Constant::CHALLENGE_SIZE]> {
let mut nonce = [0_u8; Constant::CHALLENGE_SIZE];
SystemRandom::new().fill(&mut nonce).map_err(|_| anyhow::anyhow!("failed to gather entropy"))?;
Ok(nonce)
}
pub fn generate_token() -> Res<String> {
let mut bytes = [0_u8; 24];
SystemRandom::new().fill(&mut bytes).map_err(|_| anyhow::anyhow!("failed to gather entropy"))?;
Ok(encode_key(&bytes))
}
#[must_use]
pub fn encode_key(bytes: &[u8]) -> String {
URL_SAFE_NO_PAD.encode(bytes)
}
pub fn decode_key(text: &str) -> Result<Vec<u8>, AuthError> {
URL_SAFE_NO_PAD.decode(text.trim()).map_err(|e| AuthError::Malformed(e.to_string()))
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerRegistration {
pub url: String,
pub username: String,
pub machine: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PermissionOverride {
pub server: String,
#[serde(default)]
pub channel: Option<String>,
pub level: PermissionLevel,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Scope {
Channel(String),
Whisper,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub default_permission: PermissionLevel,
#[serde(default)]
pub servers: Vec<ServerRegistration>,
#[serde(default)]
pub overrides: Vec<PermissionOverride>,
}
impl Config {
#[must_use]
pub fn resolve_permission(&self, server: &str, scope: &Scope) -> PermissionLevel {
let target_channel = match scope {
Scope::Channel(name) => Some(name.as_str()),
Scope::Whisper => None,
};
self.overrides
.iter()
.find(|o| o.server == server && o.channel.as_deref() == target_channel)
.map_or(self.default_permission, |o| o.level)
}
}
pub fn default_config_dir() -> Res<PathBuf> {
let base = dirs::config_dir().context("could not determine the OS configuration directory")?;
Ok(base.join(Constant::CONFIG_DIR_NAME))
}
pub fn save_identity(dir: &Path, identity: &Identity) -> Void {
fs::create_dir_all(dir).with_context(|| format!("failed to create keystore directory `{}`", dir.display()))?;
let key_file = dir.join("key");
fs::write(&key_file, identity.secret_seed_base64()).with_context(|| format!("failed to write keyfile `{}`", key_file.display()))?;
restrict_permissions(&key_file)?;
Ok(())
}
pub fn load_identity(dir: &Path) -> Res<Identity> {
let key_file = dir.join("key");
let contents = fs::read_to_string(&key_file).with_context(|| format!("failed to read keyfile `{}` (run `conclave key` first)", key_file.display()))?;
let seed_bytes = decode_key(&contents)?;
let seed: [u8; SEED_LEN] = seed_bytes.as_slice().try_into().context("keyfile does not contain a 32-byte seed")?;
Identity::from_seed(seed)
}
pub fn save_config(dir: &Path, config: &Config) -> Void {
fs::create_dir_all(dir).with_context(|| format!("failed to create config directory `{}`", dir.display()))?;
let path = dir.join("config.toml");
let text = toml::to_string_pretty(config).context("failed to serialize config")?;
fs::write(&path, text).with_context(|| format!("failed to write config `{}`", path.display()))?;
Ok(())
}
pub fn load_config(dir: &Path) -> Res<Config> {
let path = dir.join("config.toml");
if !path.exists() {
return Ok(Config::default());
}
let text = fs::read_to_string(&path).with_context(|| format!("failed to read config `{}`", path.display()))?;
toml::from_str(&text).with_context(|| format!("failed to parse config `{}`", path.display()))
}
#[cfg(unix)]
fn restrict_permissions(path: &Path) -> Void {
use std::os::unix::fs::PermissionsExt as _;
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).with_context(|| format!("failed to restrict permissions on `{}`", path.display()))
}
#[cfg(not(unix))]
fn restrict_permissions(_path: &Path) -> Void {
Ok(())
}
#[must_use]
pub fn default_handle(working_dir: &Path) -> String {
working_dir.file_name().map_or_else(|| "session".to_owned(), |name| name.to_string_lossy().into_owned())
}
#[must_use]
pub fn session_path(registration: &ServerRegistration, handle: &str) -> SessionPath {
SessionPath::new(registration.username.clone(), registration.machine.clone(), handle.to_owned())
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
#[test]
fn signs_and_verifies_a_server_challenge() {
let identity = Identity::generate().unwrap();
let challenge = generate_challenge().unwrap();
let signature = identity.sign(&challenge).unwrap();
verify(&identity.public_key(), &challenge, &signature).unwrap();
}
#[test]
fn verification_rejects_a_wrong_key() {
let signer = Identity::generate().unwrap();
let impostor = Identity::generate().unwrap();
let challenge = generate_challenge().unwrap();
let signature = signer.sign(&challenge).unwrap();
assert_eq!(verify(&impostor.public_key(), &challenge, &signature), Err(AuthError::BadSignature));
}
#[test]
fn verification_rejects_a_tampered_message_or_signature() {
let identity = Identity::generate().unwrap();
let challenge = generate_challenge().unwrap();
let mut signature = identity.sign(&challenge).unwrap();
let mut tampered_challenge = challenge;
tampered_challenge[0] ^= 0xFF;
assert_eq!(verify(&identity.public_key(), &tampered_challenge, &signature), Err(AuthError::BadSignature));
signature[0] ^= 0xFF;
assert_eq!(verify(&identity.public_key(), &challenge, &signature), Err(AuthError::BadSignature));
}
#[test]
fn verification_rejects_a_malformed_key() {
let identity = Identity::generate().unwrap();
let challenge = generate_challenge().unwrap();
let signature = identity.sign(&challenge).unwrap();
assert!(matches!(verify(&[0_u8; 8], &challenge, &signature), Err(AuthError::Malformed(_))));
}
#[test]
fn debug_never_reveals_the_secret_seed() {
let seed = [7_u8; SEED_LEN];
let identity = Identity::from_seed(seed).unwrap();
let rendered = format!("{identity:?}");
assert!(!rendered.contains(&encode_key(&seed)), "debug output leaked the secret seed: {rendered}");
assert!(rendered.contains("redacted"));
assert!(rendered.contains(&identity.public_key_base64()));
}
#[test]
fn challenges_are_random() {
assert_ne!(generate_challenge().unwrap(), generate_challenge().unwrap());
}
#[test]
fn invite_tokens_are_random_and_url_safe() {
let a = generate_token().unwrap();
let b = generate_token().unwrap();
assert_ne!(a, b);
assert!(!a.is_empty());
assert!(a.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_'), "token is not URL-safe: {a}");
}
#[test]
fn keystore_round_trips_the_identity() {
let dir = TempDir::new().unwrap();
let identity = Identity::generate().unwrap();
save_identity(dir.path(), &identity).unwrap();
let loaded = load_identity(dir.path()).unwrap();
assert_eq!(loaded.public_key(), identity.public_key());
let challenge = generate_challenge().unwrap();
verify(&loaded.public_key(), &challenge, &loaded.sign(&challenge).unwrap()).unwrap();
}
#[cfg(unix)]
#[test]
fn keyfile_is_owner_only() {
use std::os::unix::fs::PermissionsExt as _;
let dir = TempDir::new().unwrap();
save_identity(dir.path(), &Identity::generate().unwrap()).unwrap();
let mode = fs::metadata(dir.path().join("key")).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
#[test]
fn permission_resolution_prefers_the_most_specific_override() {
let config = Config {
default_permission: PermissionLevel::Notify,
servers: vec![],
overrides: vec![
PermissionOverride {
server: "s1".to_owned(),
channel: Some("ops".to_owned()),
level: PermissionLevel::Act,
},
PermissionOverride {
server: "s1".to_owned(),
channel: None,
level: PermissionLevel::Converse,
},
],
};
assert_eq!(config.resolve_permission("s1", &Scope::Channel("ops".to_owned())), PermissionLevel::Act);
assert_eq!(config.resolve_permission("s1", &Scope::Whisper), PermissionLevel::Converse);
assert_eq!(config.resolve_permission("s1", &Scope::Channel("other".to_owned())), PermissionLevel::Notify);
assert_eq!(config.resolve_permission("s2", &Scope::Channel("ops".to_owned())), PermissionLevel::Notify);
}
#[test]
fn config_round_trips_through_toml_with_lowercase_levels() {
let dir = TempDir::new().unwrap();
let config = Config {
default_permission: PermissionLevel::Notify,
servers: vec![ServerRegistration {
url: "wss://s1".to_owned(),
username: "aaron".to_owned(),
machine: "workstation".to_owned(),
}],
overrides: vec![PermissionOverride {
server: "wss://s1".to_owned(),
channel: Some("ops".to_owned()),
level: PermissionLevel::Act,
}],
};
save_config(dir.path(), &config).unwrap();
let text = fs::read_to_string(dir.path().join("config.toml")).unwrap();
assert!(text.contains("notify"), "levels should serialize lowercase: {text}");
assert!(text.contains("act"));
assert_eq!(load_config(dir.path()).unwrap(), config);
}
#[test]
fn load_config_defaults_when_absent() {
let dir = TempDir::new().unwrap();
assert_eq!(load_config(dir.path()).unwrap(), Config::default());
}
#[test]
fn auth_errors_map_onto_wire_protocol_errors() {
assert!(matches!(ProtocolError::from(AuthError::BadSignature), ProtocolError::Unauthorized(_)));
assert!(matches!(ProtocolError::from(AuthError::Malformed("x".to_owned())), ProtocolError::MalformedFrame(_)));
}
#[test]
fn default_handle_uses_the_final_path_component() {
assert_eq!(default_handle(Path::new("/home/aaron/projects/razel")), "razel");
}
}