Skip to main content

conclavelib/
identity.rs

1//! Local identity and keystore: the per-machine keypair, signing, and on-disk state.
2//!
3//! Owns everything under `~/.config/conclave` (`Constant::CONFIG_DIR_NAME`): the machine's own
4//! Ed25519 keypair (the 32-byte seed wrapped in a `secrecy` `SecretBox`, never logged), and the
5//! local `config.toml` — known-server registrations (username + machine name) and the permission
6//! config (default level + per-`(server, channel)` / whisper overrides, DESIGN.md §9).
7//!
8//! Auth is challenge-response: the machine signs a server-issued nonce and the server resolves the
9//! public key to a `(user, machine)` (DESIGN.md §5). Keys are generated from OS entropy via `ring`'s
10//! system RNG and the key pair is reconstructed from the seed on demand, so the private seed is only
11//! ever materialized transiently for a signature.
12
13use 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
32/// Byte lengths of an Ed25519 private seed, public key, and signature.
33const SEED_LEN: usize = 32;
34const PUBLIC_KEY_LEN: usize = 32;
35const SIGNATURE_LEN: usize = 64;
36
37/// Errors at the authentication / identity boundary (DESIGN.md §16), matched on by the server.
38#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
39pub enum AuthError {
40    /// The presented public key is not enrolled on the server.
41    #[error("unknown machine key")]
42    UnknownKey,
43    /// The presented key has been revoked (lost-laptop kill switch, DESIGN.md §5.1).
44    #[error("machine key has been revoked")]
45    RevokedKey,
46    /// The signature did not verify against the challenge and public key.
47    #[error("signature verification failed")]
48    BadSignature,
49    /// A key or signature was the wrong length or otherwise unparseable.
50    #[error("malformed key or signature: {0}")]
51    Malformed(String),
52    /// The requested username is already claimed on this server.
53    #[error("username `{0}` is already taken")]
54    UsernameTaken(String),
55    /// The session handle collides with a live session for this `(user, machine)` (DESIGN.md §5).
56    #[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
70/// This machine's Ed25519 identity: a `secrecy`-wrapped seed plus the derived public key.
71///
72/// The seed never leaves the [`SecretBox`] except transiently to sign or to be persisted to the
73/// (0600) keyfile; the [`fmt::Debug`] impl redacts it.
74pub struct Identity {
75    secret_seed: SecretBox<[u8; SEED_LEN]>,
76    public_key: [u8; PUBLIC_KEY_LEN],
77}
78
79impl Identity {
80    /// Generates a fresh identity from operating-system entropy.
81    ///
82    /// # Errors
83    ///
84    /// Returns an error if the OS random source cannot be read or the seed is rejected.
85    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    /// Reconstructs an identity from its 32-byte seed.
92    ///
93    /// # Errors
94    ///
95    /// Returns an error if the seed is rejected by the signing backend.
96    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    /// This identity's raw 32-byte public key.
107    #[must_use]
108    pub fn public_key(&self) -> [u8; PUBLIC_KEY_LEN] {
109        self.public_key
110    }
111
112    /// This identity's public key as a URL-safe base64 string, for display and pasting.
113    #[must_use]
114    pub fn public_key_base64(&self) -> String {
115        encode_key(&self.public_key)
116    }
117
118    /// Signs `message` (e.g. a server challenge nonce), returning the 64-byte signature.
119    ///
120    /// # Errors
121    ///
122    /// Returns an error if the signing key cannot be reconstructed from the seed.
123    pub fn sign(&self, message: &[u8]) -> Res<[u8; SIGNATURE_LEN]> {
124        // Reconstruct the key pair transiently so the private seed is not held resident beyond signing.
125        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    /// The base64 seed, materialized only to persist the keyfile.
130    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
141/// Verifies `signature` over `message` against the raw `public_key`.
142///
143/// # Errors
144///
145/// Returns [`AuthError::Malformed`] if the key or signature is the wrong length, or
146/// [`AuthError::BadSignature`] if verification fails.
147pub 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
158/// Generates a fresh random challenge nonce (server-side, DESIGN.md §5).
159///
160/// # Errors
161///
162/// Returns an error if the OS random source cannot be read.
163pub 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
169/// Mints a fresh, opaque invite token: 24 bytes of OS entropy, URL-safe base64 (DESIGN.md §6).
170///
171/// # Errors
172///
173/// Returns an error if the OS random source cannot be read.
174pub 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/// Encodes raw key bytes as a URL-safe base64 string — the canonical on-wire / on-disk key
181/// encoding, symmetric with [`decode_key`]. The server stores machine public keys this way.
182#[must_use]
183pub fn encode_key(bytes: &[u8]) -> String {
184    URL_SAFE_NO_PAD.encode(bytes)
185}
186
187/// Decodes a URL-safe base64 key string back to bytes.
188///
189/// # Errors
190///
191/// Returns [`AuthError::Malformed`] if the string is not valid base64.
192pub 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// ---------------------------------------------------------------------------
197// On-disk local configuration (`~/.config/conclave/config.toml`).
198// ---------------------------------------------------------------------------
199
200/// This machine's registration on one server (DESIGN.md §5).
201#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
202pub struct ServerRegistration {
203    /// The server URL.
204    pub url: String,
205    /// The username claimed on that server.
206    pub username: String,
207    /// The machine name this key is enrolled under.
208    pub machine: String,
209}
210
211/// A local permission override, keyed by `(server, scope)` (DESIGN.md §9).
212#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
213pub struct PermissionOverride {
214    /// The server the override applies to.
215    pub server: String,
216    /// The channel the override applies to; `None` denotes the whisper scope.
217    #[serde(default)]
218    pub channel: Option<String>,
219    /// The autonomy level for that scope.
220    pub level: PermissionLevel,
221}
222
223/// The scope a permission level resolves for.
224#[derive(Clone, Debug, PartialEq, Eq)]
225pub enum Scope {
226    /// A named channel.
227    Channel(String),
228    /// The whisper (direct-message) scope.
229    Whisper,
230}
231
232/// The local machine configuration: identity-adjacent state and the permission policy.
233#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
234pub struct Config {
235    /// The machine-wide default autonomy level (ships `notify`).
236    #[serde(default)]
237    pub default_permission: PermissionLevel,
238    /// Known-server registrations.
239    #[serde(default)]
240    pub servers: Vec<ServerRegistration>,
241    /// Per-`(server, scope)` overrides.
242    #[serde(default)]
243    pub overrides: Vec<PermissionOverride>,
244}
245
246impl Config {
247    /// Resolves the autonomy level for a `(server, scope)`: a matching override, else the default.
248    #[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
262/// The default keystore / config directory, `~/.config/conclave` (`Constant::CONFIG_DIR_NAME`).
263///
264/// # Errors
265///
266/// Returns an error if the OS configuration directory cannot be determined.
267pub 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
272/// Writes the identity's seed to `dir/key` with owner-only (0600) permissions.
273///
274/// # Errors
275///
276/// Returns an error if the directory or file cannot be created or written.
277pub 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
287/// Loads the identity whose seed is stored at `dir/key`.
288///
289/// # Errors
290///
291/// Returns an error if the keyfile is missing or does not contain a valid 32-byte seed.
292pub 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
302/// Writes the local configuration to `dir/config.toml`.
303///
304/// # Errors
305///
306/// Returns an error if the config cannot be serialized or written.
307pub 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
317/// Loads the local configuration from `dir/config.toml`, returning the default if it is absent.
318///
319/// # Errors
320///
321/// Returns an error if the config file exists but cannot be read or parsed.
322pub 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    // Best-effort only; non-unix platforms rely on the user profile directory's ACLs.
341    Ok(())
342}
343
344/// The default session handle for a working directory — its final path component (DESIGN.md §5).
345#[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/// Builds a full participant path from a registration and a session handle.
351#[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    // Tests relax `unwrap_used` (house convention; DESIGN.md §22).
359    #![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        // URL-safe base64 (no `+`, `/`, or `=` padding).
431        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        // The reloaded key still produces verifiable signatures.
445        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        // No matching override falls back to the machine default.
483        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}