1mod 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
19pub 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#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct ClientIdentity {
56 pub id: String,
58 pub label: String,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct IssuedToken {
65 pub client_id: String,
67 pub token: String,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct PairingGrant {
74 pub code: String,
76 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
88pub struct AuthStore {
90 path: PathBuf,
91 state: Mutex<AuthState>,
92 channel_identity: crate::channel::Identity,
93 config: AuthConfig,
94}
95
96impl AuthStore {
97 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 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 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 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 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 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}