1use 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#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct ClientIdentity {
50 pub id: String,
51 pub label: String,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct IssuedToken {
57 pub client_id: String,
58 pub token: String,
59}
60
61#[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
76pub struct AuthStore {
78 path: PathBuf,
79 state: Mutex<AuthState>,
80}
81
82impl AuthStore {
83 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 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 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 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 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}