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::{
25 ExposeSecret as _, SecretBox,
26 zeroize::{Zeroize as _, Zeroizing},
27};
28use serde::{Deserialize, Serialize};
29
30use crate::{
31 base::{Constant, PermissionLevel, Res, SessionPath, Void},
32 protocol::ProtocolError,
33};
34
35const SEED_LEN: usize = 32;
37const PUBLIC_KEY_LEN: usize = 32;
38const SIGNATURE_LEN: usize = 64;
39
40#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
42pub enum AuthError {
43 #[error("unknown machine key")]
45 UnknownKey,
46 #[error("machine key has been revoked")]
48 RevokedKey,
49 #[error("signature verification failed")]
51 BadSignature,
52 #[error("malformed key or signature: {0}")]
54 Malformed(String),
55 #[error("username `{0}` is already taken")]
57 UsernameTaken(String),
58 #[error("username `{0}` is reserved for a different key")]
60 Reserved(String),
61}
62
63impl From<AuthError> for ProtocolError {
64 fn from(err: AuthError) -> Self {
65 let message = err.to_string();
66 match err {
67 AuthError::Malformed(_) => Self::MalformedFrame(message),
68 _ => Self::Unauthorized(message),
69 }
70 }
71}
72
73pub struct Identity {
78 secret_seed: SecretBox<[u8; SEED_LEN]>,
79 public_key: [u8; PUBLIC_KEY_LEN],
80}
81
82impl Identity {
83 pub fn generate() -> Res<Self> {
89 let mut seed = Zeroizing::new([0_u8; SEED_LEN]);
91 SystemRandom::new().fill(&mut *seed).map_err(|_| anyhow::anyhow!("failed to gather entropy"))?;
92 Self::from_seed(*seed)
93 }
94
95 pub fn from_seed(mut seed: [u8; SEED_LEN]) -> Res<Self> {
101 let key_pair = Ed25519KeyPair::from_seed_unchecked(&seed).map_err(|e| anyhow::anyhow!("invalid Ed25519 seed: {e}"))?;
102 let public_key = key_pair.public_key().as_ref().try_into().context("unexpected public key length")?;
103
104 let identity = Self {
105 secret_seed: SecretBox::new(Box::new(seed)),
106 public_key,
107 };
108 seed.zeroize();
110 Ok(identity)
111 }
112
113 #[must_use]
115 pub fn public_key(&self) -> [u8; PUBLIC_KEY_LEN] {
116 self.public_key
117 }
118
119 #[must_use]
121 pub fn public_key_base64(&self) -> String {
122 encode_key(&self.public_key)
123 }
124
125 pub fn sign(&self, message: &[u8]) -> Res<[u8; SIGNATURE_LEN]> {
131 let key_pair = Ed25519KeyPair::from_seed_unchecked(self.secret_seed.expose_secret()).map_err(|e| anyhow::anyhow!("failed to load signing key: {e}"))?;
133 key_pair.sign(message).as_ref().try_into().context("unexpected signature length")
134 }
135
136 fn secret_seed_base64(&self) -> String {
138 encode_key(self.secret_seed.expose_secret())
139 }
140}
141
142impl fmt::Debug for Identity {
143 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144 f.debug_struct("Identity").field("public_key", &self.public_key_base64()).field("secret_seed", &"<redacted>").finish()
145 }
146}
147
148pub fn verify(public_key: &[u8], message: &[u8], signature: &[u8]) -> Result<(), AuthError> {
155 if public_key.len() != PUBLIC_KEY_LEN {
156 return Err(AuthError::Malformed("public key must be 32 bytes".to_owned()));
157 }
158 if signature.len() != SIGNATURE_LEN {
159 return Err(AuthError::Malformed("signature must be 64 bytes".to_owned()));
160 }
161
162 UnparsedPublicKey::new(&signature::ED25519, public_key).verify(message, signature).map_err(|_| AuthError::BadSignature)
163}
164
165pub fn generate_challenge() -> Res<[u8; Constant::CHALLENGE_SIZE]> {
171 let mut nonce = [0_u8; Constant::CHALLENGE_SIZE];
172 SystemRandom::new().fill(&mut nonce).map_err(|_| anyhow::anyhow!("failed to gather entropy"))?;
173 Ok(nonce)
174}
175
176pub fn generate_token() -> Res<String> {
182 let mut bytes = [0_u8; 24];
183 SystemRandom::new().fill(&mut bytes).map_err(|_| anyhow::anyhow!("failed to gather entropy"))?;
184 Ok(encode_key(&bytes))
185}
186
187#[must_use]
190pub fn encode_key(bytes: &[u8]) -> String {
191 URL_SAFE_NO_PAD.encode(bytes)
192}
193
194pub fn decode_key(text: &str) -> Result<Vec<u8>, AuthError> {
200 URL_SAFE_NO_PAD.decode(text.trim()).map_err(|e| AuthError::Malformed(e.to_string()))
201}
202
203#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
209pub struct ServerRegistration {
210 pub url: String,
212 pub username: String,
214 pub machine: String,
216}
217
218#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
220pub struct PermissionOverride {
221 pub server: String,
223 #[serde(default)]
225 pub channel: Option<String>,
226 pub level: PermissionLevel,
228}
229
230#[derive(Clone, Debug, PartialEq, Eq)]
232pub enum Scope {
233 Channel(String),
235 Whisper,
237}
238
239#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
241pub struct Config {
242 #[serde(default)]
244 pub default_permission: PermissionLevel,
245 #[serde(default)]
247 pub servers: Vec<ServerRegistration>,
248 #[serde(default)]
250 pub overrides: Vec<PermissionOverride>,
251}
252
253impl Config {
254 #[must_use]
256 pub fn resolve_permission(&self, server: &str, scope: &Scope) -> PermissionLevel {
257 let target_channel = match scope {
258 Scope::Channel(name) => Some(name.as_str()),
259 Scope::Whisper => None,
260 };
261
262 self.overrides
263 .iter()
264 .find(|o| o.server == server && o.channel.as_deref() == target_channel)
265 .map_or(self.default_permission, |o| o.level)
266 }
267}
268
269pub fn default_config_dir() -> Res<PathBuf> {
275 let base = dirs::config_dir().context("could not determine the OS configuration directory")?;
276 Ok(base.join(Constant::CONFIG_DIR_NAME))
277}
278
279pub fn save_identity(dir: &Path, identity: &Identity) -> Void {
285 use std::io::Write as _;
286
287 fs::create_dir_all(dir).with_context(|| format!("failed to create keystore directory `{}`", dir.display()))?;
288
289 let key_file = dir.join("key");
290 let seed_b64 = Zeroizing::new(identity.secret_seed_base64());
293 let mut file = create_key_file(&key_file)?;
294 file.write_all(seed_b64.as_bytes()).with_context(|| format!("failed to write keyfile `{}`", key_file.display()))?;
295 restrict_permissions(&key_file)?;
297
298 Ok(())
299}
300
301#[cfg(unix)]
304fn create_key_file(path: &Path) -> Res<fs::File> {
305 use std::os::unix::fs::OpenOptionsExt as _;
306 fs::OpenOptions::new()
307 .write(true)
308 .create(true)
309 .truncate(true)
310 .mode(0o600)
311 .open(path)
312 .with_context(|| format!("failed to create keyfile `{}`", path.display()))
313}
314
315#[cfg(not(unix))]
316fn create_key_file(path: &Path) -> Res<fs::File> {
317 fs::OpenOptions::new()
318 .write(true)
319 .create(true)
320 .truncate(true)
321 .open(path)
322 .with_context(|| format!("failed to create keyfile `{}`", path.display()))
323}
324
325pub fn load_identity(dir: &Path) -> Res<Identity> {
331 let key_file = dir.join("key");
332 let contents = Zeroizing::new(fs::read_to_string(&key_file).with_context(|| format!("failed to read keyfile `{}` (run `conclave key` first)", key_file.display()))?);
334
335 let mut seed_bytes = decode_key(&contents)?;
336 let mut seed: [u8; SEED_LEN] = seed_bytes.as_slice().try_into().context("keyfile does not contain a 32-byte seed")?;
337 seed_bytes.zeroize();
338
339 let identity = Identity::from_seed(seed);
340 seed.zeroize();
341 identity
342}
343
344pub fn save_config(dir: &Path, config: &Config) -> Void {
350 fs::create_dir_all(dir).with_context(|| format!("failed to create config directory `{}`", dir.display()))?;
351
352 let path = dir.join("config.toml");
353 let text = toml::to_string_pretty(config).context("failed to serialize config")?;
354 atomic_write(&path, &text)?;
355
356 Ok(())
357}
358
359fn atomic_write(path: &Path, contents: &str) -> Void {
363 let name = path.file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("conclave");
364 let tmp = path.with_file_name(format!("{name}.{}.tmp", std::process::id()));
365 fs::write(&tmp, contents).with_context(|| format!("failed to write temp file `{}`", tmp.display()))?;
366 fs::rename(&tmp, path).with_context(|| format!("failed to replace `{}`", path.display()))?;
367 Ok(())
368}
369
370pub fn load_config(dir: &Path) -> Res<Config> {
376 let path = dir.join("config.toml");
377 if !path.exists() {
378 return Ok(Config::default());
379 }
380
381 let text = fs::read_to_string(&path).with_context(|| format!("failed to read config `{}`", path.display()))?;
382 toml::from_str(&text).with_context(|| format!("failed to parse config `{}`", path.display()))
383}
384
385#[cfg(unix)]
386fn restrict_permissions(path: &Path) -> Void {
387 use std::os::unix::fs::PermissionsExt as _;
388 fs::set_permissions(path, fs::Permissions::from_mode(0o600)).with_context(|| format!("failed to restrict permissions on `{}`", path.display()))
389}
390
391#[cfg(not(unix))]
392fn restrict_permissions(_path: &Path) -> Void {
393 Ok(())
395}
396
397#[must_use]
399pub fn default_handle(working_dir: &Path) -> String {
400 working_dir.file_name().map_or_else(|| "session".to_owned(), |name| name.to_string_lossy().into_owned())
401}
402
403#[must_use]
405pub fn session_path(registration: &ServerRegistration, handle: &str) -> SessionPath {
406 SessionPath::new(registration.username.clone(), registration.machine.clone(), handle.to_owned())
407}
408
409#[cfg(test)]
410mod tests {
411 #![allow(clippy::unwrap_used)]
413
414 use super::*;
415 use pretty_assertions::assert_eq;
416 use tempfile::TempDir;
417
418 #[test]
419 fn signs_and_verifies_a_server_challenge() {
420 let identity = Identity::generate().unwrap();
421 let challenge = generate_challenge().unwrap();
422
423 let signature = identity.sign(&challenge).unwrap();
424
425 verify(&identity.public_key(), &challenge, &signature).unwrap();
426 }
427
428 #[test]
429 fn verification_rejects_a_wrong_key() {
430 let signer = Identity::generate().unwrap();
431 let impostor = Identity::generate().unwrap();
432 let challenge = generate_challenge().unwrap();
433 let signature = signer.sign(&challenge).unwrap();
434
435 assert_eq!(verify(&impostor.public_key(), &challenge, &signature), Err(AuthError::BadSignature));
436 }
437
438 #[test]
439 fn verification_rejects_a_tampered_message_or_signature() {
440 let identity = Identity::generate().unwrap();
441 let challenge = generate_challenge().unwrap();
442 let mut signature = identity.sign(&challenge).unwrap();
443
444 let mut tampered_challenge = challenge;
445 tampered_challenge[0] ^= 0xFF;
446 assert_eq!(verify(&identity.public_key(), &tampered_challenge, &signature), Err(AuthError::BadSignature));
447
448 signature[0] ^= 0xFF;
449 assert_eq!(verify(&identity.public_key(), &challenge, &signature), Err(AuthError::BadSignature));
450 }
451
452 #[test]
453 fn verification_rejects_a_malformed_key() {
454 let identity = Identity::generate().unwrap();
455 let challenge = generate_challenge().unwrap();
456 let signature = identity.sign(&challenge).unwrap();
457
458 assert!(matches!(verify(&[0_u8; 8], &challenge, &signature), Err(AuthError::Malformed(_))));
459 }
460
461 #[test]
462 fn debug_never_reveals_the_secret_seed() {
463 let seed = [7_u8; SEED_LEN];
464 let identity = Identity::from_seed(seed).unwrap();
465 let rendered = format!("{identity:?}");
466
467 assert!(!rendered.contains(&encode_key(&seed)), "debug output leaked the secret seed: {rendered}");
468 assert!(rendered.contains("redacted"));
469 assert!(rendered.contains(&identity.public_key_base64()));
470 }
471
472 #[test]
473 fn challenges_are_random() {
474 assert_ne!(generate_challenge().unwrap(), generate_challenge().unwrap());
475 }
476
477 #[test]
478 fn invite_tokens_are_random_and_url_safe() {
479 let a = generate_token().unwrap();
480 let b = generate_token().unwrap();
481 assert_ne!(a, b);
482 assert!(!a.is_empty());
483 assert!(a.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_'), "token is not URL-safe: {a}");
485 }
486
487 #[test]
488 fn keystore_round_trips_the_identity() {
489 let dir = TempDir::new().unwrap();
490 let identity = Identity::generate().unwrap();
491
492 save_identity(dir.path(), &identity).unwrap();
493 let loaded = load_identity(dir.path()).unwrap();
494
495 assert_eq!(loaded.public_key(), identity.public_key());
496
497 let challenge = generate_challenge().unwrap();
499 verify(&loaded.public_key(), &challenge, &loaded.sign(&challenge).unwrap()).unwrap();
500 }
501
502 #[cfg(unix)]
503 #[test]
504 fn keyfile_is_owner_only() {
505 use std::os::unix::fs::PermissionsExt as _;
506
507 let dir = TempDir::new().unwrap();
508 save_identity(dir.path(), &Identity::generate().unwrap()).unwrap();
509
510 let mode = fs::metadata(dir.path().join("key")).unwrap().permissions().mode();
511 assert_eq!(mode & 0o777, 0o600);
512 }
513
514 #[test]
515 fn permission_resolution_prefers_the_most_specific_override() {
516 let config = Config {
517 default_permission: PermissionLevel::Notify,
518 servers: vec![],
519 overrides: vec![
520 PermissionOverride {
521 server: "s1".to_owned(),
522 channel: Some("ops".to_owned()),
523 level: PermissionLevel::Act,
524 },
525 PermissionOverride {
526 server: "s1".to_owned(),
527 channel: None,
528 level: PermissionLevel::Converse,
529 },
530 ],
531 };
532
533 assert_eq!(config.resolve_permission("s1", &Scope::Channel("ops".to_owned())), PermissionLevel::Act);
534 assert_eq!(config.resolve_permission("s1", &Scope::Whisper), PermissionLevel::Converse);
535 assert_eq!(config.resolve_permission("s1", &Scope::Channel("other".to_owned())), PermissionLevel::Notify);
537 assert_eq!(config.resolve_permission("s2", &Scope::Channel("ops".to_owned())), PermissionLevel::Notify);
538 }
539
540 #[test]
541 fn config_round_trips_through_toml_with_lowercase_levels() {
542 let dir = TempDir::new().unwrap();
543 let config = Config {
544 default_permission: PermissionLevel::Notify,
545 servers: vec![ServerRegistration {
546 url: "wss://s1".to_owned(),
547 username: "aaron".to_owned(),
548 machine: "workstation".to_owned(),
549 }],
550 overrides: vec![PermissionOverride {
551 server: "wss://s1".to_owned(),
552 channel: Some("ops".to_owned()),
553 level: PermissionLevel::Act,
554 }],
555 };
556
557 save_config(dir.path(), &config).unwrap();
558 let text = fs::read_to_string(dir.path().join("config.toml")).unwrap();
559 assert!(text.contains("notify"), "levels should serialize lowercase: {text}");
560 assert!(text.contains("act"));
561
562 assert_eq!(load_config(dir.path()).unwrap(), config);
563 }
564
565 #[test]
566 fn load_config_defaults_when_absent() {
567 let dir = TempDir::new().unwrap();
568 assert_eq!(load_config(dir.path()).unwrap(), Config::default());
569 }
570
571 #[test]
572 fn save_config_atomic_round_trips_and_leaves_no_temp() {
573 let dir = TempDir::new().unwrap();
574 let config = Config {
575 default_permission: PermissionLevel::Converse,
576 ..Config::default()
577 };
578
579 save_config(dir.path(), &config).unwrap();
580 assert_eq!(load_config(dir.path()).unwrap(), config);
581
582 let residue: Vec<_> = fs::read_dir(dir.path())
584 .unwrap()
585 .filter_map(Result::ok)
586 .filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
587 .collect();
588 assert!(residue.is_empty(), "atomic write must leave no temp file: {residue:?}");
589
590 let updated = Config {
592 default_permission: PermissionLevel::Act,
593 ..config
594 };
595 save_config(dir.path(), &updated).unwrap();
596 assert_eq!(load_config(dir.path()).unwrap(), updated);
597 }
598
599 #[test]
600 fn auth_errors_map_onto_wire_protocol_errors() {
601 assert!(matches!(ProtocolError::from(AuthError::BadSignature), ProtocolError::Unauthorized(_)));
602 assert!(matches!(ProtocolError::from(AuthError::Malformed("x".to_owned())), ProtocolError::MalformedFrame(_)));
603 }
604
605 #[test]
606 fn default_handle_uses_the_final_path_component() {
607 assert_eq!(default_handle(Path::new("/home/aaron/projects/razel")), "razel");
608 }
609}