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::path::{Path, PathBuf};
6use std::sync::Mutex;
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use serde::{Deserialize, Serialize};
10use sha2::{Digest as _, Sha256};
11use subtle::ConstantTimeEq as _;
12use uuid::Uuid;
13
14use crate::{Error, Result};
15
16const MAX_CREDENTIAL_BYTES: usize = 512;
17const MAX_CLIENT_LABEL_BYTES: usize = 128;
18const MAX_CLIENTS: usize = 32;
19const PAIRING_LIFETIME_SECONDS: i64 = 10 * 60;
20const REVOKED_PAIRING_EXPIRY: i64 = 0;
21const LOCAL_CLIENT_ID: &str = "00000000-0000-0000-0000-000000000001";
22const LOCAL_CLIENT_LABEL: &str = "Local möbius CLI";
23
24#[derive(Clone, Serialize, Deserialize)]
25#[serde(deny_unknown_fields)]
26struct AuthState {
27    pending_pairing: Option<PendingPairing>,
28    clients: Vec<ClientToken>,
29}
30
31#[derive(Clone, Serialize, Deserialize)]
32#[serde(deny_unknown_fields)]
33struct PendingPairing {
34    digest: [u8; 32],
35    expires_at: i64,
36}
37
38#[derive(Clone, Serialize, Deserialize)]
39#[serde(deny_unknown_fields)]
40struct ClientToken {
41    id: String,
42    label: String,
43    digest: [u8; 32],
44    created_at: i64,
45}
46
47/// Identity returned after a successful authentication handshake.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct ClientIdentity {
50    pub id: String,
51    pub label: String,
52}
53
54/// A newly issued bearer token. Only the digest is persisted.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct IssuedToken {
57    pub client_id: String,
58    pub token: String,
59}
60
61/// One pending code that may be consumed by exactly one new client.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct PairingGrant {
64    pub code: String,
65    pub expires_at: i64,
66}
67
68#[cfg(any(unix, test))]
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub(crate) enum PairingStatus {
71    Pending,
72    Consumed,
73    Replaced,
74}
75
76/// File-backed authentication state shared by all accepted connections.
77pub struct AuthStore {
78    path: PathBuf,
79    state: Mutex<AuthState>,
80}
81
82impl AuthStore {
83    /// Creates fresh auth state and returns the short-lived bootstrap code.
84    pub fn initialize(path: impl Into<PathBuf>) -> Result<(Self, PairingGrant)> {
85        let path = path.into();
86        let grant = new_pairing_grant()?;
87        let state = AuthState {
88            pending_pairing: Some(PendingPairing {
89                digest: digest(&grant.code),
90                expires_at: grant.expires_at,
91            }),
92            clients: Vec::new(),
93        };
94        save_auth_state(&path, &state, true)?;
95        Ok((
96            Self {
97                path,
98                state: Mutex::new(state),
99            },
100            grant,
101        ))
102    }
103
104    /// Opens previously initialized authentication state.
105    pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
106        let path = path.into();
107        let contents = fs::read(&path)?;
108        if contents.len() > 64 * 1024 {
109            return Err(Error::Config("authentication state is too large".into()));
110        }
111        let state: AuthState = serde_json::from_slice(&contents)?;
112        validate_auth_state(&state)?;
113        Ok(Self {
114            path,
115            state: Mutex::new(state),
116        })
117    }
118
119    /// Consumes the pending code and appends an independently issued client token.
120    pub fn pair(&self, code: &str, client_label: &str) -> Result<IssuedToken> {
121        self.pair_at(code, client_label, unix_timestamp()?)
122    }
123
124    fn pair_at(&self, code: &str, client_label: &str, now: i64) -> Result<IssuedToken> {
125        validate_client_label(client_label)?;
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    crate::publication::publish(path, &contents, create_new)
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    #[cfg(unix)]
401    use std::os::unix::fs::PermissionsExt as _;
402
403    fn client(id: &str, label: &str, digest_byte: u8) -> ClientToken {
404        ClientToken {
405            id: id.into(),
406            label: label.into(),
407            digest: [digest_byte; 32],
408            created_at: 1,
409        }
410    }
411
412    fn write_auth_state(path: &Path, clients: Vec<ClientToken>) {
413        fs::write(
414            path,
415            serde_json::to_vec(&AuthState {
416                pending_pairing: None,
417                clients,
418            })
419            .expect("encode auth state"),
420        )
421        .expect("write auth state");
422    }
423
424    #[test]
425    fn pairing_three_clients_keeps_every_issued_token_valid() {
426        let directory = tempfile::tempdir().expect("state directory");
427        let path = directory.path().join("auth.json");
428        let (auth, first) = AuthStore::initialize(&path).expect("initialize auth");
429        let first = auth.pair(&first.code, "Mac").expect("pair Mac");
430        let second_code = auth.create_pairing_code().expect("second code");
431        let second = auth.pair(&second_code.code, "iPhone").expect("pair iPhone");
432        let third_code = auth.create_pairing_code().expect("third code");
433        let third = auth.pair(&third_code.code, "CLI").expect("pair CLI");
434
435        assert!(auth.authenticate(&first.token).is_ok());
436        assert!(auth.authenticate(&second.token).is_ok());
437        assert!(auth.authenticate(&third.token).is_ok());
438    }
439
440    #[test]
441    fn provisioning_local_client_preserves_remote_pairing() {
442        let directory = tempfile::tempdir().expect("state directory");
443        let path = directory.path().join("auth.json");
444        let (auth, grant) = AuthStore::initialize(path).expect("initialize auth");
445
446        let local = auth
447            .provision_local_client()
448            .expect("provision local client");
449        let remote = auth.pair(&grant.code, "iPhone").expect("pair iPhone");
450
451        assert!(auth.authenticate(&local.token).is_ok());
452        assert!(auth.authenticate(&remote.token).is_ok());
453    }
454
455    #[test]
456    fn paired_clients_are_listed_without_credentials() {
457        let directory = tempfile::tempdir().expect("state directory");
458        let path = directory.path().join("auth.json");
459        let (auth, grant) = AuthStore::initialize(&path).expect("initialize auth");
460        auth.pair(&grant.code, "Mac").expect("pair Mac");
461
462        assert_eq!(
463            auth.clients().expect("paired clients"),
464            [ClientIdentity {
465                id: pairing_client_id(&grant.code),
466                label: "Mac".into(),
467            }]
468        );
469    }
470
471    #[test]
472    fn pairing_rejects_noncanonical_client_labels_without_consuming_the_code() {
473        let directory = tempfile::tempdir().expect("state directory");
474        let path = directory.path().join("auth.json");
475        let (auth, grant) = AuthStore::initialize(path).expect("initialize auth");
476
477        for label in [" Mac", "Mac ", "Mac\nterminal"] {
478            let error = auth
479                .pair(&grant.code, label)
480                .expect_err("noncanonical label must fail");
481            assert!(error.to_string().contains("client label"));
482        }
483
484        auth.pair(&grant.code, "Mac").expect("pair canonical label");
485    }
486
487    #[test]
488    fn pairing_rejects_the_code_at_its_exact_expiry() {
489        let directory = tempfile::tempdir().expect("state directory");
490        let path = directory.path().join("auth.json");
491        let (auth, grant) = AuthStore::initialize(path).expect("initialize auth");
492
493        assert!(matches!(
494            auth.pair_at(&grant.code, "Mac", grant.expires_at),
495            Err(Error::Unauthorized)
496        ));
497        auth.pair_at(&grant.code, "Mac", grant.expires_at - 1)
498            .expect("code is valid before expiry");
499    }
500
501    #[test]
502    fn opening_auth_state_rejects_noncanonical_or_duplicate_client_identity() {
503        let directory = tempfile::tempdir().expect("state directory");
504        let path = directory.path().join("auth.json");
505        let first_id = "00000000-0000-0000-0000-000000000001";
506        let second_id = "00000000-0000-0000-0000-000000000002";
507        let cases = [
508            (
509                vec![client("00000000-0000-0000-0000-00000000000A", "Mac", 1)],
510                "must be canonical",
511            ),
512            (
513                vec![client(first_id, "Mac", 1), client(first_id, "Phone", 2)],
514                "duplicate client IDs",
515            ),
516            (
517                vec![client(first_id, "Mac", 1), client(second_id, "Phone", 1)],
518                "duplicate token digests",
519            ),
520            (vec![client(first_id, " Mac", 1)], "client label"),
521        ];
522
523        for (clients, expected) in cases {
524            write_auth_state(&path, clients);
525            let error = match AuthStore::open(&path) {
526                Ok(_) => panic!("invalid auth state must fail"),
527                Err(error) => error,
528            };
529            assert!(error.to_string().contains(expected), "{error}");
530        }
531    }
532
533    #[test]
534    fn unpairing_revokes_the_token_and_blocks_the_stale_client() {
535        let directory = tempfile::tempdir().expect("state directory");
536        let path = directory.path().join("auth.json");
537        let (auth, first_code) = AuthStore::initialize(&path).expect("initialize auth");
538        let first = auth.pair(&first_code.code, "Mac").expect("pair Mac");
539        let second_code = auth.create_pairing_code().expect("second code");
540        let second = auth.pair(&second_code.code, "iPhone").expect("pair iPhone");
541        let third_code = auth.create_pairing_code().expect("third code");
542        let third = auth.pair(&third_code.code, "CLI").expect("pair CLI");
543
544        let removed = auth
545            .unpair_client(&first.client_id, &second.client_id)
546            .expect("unpair iPhone");
547        let stale_removal = auth
548            .unpair_client(&second.client_id, &third.client_id)
549            .expect("reject stale client");
550        let reopened = AuthStore::open(path).expect("reopen auth");
551
552        assert_eq!(
553            (
554                removed,
555                stale_removal,
556                reopened.authenticate(&second.token).is_err(),
557                reopened.authenticate(&first.token).is_ok(),
558                reopened.authenticate(&third.token).is_ok(),
559            ),
560            (true, false, true, true, true)
561        );
562    }
563
564    #[test]
565    fn creating_a_new_pairing_code_invalidates_the_previous_code_only() {
566        let directory = tempfile::tempdir().expect("state directory");
567        let path = directory.path().join("auth.json");
568        let (auth, bootstrap) = AuthStore::initialize(&path).expect("initialize auth");
569        let replacement = auth.create_pairing_code().expect("replacement code");
570
571        let error = auth
572            .pair(&bootstrap.code, "stale")
573            .expect_err("old code must fail");
574
575        assert!(matches!(error, Error::Unauthorized));
576        assert!(auth.pair(&replacement.code, "current").is_ok());
577        assert_eq!(
578            auth.pairing_status(&bootstrap.code)
579                .expect("replaced status"),
580            PairingStatus::Replaced
581        );
582    }
583
584    #[test]
585    fn pairing_status_tracks_a_durable_client_issuance() {
586        let directory = tempfile::tempdir().expect("state directory");
587        let path = directory.path().join("auth.json");
588        let (auth, grant) = AuthStore::initialize(&path).expect("initialize auth");
589        let pending = auth.pairing_status(&grant.code).expect("pending status");
590        auth.pair(&grant.code, "iPhone").expect("pair iPhone");
591        let reopened = AuthStore::open(path).expect("reopen auth");
592        let replacement = reopened.create_pairing_code().expect("replacement code");
593        let consumed = reopened
594            .pairing_status(&grant.code)
595            .expect("consumed status");
596
597        assert_eq!(
598            (pending, consumed),
599            (PairingStatus::Pending, PairingStatus::Consumed)
600        );
601        assert_eq!(
602            reopened
603                .pairing_status(&replacement.code)
604                .expect("replacement status"),
605            PairingStatus::Pending
606        );
607    }
608
609    #[test]
610    fn revoking_a_pairing_code_does_not_revoke_its_replacement() {
611        let directory = tempfile::tempdir().expect("state directory");
612        let path = directory.path().join("auth.json");
613        let (auth, revoked) = AuthStore::initialize(path).expect("initialize auth");
614
615        auth.revoke_pairing_code(&revoked.code)
616            .expect("revoke code");
617        assert!(auth.pair(&revoked.code, "stale").is_err());
618
619        let replacement = auth.create_pairing_code().expect("replacement code");
620        auth.revoke_pairing_code(&revoked.code)
621            .expect("revoke old code");
622        assert!(auth.pair(&replacement.code, "current").is_ok());
623    }
624
625    #[test]
626    fn pairing_code_is_not_created_at_the_client_limit() {
627        let directory = tempfile::tempdir().expect("state directory");
628        let path = directory.path().join("auth.json");
629        let (auth, mut grant) = AuthStore::initialize(path).expect("initialize auth");
630        for index in 0..MAX_CLIENTS {
631            auth.pair(&grant.code, &format!("client {index}"))
632                .expect("pair client");
633            if index + 1 < MAX_CLIENTS {
634                grant = auth.create_pairing_code().expect("next code");
635            }
636        }
637
638        let error = auth
639            .create_pairing_code()
640            .expect_err("client limit must reject a code");
641
642        assert!(error.to_string().contains("client limit"));
643    }
644
645    #[cfg(unix)]
646    #[test]
647    fn auth_state_is_owner_only() {
648        let directory = tempfile::tempdir().expect("state directory");
649        let path = directory.path().join("auth.json");
650        AuthStore::initialize(&path).expect("initialize auth");
651
652        let mode = fs::metadata(path)
653            .expect("auth metadata")
654            .permissions()
655            .mode()
656            & 0o777;
657
658        assert_eq!(mode, 0o600);
659    }
660}