1use 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#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct ClientIdentity {
53 pub id: String,
54 pub label: String,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct IssuedToken {
60 pub client_id: String,
61 pub token: String,
62}
63
64#[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
79pub struct AuthStore {
81 path: PathBuf,
82 state: Mutex<AuthState>,
83}
84
85impl AuthStore {
86 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 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 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 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 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}