Skip to main content

mobius_gateway/
auth.rs

1//! One-time pairing and independent bearer-token authentication.
2
3use std::collections::BTreeSet;
4use std::fs;
5use std::io::Write as _;
6#[cfg(unix)]
7use std::os::unix::fs::PermissionsExt as _;
8use std::path::{Path, PathBuf};
9use std::sync::Mutex;
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use serde::{Deserialize, Serialize};
13use sha2::{Digest as _, Sha256};
14use subtle::ConstantTimeEq as _;
15use uuid::Uuid;
16
17use crate::{Error, Result};
18
19const MAX_CREDENTIAL_BYTES: usize = 512;
20const MAX_CLIENT_LABEL_BYTES: usize = 128;
21const MAX_CLIENTS: usize = 32;
22const PAIRING_LIFETIME_SECONDS: i64 = 10 * 60;
23const REVOKED_PAIRING_EXPIRY: i64 = 0;
24const LOCAL_CLIENT_ID: &str = "00000000-0000-0000-0000-000000000001";
25const LOCAL_CLIENT_LABEL: &str = "Local möbius CLI";
26
27#[derive(Clone, Serialize, Deserialize)]
28#[serde(deny_unknown_fields)]
29struct AuthState {
30    pending_pairing: Option<PendingPairing>,
31    clients: Vec<ClientToken>,
32}
33
34#[derive(Clone, Serialize, Deserialize)]
35#[serde(deny_unknown_fields)]
36struct PendingPairing {
37    digest: [u8; 32],
38    expires_at: i64,
39}
40
41#[derive(Clone, Serialize, Deserialize)]
42#[serde(deny_unknown_fields)]
43struct ClientToken {
44    id: String,
45    label: String,
46    digest: [u8; 32],
47    created_at: i64,
48}
49
50/// Identity returned after a successful authentication handshake.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct ClientIdentity {
53    pub id: String,
54    pub label: String,
55}
56
57/// A newly issued bearer token. Only the digest is persisted.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct IssuedToken {
60    pub client_id: String,
61    pub token: String,
62}
63
64/// One pending code that may be consumed by exactly one new client.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct PairingGrant {
67    pub code: String,
68    pub expires_at: i64,
69}
70
71#[cfg(any(unix, test))]
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub(crate) enum PairingStatus {
74    Pending,
75    Consumed,
76    Replaced,
77}
78
79/// File-backed authentication state shared by all accepted connections.
80pub struct AuthStore {
81    path: PathBuf,
82    state: Mutex<AuthState>,
83}
84
85impl AuthStore {
86    /// Creates fresh auth state and returns the short-lived bootstrap code.
87    pub fn initialize(path: impl Into<PathBuf>) -> Result<(Self, PairingGrant)> {
88        let path = path.into();
89        let grant = new_pairing_grant()?;
90        let state = AuthState {
91            pending_pairing: Some(PendingPairing {
92                digest: digest(&grant.code),
93                expires_at: grant.expires_at,
94            }),
95            clients: Vec::new(),
96        };
97        save_auth_state(&path, &state, true)?;
98        Ok((
99            Self {
100                path,
101                state: Mutex::new(state),
102            },
103            grant,
104        ))
105    }
106
107    /// Opens previously initialized authentication state.
108    pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
109        let path = path.into();
110        let contents = fs::read(&path)?;
111        if contents.len() > 64 * 1024 {
112            return Err(Error::Config("authentication state is too large".into()));
113        }
114        let state: AuthState = serde_json::from_slice(&contents)?;
115        validate_auth_state(&state)?;
116        Ok(Self {
117            path,
118            state: Mutex::new(state),
119        })
120    }
121
122    /// Consumes the pending code and appends an independently issued client token.
123    pub fn pair(&self, code: &str, client_label: &str) -> Result<IssuedToken> {
124        validate_client_label(client_label)?;
125        let now = unix_timestamp()?;
126        let mut state = self.lock_state()?;
127        let Some(pending) = &state.pending_pairing else {
128            return Err(Error::Unauthorized);
129        };
130        if pending.expires_at < now || !credential_matches(code, &pending.digest) {
131            return Err(Error::Unauthorized);
132        }
133        if state.clients.len() == MAX_CLIENTS {
134            return Err(Error::Config("paired client limit reached".into()));
135        }
136
137        let token = random_secret(2);
138        let client_id = pairing_client_id(code);
139        let mut next = state.clone();
140        next.pending_pairing = None;
141        next.clients.push(ClientToken {
142            id: client_id.clone(),
143            label: client_label.into(),
144            digest: digest(&token),
145            created_at: now,
146        });
147        save_auth_state(&self.path, &next, false)?;
148        *state = next;
149        Ok(IssuedToken { client_id, token })
150    }
151
152    pub(crate) fn provision_local_client(&self) -> Result<IssuedToken> {
153        let token = random_secret(2);
154        let now = unix_timestamp()?;
155        let mut state = self.lock_state()?;
156        let mut next = state.clone();
157        if let Some(client) = next
158            .clients
159            .iter_mut()
160            .find(|client| client.id == LOCAL_CLIENT_ID)
161        {
162            client.digest = digest(&token);
163            client.created_at = now;
164        } else {
165            if next.clients.len() == MAX_CLIENTS {
166                return Err(Error::Config("paired client limit reached".into()));
167            }
168            next.clients.push(ClientToken {
169                id: LOCAL_CLIENT_ID.into(),
170                label: LOCAL_CLIENT_LABEL.into(),
171                digest: digest(&token),
172                created_at: now,
173            });
174        }
175        save_auth_state(&self.path, &next, false)?;
176        *state = next;
177        Ok(IssuedToken {
178            client_id: LOCAL_CLIENT_ID.into(),
179            token,
180        })
181    }
182
183    /// Replaces any unused pairing code without invalidating paired clients.
184    pub fn create_pairing_code(&self) -> Result<PairingGrant> {
185        let mut state = self.lock_state()?;
186        if state.clients.len() == MAX_CLIENTS {
187            return Err(Error::Config("paired client limit reached".into()));
188        }
189        let grant = new_pairing_grant()?;
190        let mut next = state.clone();
191        next.pending_pairing = Some(PendingPairing {
192            digest: digest(&grant.code),
193            expires_at: grant.expires_at,
194        });
195        save_auth_state(&self.path, &next, false)?;
196        *state = next;
197        Ok(grant)
198    }
199
200    /// Verifies a bearer token against every paired client digest.
201    pub fn authenticate(&self, token: &str) -> Result<ClientIdentity> {
202        if token.is_empty() || token.len() > MAX_CREDENTIAL_BYTES {
203            return Err(Error::Unauthorized);
204        }
205        let candidate = digest(token);
206        let state = self.lock_state()?;
207        let mut matched = None;
208        for client in &state.clients {
209            if bool::from(candidate.ct_eq(&client.digest)) {
210                matched = Some(ClientIdentity {
211                    id: client.id.clone(),
212                    label: client.label.clone(),
213                });
214            }
215        }
216        matched.ok_or(Error::Unauthorized)
217    }
218
219    pub(crate) fn clients(&self) -> Result<Vec<ClientIdentity>> {
220        Ok(self
221            .lock_state()?
222            .clients
223            .iter()
224            .map(|client| ClientIdentity {
225                id: client.id.clone(),
226                label: client.label.clone(),
227            })
228            .collect())
229    }
230
231    pub(crate) fn unpair_client(&self, actor_id: &str, client_id: &str) -> Result<bool> {
232        if actor_id == client_id
233            || Uuid::parse_str(actor_id).is_err()
234            || Uuid::parse_str(client_id).is_err()
235        {
236            return Ok(false);
237        }
238        let mut state = self.lock_state()?;
239        if !state.clients.iter().any(|client| client.id == actor_id) {
240            return Ok(false);
241        }
242        let Some(index) = state
243            .clients
244            .iter()
245            .position(|client| client.id == client_id)
246        else {
247            return Ok(false);
248        };
249        let mut next = state.clone();
250        next.clients.remove(index);
251        save_auth_state(&self.path, &next, false)?;
252        *state = next;
253        Ok(true)
254    }
255
256    #[cfg(any(unix, test))]
257    pub(crate) fn pairing_status(&self, code: &str) -> Result<PairingStatus> {
258        let state = self.lock_state()?;
259        if state
260            .clients
261            .iter()
262            .any(|client| client.id == pairing_client_id(code))
263        {
264            return Ok(PairingStatus::Consumed);
265        }
266        Ok(match &state.pending_pairing {
267            Some(pending) if credential_matches(code, &pending.digest) => PairingStatus::Pending,
268            _ => PairingStatus::Replaced,
269        })
270    }
271
272    #[cfg(any(unix, test))]
273    pub(crate) fn revoke_pairing_code(&self, code: &str) -> Result<()> {
274        let mut state = self.lock_state()?;
275        let Some(pending) = &state.pending_pairing else {
276            return Ok(());
277        };
278        if !credential_matches(code, &pending.digest) {
279            return Ok(());
280        }
281        let mut next = state.clone();
282        next.pending_pairing = Some(PendingPairing {
283            digest: digest(&random_secret(1)),
284            expires_at: REVOKED_PAIRING_EXPIRY,
285        });
286        save_auth_state(&self.path, &next, false)?;
287        *state = next;
288        Ok(())
289    }
290
291    fn lock_state(&self) -> Result<std::sync::MutexGuard<'_, AuthState>> {
292        self.state
293            .lock()
294            .map_err(|_| Error::Config("authentication state lock is poisoned".into()))
295    }
296}
297
298fn validate_client_label(label: &str) -> Result<()> {
299    if label.is_empty()
300        || label != label.trim()
301        || label.len() > MAX_CLIENT_LABEL_BYTES
302        || label.chars().any(char::is_control)
303    {
304        return Err(Error::Config(format!(
305            "client label must be canonical, control-free, and 1–{MAX_CLIENT_LABEL_BYTES} bytes"
306        )));
307    }
308    Ok(())
309}
310
311fn validate_auth_state(state: &AuthState) -> Result<()> {
312    if state.clients.len() > MAX_CLIENTS {
313        return Err(Error::Config(
314            "authentication state exceeds the client limit".into(),
315        ));
316    }
317    if state.pending_pairing.is_none() && state.clients.is_empty() {
318        return Err(Error::Config(
319            "authentication state has neither pairing nor client access".into(),
320        ));
321    }
322    let mut client_ids = BTreeSet::new();
323    let mut token_digests = BTreeSet::new();
324    for client in &state.clients {
325        let id = Uuid::parse_str(&client.id)
326            .map_err(|_| Error::Config("authentication client ID is invalid".into()))?;
327        if id.to_string() != client.id {
328            return Err(Error::Config(
329                "authentication client ID must be canonical".into(),
330            ));
331        }
332        if !client_ids.insert(client.id.as_str()) {
333            return Err(Error::Config(
334                "authentication state contains duplicate client IDs".into(),
335            ));
336        }
337        if !token_digests.insert(client.digest) {
338            return Err(Error::Config(
339                "authentication state contains duplicate token digests".into(),
340            ));
341        }
342        validate_client_label(&client.label)?;
343    }
344    Ok(())
345}
346
347fn credential_matches(candidate: &str, expected: &[u8; 32]) -> bool {
348    if candidate.is_empty() || candidate.len() > MAX_CREDENTIAL_BYTES {
349        return false;
350    }
351    bool::from(digest(candidate).ct_eq(expected))
352}
353
354fn digest(value: &str) -> [u8; 32] {
355    Sha256::digest(value.as_bytes()).into()
356}
357
358fn pairing_client_id(code: &str) -> String {
359    let mut bytes = [0; 16];
360    bytes.copy_from_slice(&digest(code)[..16]);
361    Uuid::from_bytes(bytes).to_string()
362}
363
364fn new_pairing_grant() -> Result<PairingGrant> {
365    Ok(PairingGrant {
366        code: random_secret(1),
367        expires_at: unix_timestamp()?
368            .checked_add(PAIRING_LIFETIME_SECONDS)
369            .ok_or_else(|| Error::Config("pairing expiry overflow".into()))?,
370    })
371}
372
373fn random_secret(parts: usize) -> String {
374    (0..parts)
375        .map(|_| Uuid::new_v4().simple().to_string())
376        .collect()
377}
378
379fn unix_timestamp() -> Result<i64> {
380    let seconds = SystemTime::now()
381        .duration_since(UNIX_EPOCH)
382        .map_err(|_| Error::Config("system clock is before the Unix epoch".into()))?
383        .as_secs();
384    i64::try_from(seconds).map_err(|_| Error::Config("system clock is unsupported".into()))
385}
386
387fn save_auth_state(path: &Path, state: &AuthState, create_new: bool) -> Result<()> {
388    let parent = path
389        .parent()
390        .ok_or_else(|| Error::Config("authentication path has no parent".into()))?;
391    fs::create_dir_all(parent)?;
392    validate_auth_state(state)?;
393    let contents = serde_json::to_vec_pretty(state)?;
394    let mut file = tempfile::NamedTempFile::new_in(parent)?;
395    #[cfg(unix)]
396    file.as_file()
397        .set_permissions(fs::Permissions::from_mode(0o600))?;
398    file.write_all(&contents)?;
399    file.as_file().sync_all()?;
400    if create_new {
401        file.persist_noclobber(path).map_err(|error| error.error)?;
402    } else {
403        file.persist(path).map_err(|error| error.error)?;
404    }
405    Ok(())
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    fn client(id: &str, label: &str, digest_byte: u8) -> ClientToken {
413        ClientToken {
414            id: id.into(),
415            label: label.into(),
416            digest: [digest_byte; 32],
417            created_at: 1,
418        }
419    }
420
421    fn write_auth_state(path: &Path, clients: Vec<ClientToken>) {
422        fs::write(
423            path,
424            serde_json::to_vec(&AuthState {
425                pending_pairing: None,
426                clients,
427            })
428            .expect("encode auth state"),
429        )
430        .expect("write auth state");
431    }
432
433    #[test]
434    fn pairing_three_clients_keeps_every_issued_token_valid() {
435        let directory = tempfile::tempdir().expect("state directory");
436        let path = directory.path().join("auth.json");
437        let (auth, first) = AuthStore::initialize(&path).expect("initialize auth");
438        let first = auth.pair(&first.code, "Mac").expect("pair Mac");
439        let second_code = auth.create_pairing_code().expect("second code");
440        let second = auth.pair(&second_code.code, "iPhone").expect("pair iPhone");
441        let third_code = auth.create_pairing_code().expect("third code");
442        let third = auth.pair(&third_code.code, "CLI").expect("pair CLI");
443
444        assert!(auth.authenticate(&first.token).is_ok());
445        assert!(auth.authenticate(&second.token).is_ok());
446        assert!(auth.authenticate(&third.token).is_ok());
447    }
448
449    #[test]
450    fn provisioning_local_client_preserves_remote_pairing() {
451        let directory = tempfile::tempdir().expect("state directory");
452        let path = directory.path().join("auth.json");
453        let (auth, grant) = AuthStore::initialize(path).expect("initialize auth");
454
455        let local = auth
456            .provision_local_client()
457            .expect("provision local client");
458        let remote = auth.pair(&grant.code, "iPhone").expect("pair iPhone");
459
460        assert!(auth.authenticate(&local.token).is_ok());
461        assert!(auth.authenticate(&remote.token).is_ok());
462    }
463
464    #[test]
465    fn paired_clients_are_listed_without_credentials() {
466        let directory = tempfile::tempdir().expect("state directory");
467        let path = directory.path().join("auth.json");
468        let (auth, grant) = AuthStore::initialize(&path).expect("initialize auth");
469        auth.pair(&grant.code, "Mac").expect("pair Mac");
470
471        assert_eq!(
472            auth.clients().expect("paired clients"),
473            [ClientIdentity {
474                id: pairing_client_id(&grant.code),
475                label: "Mac".into(),
476            }]
477        );
478    }
479
480    #[test]
481    fn pairing_rejects_noncanonical_client_labels_without_consuming_the_code() {
482        let directory = tempfile::tempdir().expect("state directory");
483        let path = directory.path().join("auth.json");
484        let (auth, grant) = AuthStore::initialize(path).expect("initialize auth");
485
486        for label in [" Mac", "Mac ", "Mac\nterminal"] {
487            let error = auth
488                .pair(&grant.code, label)
489                .expect_err("noncanonical label must fail");
490            assert!(error.to_string().contains("client label"));
491        }
492
493        auth.pair(&grant.code, "Mac").expect("pair canonical label");
494    }
495
496    #[test]
497    fn opening_auth_state_rejects_noncanonical_or_duplicate_client_identity() {
498        let directory = tempfile::tempdir().expect("state directory");
499        let path = directory.path().join("auth.json");
500        let first_id = "00000000-0000-0000-0000-000000000001";
501        let second_id = "00000000-0000-0000-0000-000000000002";
502        let cases = [
503            (
504                vec![client("00000000-0000-0000-0000-00000000000A", "Mac", 1)],
505                "must be canonical",
506            ),
507            (
508                vec![client(first_id, "Mac", 1), client(first_id, "Phone", 2)],
509                "duplicate client IDs",
510            ),
511            (
512                vec![client(first_id, "Mac", 1), client(second_id, "Phone", 1)],
513                "duplicate token digests",
514            ),
515            (vec![client(first_id, " Mac", 1)], "client label"),
516        ];
517
518        for (clients, expected) in cases {
519            write_auth_state(&path, clients);
520            let error = match AuthStore::open(&path) {
521                Ok(_) => panic!("invalid auth state must fail"),
522                Err(error) => error,
523            };
524            assert!(error.to_string().contains(expected), "{error}");
525        }
526    }
527
528    #[test]
529    fn unpairing_revokes_the_token_and_blocks_the_stale_client() {
530        let directory = tempfile::tempdir().expect("state directory");
531        let path = directory.path().join("auth.json");
532        let (auth, first_code) = AuthStore::initialize(&path).expect("initialize auth");
533        let first = auth.pair(&first_code.code, "Mac").expect("pair Mac");
534        let second_code = auth.create_pairing_code().expect("second code");
535        let second = auth.pair(&second_code.code, "iPhone").expect("pair iPhone");
536        let third_code = auth.create_pairing_code().expect("third code");
537        let third = auth.pair(&third_code.code, "CLI").expect("pair CLI");
538
539        let removed = auth
540            .unpair_client(&first.client_id, &second.client_id)
541            .expect("unpair iPhone");
542        let stale_removal = auth
543            .unpair_client(&second.client_id, &third.client_id)
544            .expect("reject stale client");
545        let reopened = AuthStore::open(path).expect("reopen auth");
546
547        assert_eq!(
548            (
549                removed,
550                stale_removal,
551                reopened.authenticate(&second.token).is_err(),
552                reopened.authenticate(&first.token).is_ok(),
553                reopened.authenticate(&third.token).is_ok(),
554            ),
555            (true, false, true, true, true)
556        );
557    }
558
559    #[test]
560    fn creating_a_new_pairing_code_invalidates_the_previous_code_only() {
561        let directory = tempfile::tempdir().expect("state directory");
562        let path = directory.path().join("auth.json");
563        let (auth, bootstrap) = AuthStore::initialize(&path).expect("initialize auth");
564        let replacement = auth.create_pairing_code().expect("replacement code");
565
566        let error = auth
567            .pair(&bootstrap.code, "stale")
568            .expect_err("old code must fail");
569
570        assert!(matches!(error, Error::Unauthorized));
571        assert!(auth.pair(&replacement.code, "current").is_ok());
572        assert_eq!(
573            auth.pairing_status(&bootstrap.code)
574                .expect("replaced status"),
575            PairingStatus::Replaced
576        );
577    }
578
579    #[test]
580    fn pairing_status_tracks_a_durable_client_issuance() {
581        let directory = tempfile::tempdir().expect("state directory");
582        let path = directory.path().join("auth.json");
583        let (auth, grant) = AuthStore::initialize(&path).expect("initialize auth");
584        let pending = auth.pairing_status(&grant.code).expect("pending status");
585        auth.pair(&grant.code, "iPhone").expect("pair iPhone");
586        let reopened = AuthStore::open(path).expect("reopen auth");
587        let replacement = reopened.create_pairing_code().expect("replacement code");
588        let consumed = reopened
589            .pairing_status(&grant.code)
590            .expect("consumed status");
591
592        assert_eq!(
593            (pending, consumed),
594            (PairingStatus::Pending, PairingStatus::Consumed)
595        );
596        assert_eq!(
597            reopened
598                .pairing_status(&replacement.code)
599                .expect("replacement status"),
600            PairingStatus::Pending
601        );
602    }
603
604    #[test]
605    fn revoking_a_pairing_code_does_not_revoke_its_replacement() {
606        let directory = tempfile::tempdir().expect("state directory");
607        let path = directory.path().join("auth.json");
608        let (auth, revoked) = AuthStore::initialize(path).expect("initialize auth");
609
610        auth.revoke_pairing_code(&revoked.code)
611            .expect("revoke code");
612        assert!(auth.pair(&revoked.code, "stale").is_err());
613
614        let replacement = auth.create_pairing_code().expect("replacement code");
615        auth.revoke_pairing_code(&revoked.code)
616            .expect("revoke old code");
617        assert!(auth.pair(&replacement.code, "current").is_ok());
618    }
619
620    #[test]
621    fn pairing_code_is_not_created_at_the_client_limit() {
622        let directory = tempfile::tempdir().expect("state directory");
623        let path = directory.path().join("auth.json");
624        let (auth, mut grant) = AuthStore::initialize(path).expect("initialize auth");
625        for index in 0..MAX_CLIENTS {
626            auth.pair(&grant.code, &format!("client {index}"))
627                .expect("pair client");
628            if index + 1 < MAX_CLIENTS {
629                grant = auth.create_pairing_code().expect("next code");
630            }
631        }
632
633        let error = auth
634            .create_pairing_code()
635            .expect_err("client limit must reject a code");
636
637        assert!(error.to_string().contains("client limit"));
638    }
639
640    #[cfg(unix)]
641    #[test]
642    fn auth_state_is_owner_only() {
643        let directory = tempfile::tempdir().expect("state directory");
644        let path = directory.path().join("auth.json");
645        AuthStore::initialize(&path).expect("initialize auth");
646
647        let mode = fs::metadata(path)
648            .expect("auth metadata")
649            .permissions()
650            .mode()
651            & 0o777;
652
653        assert_eq!(mode, 0o600);
654    }
655}