1use std::{
14 fmt, fs,
15 path::{Path, PathBuf},
16};
17
18use anyhow::Context as _;
19use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
20use ring::{
21 rand::{SecureRandom as _, SystemRandom},
22 signature::{self, Ed25519KeyPair, KeyPair as _, UnparsedPublicKey},
23};
24use secrecy::{ExposeSecret as _, SecretBox};
25use serde::{Deserialize, Serialize};
26
27use crate::{
28 base::{Constant, PermissionLevel, Res, SessionPath, Void},
29 protocol::ProtocolError,
30};
31
32const SEED_LEN: usize = 32;
34const PUBLIC_KEY_LEN: usize = 32;
35const SIGNATURE_LEN: usize = 64;
36
37#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
39pub enum AuthError {
40 #[error("unknown machine key")]
42 UnknownKey,
43 #[error("machine key has been revoked")]
45 RevokedKey,
46 #[error("signature verification failed")]
48 BadSignature,
49 #[error("malformed key or signature: {0}")]
51 Malformed(String),
52 #[error("username `{0}` is already taken")]
54 UsernameTaken(String),
55 #[error("session handle `{0}` collides with a live session")]
57 HandleCollision(String),
58}
59
60impl From<AuthError> for ProtocolError {
61 fn from(err: AuthError) -> Self {
62 let message = err.to_string();
63 match err {
64 AuthError::Malformed(_) => Self::MalformedFrame(message),
65 _ => Self::Unauthorized(message),
66 }
67 }
68}
69
70pub struct Identity {
75 secret_seed: SecretBox<[u8; SEED_LEN]>,
76 public_key: [u8; PUBLIC_KEY_LEN],
77}
78
79impl Identity {
80 pub fn generate() -> Res<Self> {
86 let mut seed = [0_u8; SEED_LEN];
87 SystemRandom::new().fill(&mut seed).map_err(|_| anyhow::anyhow!("failed to gather entropy"))?;
88 Self::from_seed(seed)
89 }
90
91 pub fn from_seed(seed: [u8; SEED_LEN]) -> Res<Self> {
97 let key_pair = Ed25519KeyPair::from_seed_unchecked(&seed).map_err(|e| anyhow::anyhow!("invalid Ed25519 seed: {e}"))?;
98 let public_key = key_pair.public_key().as_ref().try_into().context("unexpected public key length")?;
99
100 Ok(Self {
101 secret_seed: SecretBox::new(Box::new(seed)),
102 public_key,
103 })
104 }
105
106 #[must_use]
108 pub fn public_key(&self) -> [u8; PUBLIC_KEY_LEN] {
109 self.public_key
110 }
111
112 #[must_use]
114 pub fn public_key_base64(&self) -> String {
115 encode_key(&self.public_key)
116 }
117
118 pub fn sign(&self, message: &[u8]) -> Res<[u8; SIGNATURE_LEN]> {
124 let key_pair = Ed25519KeyPair::from_seed_unchecked(self.secret_seed.expose_secret()).map_err(|e| anyhow::anyhow!("failed to load signing key: {e}"))?;
126 key_pair.sign(message).as_ref().try_into().context("unexpected signature length")
127 }
128
129 fn secret_seed_base64(&self) -> String {
131 encode_key(self.secret_seed.expose_secret())
132 }
133}
134
135impl fmt::Debug for Identity {
136 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137 f.debug_struct("Identity").field("public_key", &self.public_key_base64()).field("secret_seed", &"<redacted>").finish()
138 }
139}
140
141pub fn verify(public_key: &[u8], message: &[u8], signature: &[u8]) -> Result<(), AuthError> {
148 if public_key.len() != PUBLIC_KEY_LEN {
149 return Err(AuthError::Malformed("public key must be 32 bytes".to_owned()));
150 }
151 if signature.len() != SIGNATURE_LEN {
152 return Err(AuthError::Malformed("signature must be 64 bytes".to_owned()));
153 }
154
155 UnparsedPublicKey::new(&signature::ED25519, public_key).verify(message, signature).map_err(|_| AuthError::BadSignature)
156}
157
158pub fn generate_challenge() -> Res<[u8; Constant::CHALLENGE_SIZE]> {
164 let mut nonce = [0_u8; Constant::CHALLENGE_SIZE];
165 SystemRandom::new().fill(&mut nonce).map_err(|_| anyhow::anyhow!("failed to gather entropy"))?;
166 Ok(nonce)
167}
168
169pub fn generate_token() -> Res<String> {
175 let mut bytes = [0_u8; 24];
176 SystemRandom::new().fill(&mut bytes).map_err(|_| anyhow::anyhow!("failed to gather entropy"))?;
177 Ok(encode_key(&bytes))
178}
179
180#[must_use]
183pub fn encode_key(bytes: &[u8]) -> String {
184 URL_SAFE_NO_PAD.encode(bytes)
185}
186
187pub fn decode_key(text: &str) -> Result<Vec<u8>, AuthError> {
193 URL_SAFE_NO_PAD.decode(text.trim()).map_err(|e| AuthError::Malformed(e.to_string()))
194}
195
196#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
202pub struct ServerRegistration {
203 pub url: String,
205 pub username: String,
207 pub machine: String,
209}
210
211#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
213pub struct PermissionOverride {
214 pub server: String,
216 #[serde(default)]
218 pub channel: Option<String>,
219 pub level: PermissionLevel,
221}
222
223#[derive(Clone, Debug, PartialEq, Eq)]
225pub enum Scope {
226 Channel(String),
228 Whisper,
230}
231
232#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
234pub struct Config {
235 #[serde(default)]
237 pub default_permission: PermissionLevel,
238 #[serde(default)]
240 pub servers: Vec<ServerRegistration>,
241 #[serde(default)]
243 pub overrides: Vec<PermissionOverride>,
244}
245
246impl Config {
247 #[must_use]
249 pub fn resolve_permission(&self, server: &str, scope: &Scope) -> PermissionLevel {
250 let target_channel = match scope {
251 Scope::Channel(name) => Some(name.as_str()),
252 Scope::Whisper => None,
253 };
254
255 self.overrides
256 .iter()
257 .find(|o| o.server == server && o.channel.as_deref() == target_channel)
258 .map_or(self.default_permission, |o| o.level)
259 }
260}
261
262pub fn default_config_dir() -> Res<PathBuf> {
268 let base = dirs::config_dir().context("could not determine the OS configuration directory")?;
269 Ok(base.join(Constant::CONFIG_DIR_NAME))
270}
271
272pub fn save_identity(dir: &Path, identity: &Identity) -> Void {
278 fs::create_dir_all(dir).with_context(|| format!("failed to create keystore directory `{}`", dir.display()))?;
279
280 let key_file = dir.join("key");
281 fs::write(&key_file, identity.secret_seed_base64()).with_context(|| format!("failed to write keyfile `{}`", key_file.display()))?;
282 restrict_permissions(&key_file)?;
283
284 Ok(())
285}
286
287pub fn load_identity(dir: &Path) -> Res<Identity> {
293 let key_file = dir.join("key");
294 let contents = fs::read_to_string(&key_file).with_context(|| format!("failed to read keyfile `{}` (run `conclave key` first)", key_file.display()))?;
295
296 let seed_bytes = decode_key(&contents)?;
297 let seed: [u8; SEED_LEN] = seed_bytes.as_slice().try_into().context("keyfile does not contain a 32-byte seed")?;
298
299 Identity::from_seed(seed)
300}
301
302pub fn save_config(dir: &Path, config: &Config) -> Void {
308 fs::create_dir_all(dir).with_context(|| format!("failed to create config directory `{}`", dir.display()))?;
309
310 let path = dir.join("config.toml");
311 let text = toml::to_string_pretty(config).context("failed to serialize config")?;
312 fs::write(&path, text).with_context(|| format!("failed to write config `{}`", path.display()))?;
313
314 Ok(())
315}
316
317pub fn load_config(dir: &Path) -> Res<Config> {
323 let path = dir.join("config.toml");
324 if !path.exists() {
325 return Ok(Config::default());
326 }
327
328 let text = fs::read_to_string(&path).with_context(|| format!("failed to read config `{}`", path.display()))?;
329 toml::from_str(&text).with_context(|| format!("failed to parse config `{}`", path.display()))
330}
331
332#[cfg(unix)]
333fn restrict_permissions(path: &Path) -> Void {
334 use std::os::unix::fs::PermissionsExt as _;
335 fs::set_permissions(path, fs::Permissions::from_mode(0o600)).with_context(|| format!("failed to restrict permissions on `{}`", path.display()))
336}
337
338#[cfg(not(unix))]
339fn restrict_permissions(_path: &Path) -> Void {
340 Ok(())
342}
343
344#[must_use]
346pub fn default_handle(working_dir: &Path) -> String {
347 working_dir.file_name().map_or_else(|| "session".to_owned(), |name| name.to_string_lossy().into_owned())
348}
349
350#[must_use]
352pub fn session_path(registration: &ServerRegistration, handle: &str) -> SessionPath {
353 SessionPath::new(registration.username.clone(), registration.machine.clone(), handle.to_owned())
354}
355
356#[cfg(test)]
357mod tests {
358 #![allow(clippy::unwrap_used)]
360
361 use super::*;
362 use pretty_assertions::assert_eq;
363 use tempfile::TempDir;
364
365 #[test]
366 fn signs_and_verifies_a_server_challenge() {
367 let identity = Identity::generate().unwrap();
368 let challenge = generate_challenge().unwrap();
369
370 let signature = identity.sign(&challenge).unwrap();
371
372 verify(&identity.public_key(), &challenge, &signature).unwrap();
373 }
374
375 #[test]
376 fn verification_rejects_a_wrong_key() {
377 let signer = Identity::generate().unwrap();
378 let impostor = Identity::generate().unwrap();
379 let challenge = generate_challenge().unwrap();
380 let signature = signer.sign(&challenge).unwrap();
381
382 assert_eq!(verify(&impostor.public_key(), &challenge, &signature), Err(AuthError::BadSignature));
383 }
384
385 #[test]
386 fn verification_rejects_a_tampered_message_or_signature() {
387 let identity = Identity::generate().unwrap();
388 let challenge = generate_challenge().unwrap();
389 let mut signature = identity.sign(&challenge).unwrap();
390
391 let mut tampered_challenge = challenge;
392 tampered_challenge[0] ^= 0xFF;
393 assert_eq!(verify(&identity.public_key(), &tampered_challenge, &signature), Err(AuthError::BadSignature));
394
395 signature[0] ^= 0xFF;
396 assert_eq!(verify(&identity.public_key(), &challenge, &signature), Err(AuthError::BadSignature));
397 }
398
399 #[test]
400 fn verification_rejects_a_malformed_key() {
401 let identity = Identity::generate().unwrap();
402 let challenge = generate_challenge().unwrap();
403 let signature = identity.sign(&challenge).unwrap();
404
405 assert!(matches!(verify(&[0_u8; 8], &challenge, &signature), Err(AuthError::Malformed(_))));
406 }
407
408 #[test]
409 fn debug_never_reveals_the_secret_seed() {
410 let seed = [7_u8; SEED_LEN];
411 let identity = Identity::from_seed(seed).unwrap();
412 let rendered = format!("{identity:?}");
413
414 assert!(!rendered.contains(&encode_key(&seed)), "debug output leaked the secret seed: {rendered}");
415 assert!(rendered.contains("redacted"));
416 assert!(rendered.contains(&identity.public_key_base64()));
417 }
418
419 #[test]
420 fn challenges_are_random() {
421 assert_ne!(generate_challenge().unwrap(), generate_challenge().unwrap());
422 }
423
424 #[test]
425 fn invite_tokens_are_random_and_url_safe() {
426 let a = generate_token().unwrap();
427 let b = generate_token().unwrap();
428 assert_ne!(a, b);
429 assert!(!a.is_empty());
430 assert!(a.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_'), "token is not URL-safe: {a}");
432 }
433
434 #[test]
435 fn keystore_round_trips_the_identity() {
436 let dir = TempDir::new().unwrap();
437 let identity = Identity::generate().unwrap();
438
439 save_identity(dir.path(), &identity).unwrap();
440 let loaded = load_identity(dir.path()).unwrap();
441
442 assert_eq!(loaded.public_key(), identity.public_key());
443
444 let challenge = generate_challenge().unwrap();
446 verify(&loaded.public_key(), &challenge, &loaded.sign(&challenge).unwrap()).unwrap();
447 }
448
449 #[cfg(unix)]
450 #[test]
451 fn keyfile_is_owner_only() {
452 use std::os::unix::fs::PermissionsExt as _;
453
454 let dir = TempDir::new().unwrap();
455 save_identity(dir.path(), &Identity::generate().unwrap()).unwrap();
456
457 let mode = fs::metadata(dir.path().join("key")).unwrap().permissions().mode();
458 assert_eq!(mode & 0o777, 0o600);
459 }
460
461 #[test]
462 fn permission_resolution_prefers_the_most_specific_override() {
463 let config = Config {
464 default_permission: PermissionLevel::Notify,
465 servers: vec![],
466 overrides: vec![
467 PermissionOverride {
468 server: "s1".to_owned(),
469 channel: Some("ops".to_owned()),
470 level: PermissionLevel::Act,
471 },
472 PermissionOverride {
473 server: "s1".to_owned(),
474 channel: None,
475 level: PermissionLevel::Converse,
476 },
477 ],
478 };
479
480 assert_eq!(config.resolve_permission("s1", &Scope::Channel("ops".to_owned())), PermissionLevel::Act);
481 assert_eq!(config.resolve_permission("s1", &Scope::Whisper), PermissionLevel::Converse);
482 assert_eq!(config.resolve_permission("s1", &Scope::Channel("other".to_owned())), PermissionLevel::Notify);
484 assert_eq!(config.resolve_permission("s2", &Scope::Channel("ops".to_owned())), PermissionLevel::Notify);
485 }
486
487 #[test]
488 fn config_round_trips_through_toml_with_lowercase_levels() {
489 let dir = TempDir::new().unwrap();
490 let config = Config {
491 default_permission: PermissionLevel::Notify,
492 servers: vec![ServerRegistration {
493 url: "wss://s1".to_owned(),
494 username: "aaron".to_owned(),
495 machine: "workstation".to_owned(),
496 }],
497 overrides: vec![PermissionOverride {
498 server: "wss://s1".to_owned(),
499 channel: Some("ops".to_owned()),
500 level: PermissionLevel::Act,
501 }],
502 };
503
504 save_config(dir.path(), &config).unwrap();
505 let text = fs::read_to_string(dir.path().join("config.toml")).unwrap();
506 assert!(text.contains("notify"), "levels should serialize lowercase: {text}");
507 assert!(text.contains("act"));
508
509 assert_eq!(load_config(dir.path()).unwrap(), config);
510 }
511
512 #[test]
513 fn load_config_defaults_when_absent() {
514 let dir = TempDir::new().unwrap();
515 assert_eq!(load_config(dir.path()).unwrap(), Config::default());
516 }
517
518 #[test]
519 fn auth_errors_map_onto_wire_protocol_errors() {
520 assert!(matches!(ProtocolError::from(AuthError::BadSignature), ProtocolError::Unauthorized(_)));
521 assert!(matches!(ProtocolError::from(AuthError::Malformed("x".to_owned())), ProtocolError::MalformedFrame(_)));
522 }
523
524 #[test]
525 fn default_handle_uses_the_final_path_component() {
526 assert_eq!(default_handle(Path::new("/home/aaron/projects/razel")), "razel");
527 }
528}