Skip to main content

mobius_gateway/
auth.rs

1//! One-time pairing and independent bearer-token authentication.
2
3mod config;
4pub use config::AuthConfig;
5
6use std::collections::BTreeSet;
7use std::fs;
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
19/// Maximum UTF-8 byte length of one gateway client bearer credential.
20pub const MAX_CLIENT_CREDENTIAL_BYTES: usize = 512;
21const MAX_CLIENT_LABEL_BYTES: usize = 128;
22const REVOKED_PAIRING_EXPIRY: i64 = 0;
23const LOCAL_CLIENT_ID: &str = "00000000-0000-0000-0000-000000000001";
24const LOCAL_CLIENT_LABEL: &str = "Local möbius CLI";
25
26pub(crate) fn is_local_operator(id: &str) -> bool {
27    id == LOCAL_CLIENT_ID
28}
29
30#[derive(Clone, Serialize, Deserialize)]
31#[serde(deny_unknown_fields)]
32struct AuthState {
33    pending_pairing: Option<PendingPairing>,
34    clients: Vec<ClientToken>,
35}
36
37#[derive(Clone, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39struct PendingPairing {
40    digest: [u8; 32],
41    expires_at: i64,
42}
43
44#[derive(Clone, Serialize, Deserialize)]
45#[serde(deny_unknown_fields)]
46struct ClientToken {
47    id: String,
48    label: String,
49    digest: [u8; 32],
50    created_at: i64,
51}
52
53/// Identity returned after a successful authentication handshake.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct ClientIdentity {
56    /// The identifier.
57    pub id: String,
58    /// The label.
59    pub label: String,
60}
61
62/// A newly issued bearer token. Only the digest is persisted.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct IssuedToken {
65    /// The client identifier.
66    pub client_id: String,
67    /// The token.
68    pub token: String,
69}
70
71/// One pending code that may be consumed by exactly one client pairing.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct PairingGrant {
74    /// The code.
75    pub code: String,
76    /// The expires at.
77    pub expires_at: i64,
78}
79
80#[cfg(any(unix, test))]
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub(crate) enum PairingStatus {
83    Pending,
84    Consumed,
85    Replaced,
86}
87
88/// File-backed authentication state shared by all accepted connections.
89pub struct AuthStore {
90    path: PathBuf,
91    state: Mutex<AuthState>,
92    channel_identity: crate::channel::Identity,
93    config: AuthConfig,
94}
95
96impl AuthStore {
97    /// Initializes authentication with the required operator pairing policy.
98    /// # Errors
99    /// Returns an error for invalid policy or inaccessible authentication state.
100    pub fn initialize(
101        path: impl Into<PathBuf>,
102        config: AuthConfig,
103    ) -> Result<(Self, PairingGrant)> {
104        config.validate()?;
105        let path = path.into();
106        fs::create_dir_all(
107            path.parent()
108                .ok_or_else(|| Error::Config("authentication path has no parent".into()))?,
109        )?;
110        let channel_identity = crate::channel::Identity::open(&path)?;
111        let grant = new_pairing_grant(&channel_identity, config.pairing_lifetime_seconds)?;
112        let state = AuthState {
113            pending_pairing: Some(PendingPairing {
114                digest: digest(&grant.code),
115                expires_at: grant.expires_at,
116            }),
117            clients: Vec::new(),
118        };
119        save_auth_state(&path, &state, true)?;
120        Ok((
121            Self {
122                path,
123                state: Mutex::new(state),
124                channel_identity,
125                config,
126            },
127            grant,
128        ))
129    }
130
131    /// Opens authentication with the required operator pairing policy.
132    /// Existing clients remain valid when the configured limit is reduced.
133    /// # Errors
134    /// Returns an error for invalid policy, corrupt state, or inaccessible files.
135    pub fn open(path: impl Into<PathBuf>, config: AuthConfig) -> Result<Self> {
136        config.validate()?;
137        let path = path.into();
138        let contents = fs::read(&path)?;
139        if contents.len() > 2 * 1024 * 1024 {
140            return Err(Error::Config("authentication state is too large".into()));
141        }
142        let state: AuthState = serde_json::from_slice(&contents)?;
143        validate_auth_state(&state)?;
144        let channel_identity = crate::channel::Identity::open(&path)?;
145        Ok(Self {
146            path,
147            state: Mutex::new(state),
148            channel_identity,
149            config,
150        })
151    }
152
153    /// Consumes the pending code and appends an independently issued client token.
154    /// # Errors
155    ///
156    /// Returns an error if authentication fails or its stored state is invalid.
157    pub fn pair(&self, code: &str, client_label: &str) -> Result<IssuedToken> {
158        self.pair_at(code, client_label, unix_timestamp()?)
159    }
160
161    fn pair_at(&self, code: &str, client_label: &str, now: i64) -> Result<IssuedToken> {
162        validate_client_label(client_label)?;
163        let mut state = self.lock_state()?;
164        let Some(pending) = &state.pending_pairing else {
165            return Err(Error::Unauthorized);
166        };
167        if pending.expires_at <= now || !credential_matches(code, &pending.digest) {
168            return Err(Error::Unauthorized);
169        }
170        if state.clients.len() >= self.config.paired_clients {
171            return Err(Error::Config("paired client limit reached".into()));
172        }
173
174        let token = self.channel_identity.credential(&random_secret(2));
175        let client_id = pairing_client_id(code);
176        let mut next = state.clone();
177        next.pending_pairing = None;
178        next.clients.push(ClientToken {
179            id: client_id.clone(),
180            label: client_label.into(),
181            digest: digest(&token),
182            created_at: now,
183        });
184        save_auth_state(&self.path, &next, false)?;
185        *state = next;
186        Ok(IssuedToken { client_id, token })
187    }
188
189    /// Consumes the pending code and rotates one existing client token in place.
190    /// # Errors
191    ///
192    /// Returns an error if authentication fails or its stored state is invalid.
193    pub fn repair_pairing(
194        &self,
195        code: &str,
196        replacing_token_digest: &[u8; 32],
197        client_label: &str,
198    ) -> Result<IssuedToken> {
199        let now = unix_timestamp()?;
200        validate_client_label(client_label)?;
201        let mut state = self.lock_state()?;
202        let Some(pending) = &state.pending_pairing else {
203            return Err(Error::Unauthorized);
204        };
205        if pending.expires_at <= now || !credential_matches(code, &pending.digest) {
206            return Err(Error::Unauthorized);
207        }
208        let mut matched = None;
209        for (index, client) in state.clients.iter().enumerate() {
210            if bool::from(client.digest.ct_eq(replacing_token_digest)) {
211                matched = Some(index);
212            }
213        }
214        let Some(index) = matched else {
215            return Err(Error::Unauthorized);
216        };
217
218        let token = self.channel_identity.credential(&random_secret(2));
219        let mut next = state.clone();
220        next.pending_pairing = None;
221        let client = &mut next.clients[index];
222        client.label = client_label.into();
223        client.digest = digest(&token);
224        client.created_at = now;
225        let client_id = client.id.clone();
226        save_auth_state(&self.path, &next, false)?;
227        *state = next;
228        Ok(IssuedToken { client_id, token })
229    }
230
231    pub(crate) fn provision_local_client(&self) -> Result<IssuedToken> {
232        let token = self.channel_identity.credential(&random_secret(2));
233        let now = unix_timestamp()?;
234        let mut state = self.lock_state()?;
235        let mut next = state.clone();
236        if let Some(client) = next
237            .clients
238            .iter_mut()
239            .find(|client| client.id == LOCAL_CLIENT_ID)
240        {
241            client.digest = digest(&token);
242            client.created_at = now;
243        } else {
244            if next.clients.len() >= self.config.paired_clients {
245                return Err(Error::Config("paired client limit reached".into()));
246            }
247            next.clients.push(ClientToken {
248                id: LOCAL_CLIENT_ID.into(),
249                label: LOCAL_CLIENT_LABEL.into(),
250                digest: digest(&token),
251                created_at: now,
252            });
253        }
254        save_auth_state(&self.path, &next, false)?;
255        *state = next;
256        Ok(IssuedToken {
257            client_id: LOCAL_CLIENT_ID.into(),
258            token,
259        })
260    }
261
262    /// Replaces any unused pairing code without invalidating paired clients.
263    /// # Errors
264    ///
265    /// Returns an error if validation or an operation required by this function fails.
266    pub fn create_pairing_code(&self) -> Result<PairingGrant> {
267        let mut state = self.lock_state()?;
268        let grant =
269            new_pairing_grant(&self.channel_identity, self.config.pairing_lifetime_seconds)?;
270        let mut next = state.clone();
271        next.pending_pairing = Some(PendingPairing {
272            digest: digest(&grant.code),
273            expires_at: grant.expires_at,
274        });
275        save_auth_state(&self.path, &next, false)?;
276        *state = next;
277        Ok(grant)
278    }
279
280    /// Verifies a bearer token against every paired client digest.
281    /// # Errors
282    ///
283    /// Returns an error if authentication fails or its stored state is invalid.
284    pub fn authenticate(&self, token: &str) -> Result<ClientIdentity> {
285        if token.is_empty() || token.len() > MAX_CLIENT_CREDENTIAL_BYTES {
286            return Err(Error::Unauthorized);
287        }
288        let candidate = digest(token);
289        let state = self.lock_state()?;
290        let mut matched = None;
291        for client in &state.clients {
292            if bool::from(candidate.ct_eq(&client.digest)) {
293                matched = Some(ClientIdentity {
294                    id: client.id.clone(),
295                    label: client.label.clone(),
296                });
297            }
298        }
299        matched.ok_or(Error::Unauthorized)
300    }
301
302    pub(crate) fn clients(&self) -> Result<Vec<ClientIdentity>> {
303        Ok(self
304            .lock_state()?
305            .clients
306            .iter()
307            .map(|client| ClientIdentity {
308                id: client.id.clone(),
309                label: client.label.clone(),
310            })
311            .collect())
312    }
313
314    pub(crate) fn unpair_client(&self, actor_id: &str, client_id: &str) -> Result<bool> {
315        if actor_id == client_id
316            || Uuid::parse_str(actor_id).is_err()
317            || Uuid::parse_str(client_id).is_err()
318        {
319            return Ok(false);
320        }
321        let mut state = self.lock_state()?;
322        if !state.clients.iter().any(|client| client.id == actor_id) {
323            return Ok(false);
324        }
325        let Some(index) = state
326            .clients
327            .iter()
328            .position(|client| client.id == client_id)
329        else {
330            return Ok(false);
331        };
332        let mut next = state.clone();
333        next.clients.remove(index);
334        save_auth_state(&self.path, &next, false)?;
335        *state = next;
336        Ok(true)
337    }
338
339    #[cfg(any(unix, test))]
340    pub(crate) fn pairing_status(&self, code: &str) -> Result<PairingStatus> {
341        let state = self.lock_state()?;
342        if state
343            .clients
344            .iter()
345            .any(|client| client.id == pairing_client_id(code))
346        {
347            return Ok(PairingStatus::Consumed);
348        }
349        Ok(match &state.pending_pairing {
350            Some(pending) if credential_matches(code, &pending.digest) => PairingStatus::Pending,
351            _ => PairingStatus::Replaced,
352        })
353    }
354
355    #[cfg(any(unix, test))]
356    pub(crate) fn revoke_pairing_code(&self, code: &str) -> Result<()> {
357        let mut state = self.lock_state()?;
358        let Some(pending) = &state.pending_pairing else {
359            return Ok(());
360        };
361        if !credential_matches(code, &pending.digest) {
362            return Ok(());
363        }
364        let mut next = state.clone();
365        next.pending_pairing = Some(PendingPairing {
366            digest: digest(&random_secret(1)),
367            expires_at: REVOKED_PAIRING_EXPIRY,
368        });
369        save_auth_state(&self.path, &next, false)?;
370        *state = next;
371        Ok(())
372    }
373
374    fn lock_state(&self) -> Result<std::sync::MutexGuard<'_, AuthState>> {
375        self.state
376            .lock()
377            .map_err(|_| Error::Config("authentication state lock is poisoned".into()))
378    }
379
380    pub(crate) fn channel_handshake(&self) -> Result<snow::HandshakeState> {
381        self.channel_identity.responder()
382    }
383}
384
385fn validate_client_label(label: &str) -> Result<()> {
386    if label.is_empty()
387        || label != label.trim()
388        || label.len() > MAX_CLIENT_LABEL_BYTES
389        || label.chars().any(char::is_control)
390    {
391        return Err(Error::Config(format!(
392            "client label must be canonical, control-free, and 1–{MAX_CLIENT_LABEL_BYTES} bytes"
393        )));
394    }
395    Ok(())
396}
397
398fn validate_auth_state(state: &AuthState) -> Result<()> {
399    if state.clients.len() > crate::config::MAX_CAPACITY {
400        return Err(Error::Config(
401            "authentication state exceeds the client limit".into(),
402        ));
403    }
404    if state.pending_pairing.is_none() && state.clients.is_empty() {
405        return Err(Error::Config(
406            "authentication state has neither pairing nor client access".into(),
407        ));
408    }
409    let mut client_ids = BTreeSet::new();
410    let mut token_digests = BTreeSet::new();
411    for client in &state.clients {
412        let id = Uuid::parse_str(&client.id)
413            .map_err(|_| Error::Config("authentication client ID is invalid".into()))?;
414        if id.to_string() != client.id {
415            return Err(Error::Config(
416                "authentication client ID must be canonical".into(),
417            ));
418        }
419        if !client_ids.insert(client.id.as_str()) {
420            return Err(Error::Config(
421                "authentication state contains duplicate client IDs".into(),
422            ));
423        }
424        if !token_digests.insert(client.digest) {
425            return Err(Error::Config(
426                "authentication state contains duplicate token digests".into(),
427            ));
428        }
429        validate_client_label(&client.label)?;
430    }
431    Ok(())
432}
433
434fn credential_matches(candidate: &str, expected: &[u8; 32]) -> bool {
435    if candidate.is_empty() || candidate.len() > MAX_CLIENT_CREDENTIAL_BYTES {
436        return false;
437    }
438    bool::from(digest(candidate).ct_eq(expected))
439}
440
441fn digest(value: &str) -> [u8; 32] {
442    Sha256::digest(value.as_bytes()).into()
443}
444
445fn pairing_client_id(code: &str) -> String {
446    let mut bytes = [0; 16];
447    bytes.copy_from_slice(&digest(code)[..16]);
448    Uuid::from_bytes(bytes).to_string()
449}
450
451fn new_pairing_grant(
452    identity: &crate::channel::Identity,
453    lifetime_seconds: u64,
454) -> Result<PairingGrant> {
455    Ok(PairingGrant {
456        code: identity.credential(&random_secret(1)),
457        expires_at: unix_timestamp()?
458            .checked_add(
459                i64::try_from(lifetime_seconds)
460                    .map_err(|_| Error::Config("pairing lifetime is too large".into()))?,
461            )
462            .ok_or_else(|| Error::Config("pairing expiry overflow".into()))?,
463    })
464}
465
466fn random_secret(parts: usize) -> String {
467    (0..parts)
468        .map(|_| Uuid::new_v4().simple().to_string())
469        .collect()
470}
471
472fn unix_timestamp() -> Result<i64> {
473    let seconds = SystemTime::now()
474        .duration_since(UNIX_EPOCH)
475        .map_err(|_| Error::Config("system clock is before the Unix epoch".into()))?
476        .as_secs();
477    i64::try_from(seconds).map_err(|_| Error::Config("system clock is unsupported".into()))
478}
479
480fn save_auth_state(path: &Path, state: &AuthState, create_new: bool) -> Result<()> {
481    let parent = path
482        .parent()
483        .ok_or_else(|| Error::Config("authentication path has no parent".into()))?;
484    fs::create_dir_all(parent)?;
485    validate_auth_state(state)?;
486    let contents = serde_json::to_vec_pretty(state)?;
487    crate::publication::publish(path, &contents, create_new)
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493    #[cfg(unix)]
494    use std::os::unix::fs::PermissionsExt as _;
495
496    fn client(id: &str, label: &str, digest_byte: u8) -> ClientToken {
497        ClientToken {
498            id: id.into(),
499            label: label.into(),
500            digest: [digest_byte; 32],
501            created_at: 1,
502        }
503    }
504
505    fn write_auth_state(path: &Path, clients: Vec<ClientToken>) {
506        fs::write(
507            path,
508            serde_json::to_vec(&AuthState {
509                pending_pairing: None,
510                clients,
511            })
512            .expect("encode auth state"),
513        )
514        .expect("write auth state");
515    }
516
517    #[test]
518    fn pairing_three_clients_keeps_every_issued_token_valid() {
519        let directory = tempfile::tempdir().expect("state directory");
520        let path = directory.path().join("auth.json");
521        let (auth, first) =
522            AuthStore::initialize(&path, AuthConfig::default()).expect("initialize auth");
523        let first = auth.pair(&first.code, "Mac").expect("pair Mac");
524        let second_code = auth.create_pairing_code().expect("second code");
525        let second = auth.pair(&second_code.code, "iPhone").expect("pair iPhone");
526        let third_code = auth.create_pairing_code().expect("third code");
527        let third = auth.pair(&third_code.code, "CLI").expect("pair CLI");
528
529        assert!(auth.authenticate(&first.token).is_ok());
530        assert!(auth.authenticate(&second.token).is_ok());
531        assert!(auth.authenticate(&third.token).is_ok());
532    }
533
534    #[test]
535    fn provisioning_local_client_preserves_remote_pairing() {
536        let directory = tempfile::tempdir().expect("state directory");
537        let path = directory.path().join("auth.json");
538        let (auth, grant) =
539            AuthStore::initialize(path, AuthConfig::default()).expect("initialize auth");
540
541        let local = auth
542            .provision_local_client()
543            .expect("provision local client");
544        let remote = auth.pair(&grant.code, "iPhone").expect("pair iPhone");
545
546        assert!(auth.authenticate(&local.token).is_ok());
547        assert!(auth.authenticate(&remote.token).is_ok());
548    }
549
550    #[test]
551    fn paired_clients_are_listed_without_credentials() {
552        let directory = tempfile::tempdir().expect("state directory");
553        let path = directory.path().join("auth.json");
554        let (auth, grant) =
555            AuthStore::initialize(&path, AuthConfig::default()).expect("initialize auth");
556        auth.pair(&grant.code, "Mac").expect("pair Mac");
557
558        assert_eq!(
559            auth.clients().expect("paired clients"),
560            [ClientIdentity {
561                id: pairing_client_id(&grant.code),
562                label: "Mac".into(),
563            }]
564        );
565    }
566
567    #[test]
568    fn pairing_rejects_noncanonical_client_labels_without_consuming_the_code() {
569        let directory = tempfile::tempdir().expect("state directory");
570        let path = directory.path().join("auth.json");
571        let (auth, grant) =
572            AuthStore::initialize(path, AuthConfig::default()).expect("initialize auth");
573
574        for label in [" Mac", "Mac ", "Mac\nterminal"] {
575            let error = auth
576                .pair(&grant.code, label)
577                .expect_err("noncanonical label must fail");
578            assert!(error.to_string().contains("client label"));
579        }
580
581        auth.pair(&grant.code, "Mac").expect("pair canonical label");
582    }
583
584    #[test]
585    fn pairing_rejects_the_code_at_its_exact_expiry() {
586        let directory = tempfile::tempdir().expect("state directory");
587        let path = directory.path().join("auth.json");
588        let (auth, grant) =
589            AuthStore::initialize(path, AuthConfig::default()).expect("initialize auth");
590
591        assert!(matches!(
592            auth.pair_at(&grant.code, "Mac", grant.expires_at),
593            Err(Error::Unauthorized)
594        ));
595        auth.pair_at(&grant.code, "Mac", grant.expires_at - 1)
596            .expect("code is valid before expiry");
597    }
598
599    #[test]
600    fn opening_auth_state_rejects_noncanonical_or_duplicate_client_identity() {
601        let directory = tempfile::tempdir().expect("state directory");
602        let path = directory.path().join("auth.json");
603        let first_id = "00000000-0000-0000-0000-000000000001";
604        let second_id = "00000000-0000-0000-0000-000000000002";
605        let cases = [
606            (
607                vec![client("00000000-0000-0000-0000-00000000000A", "Mac", 1)],
608                "must be canonical",
609            ),
610            (
611                vec![client(first_id, "Mac", 1), client(first_id, "Phone", 2)],
612                "duplicate client IDs",
613            ),
614            (
615                vec![client(first_id, "Mac", 1), client(second_id, "Phone", 1)],
616                "duplicate token digests",
617            ),
618            (vec![client(first_id, " Mac", 1)], "client label"),
619        ];
620
621        for (clients, expected) in cases {
622            write_auth_state(&path, clients);
623            let error = match AuthStore::open(&path, AuthConfig::default()) {
624                Ok(_) => panic!("invalid auth state must fail"),
625                Err(error) => error,
626            };
627            assert!(error.to_string().contains(expected), "{error}");
628        }
629    }
630
631    #[test]
632    fn unpairing_revokes_the_token_and_blocks_the_stale_client() {
633        let directory = tempfile::tempdir().expect("state directory");
634        let path = directory.path().join("auth.json");
635        let (auth, first_code) =
636            AuthStore::initialize(&path, AuthConfig::default()).expect("initialize auth");
637        let first = auth.pair(&first_code.code, "Mac").expect("pair Mac");
638        let second_code = auth.create_pairing_code().expect("second code");
639        let second = auth.pair(&second_code.code, "iPhone").expect("pair iPhone");
640        let third_code = auth.create_pairing_code().expect("third code");
641        let third = auth.pair(&third_code.code, "CLI").expect("pair CLI");
642
643        let removed = auth
644            .unpair_client(&first.client_id, &second.client_id)
645            .expect("unpair iPhone");
646        let stale_removal = auth
647            .unpair_client(&second.client_id, &third.client_id)
648            .expect("reject stale client");
649        let reopened = AuthStore::open(path, AuthConfig::default()).expect("reopen auth");
650
651        assert_eq!(
652            (
653                removed,
654                stale_removal,
655                reopened.authenticate(&second.token).is_err(),
656                reopened.authenticate(&first.token).is_ok(),
657                reopened.authenticate(&third.token).is_ok(),
658            ),
659            (true, false, true, true, true)
660        );
661    }
662
663    #[test]
664    fn creating_a_new_pairing_code_invalidates_the_previous_code_only() {
665        let directory = tempfile::tempdir().expect("state directory");
666        let path = directory.path().join("auth.json");
667        let (auth, bootstrap) =
668            AuthStore::initialize(&path, AuthConfig::default()).expect("initialize auth");
669        let replacement = auth.create_pairing_code().expect("replacement code");
670
671        let error = auth
672            .pair(&bootstrap.code, "stale")
673            .expect_err("old code must fail");
674
675        assert!(matches!(error, Error::Unauthorized));
676        assert!(auth.pair(&replacement.code, "current").is_ok());
677        assert_eq!(
678            auth.pairing_status(&bootstrap.code)
679                .expect("replaced status"),
680            PairingStatus::Replaced
681        );
682    }
683
684    #[test]
685    fn pairing_status_tracks_a_durable_client_issuance() {
686        let directory = tempfile::tempdir().expect("state directory");
687        let path = directory.path().join("auth.json");
688        let (auth, grant) =
689            AuthStore::initialize(&path, AuthConfig::default()).expect("initialize auth");
690        let pending = auth.pairing_status(&grant.code).expect("pending status");
691        auth.pair(&grant.code, "iPhone").expect("pair iPhone");
692        let reopened = AuthStore::open(path, AuthConfig::default()).expect("reopen auth");
693        let replacement = reopened.create_pairing_code().expect("replacement code");
694        let consumed = reopened
695            .pairing_status(&grant.code)
696            .expect("consumed status");
697
698        assert_eq!(
699            (pending, consumed),
700            (PairingStatus::Pending, PairingStatus::Consumed)
701        );
702        assert_eq!(
703            reopened
704                .pairing_status(&replacement.code)
705                .expect("replacement status"),
706            PairingStatus::Pending
707        );
708    }
709
710    #[test]
711    fn revoking_a_pairing_code_does_not_revoke_its_replacement() {
712        let directory = tempfile::tempdir().expect("state directory");
713        let path = directory.path().join("auth.json");
714        let (auth, revoked) =
715            AuthStore::initialize(path, AuthConfig::default()).expect("initialize auth");
716
717        auth.revoke_pairing_code(&revoked.code)
718            .expect("revoke code");
719        assert!(auth.pair(&revoked.code, "stale").is_err());
720
721        let replacement = auth.create_pairing_code().expect("replacement code");
722        auth.revoke_pairing_code(&revoked.code)
723            .expect("revoke old code");
724        assert!(auth.pair(&replacement.code, "current").is_ok());
725    }
726
727    #[test]
728    fn repairing_at_the_client_limit_rotates_the_existing_client() {
729        let directory = tempfile::tempdir().expect("state directory");
730        let path = directory.path().join("auth.json");
731        let (auth, mut grant) =
732            AuthStore::initialize(path, AuthConfig::default()).expect("initialize auth");
733        let capacity = AuthConfig::default().paired_clients;
734        let mut first = None;
735        for index in 0..capacity {
736            let issued = auth
737                .pair(&grant.code, &format!("client {index}"))
738                .expect("pair client");
739            first.get_or_insert(issued);
740            if index + 1 < capacity {
741                grant = auth.create_pairing_code().expect("next code");
742            }
743        }
744        let first = first.expect("first client");
745        let replacement = auth.create_pairing_code().expect("repair code");
746
747        let error = auth
748            .pair(&replacement.code, "new client")
749            .expect_err("client limit must reject a new client");
750        let repaired = auth
751            .repair_pairing(&replacement.code, &digest(&first.token), "repaired client")
752            .expect("repair existing client");
753
754        assert!(error.to_string().contains("client limit"));
755        assert_eq!(repaired.client_id, first.client_id);
756        assert_eq!(auth.clients().expect("clients").len(), capacity);
757        assert!(auth.authenticate(&first.token).is_err());
758        assert!(auth.authenticate(&repaired.token).is_ok());
759    }
760
761    #[cfg(unix)]
762    #[test]
763    fn auth_state_is_owner_only() {
764        let directory = tempfile::tempdir().expect("state directory");
765        let path = directory.path().join("auth.json");
766        AuthStore::initialize(&path, AuthConfig::default()).expect("initialize auth");
767
768        let mode = fs::metadata(path)
769            .expect("auth metadata")
770            .permissions()
771            .mode()
772            & 0o777;
773
774        assert_eq!(mode, 0o600);
775    }
776
777    #[test]
778    fn adding_channel_identity_preserves_existing_authentication_state() {
779        let directory = tempfile::tempdir().expect("state directory");
780        let path = directory.path().join("auth.json");
781        let (auth, grant) =
782            AuthStore::initialize(&path, AuthConfig::default()).expect("initialize auth");
783        let issued = auth.pair(&grant.code, "existing client").expect("pair");
784        let legacy_token = "existing-opaque-token";
785        let mut state = auth.lock_state().unwrap().clone();
786        state.clients[0].digest = digest(legacy_token);
787        save_auth_state(&path, &state, false).unwrap();
788        let original = fs::read(&path).unwrap();
789        fs::remove_file(path.with_extension("channel-key")).unwrap();
790        let reopened =
791            AuthStore::open(&path, AuthConfig::default()).expect("open existing auth state");
792        assert_eq!(fs::read(&path).unwrap(), original);
793        assert_eq!(
794            reopened.authenticate(legacy_token).unwrap().id,
795            issued.client_id
796        );
797        let grant = reopened.create_pairing_code().unwrap();
798        let key = crate::channel::credential_key(&grant.code).unwrap();
799        let paired = reopened.pair(&grant.code, "encrypted client").unwrap();
800        assert_eq!(crate::channel::credential_key(&paired.token).unwrap(), key);
801        let reopened = AuthStore::open(&path, AuthConfig::default()).unwrap();
802        assert_eq!(
803            crate::channel::credential_key(&reopened.create_pairing_code().unwrap().code).unwrap(),
804            key
805        );
806        reopened
807            .unpair_client(&issued.client_id, &paired.client_id)
808            .unwrap();
809        assert!(reopened.authenticate(&paired.token).is_err());
810        assert!(reopened.authenticate(legacy_token).is_ok());
811    }
812}
813
814#[cfg(test)]
815mod configured_policy_tests {
816    use super::*;
817    #[test]
818    fn custom_pairing_policy_changes_lifetime_and_capacity_without_deleting_clients() {
819        let root = tempfile::tempdir().unwrap();
820        let path = root.path().join("auth.json");
821        let policy = AuthConfig {
822            paired_clients: 1,
823            pairing_lifetime_seconds: 30,
824        };
825        let (auth, grant) = AuthStore::initialize(&path, policy).unwrap();
826        let now = i64::try_from(
827            SystemTime::now()
828                .duration_since(UNIX_EPOCH)
829                .unwrap()
830                .as_secs(),
831        )
832        .unwrap();
833        assert!((29..=30).contains(&(grant.expires_at - now)));
834        auth.pair(&grant.code, "first").unwrap();
835        let second = auth.create_pairing_code().unwrap();
836        assert!(auth.pair(&second.code, "second").is_err());
837        let opened = AuthStore::open(
838            &path,
839            AuthConfig {
840                paired_clients: 2,
841                ..policy
842            },
843        )
844        .unwrap();
845        opened.pair(&second.code, "second").unwrap();
846    }
847}