1use std::path::PathBuf;
28use std::sync::Arc;
29
30use astrid_core::DeviceScope;
31use astrid_core::PrincipalId;
32use astrid_core::dirs::AstridHome;
33use astrid_core::identity::PrincipalUid;
34use astrid_crypto::IdentifierHash;
35use base64::Engine;
36use rand::{TryRng, rngs::SysRng};
37use serde::{Deserialize, Serialize};
38use subtle::ConstantTimeEq;
39#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
40use tracing::warn;
41
42mod durable_reservation;
43mod storage_migration;
44mod token_hash;
45use storage_migration::{read_legacy_source, retire_legacy_file};
46use token_hash::TokenHash;
47
48const STORE_SCHEMA_VERSION: u32 = 1;
49const TOKEN_HASH_CONTEXT: &str = "astrid.runtime.pair-device-token.identifier.v1";
50
51pub const TOKEN_PREFIX: &str = "astrid_pair_";
53
54pub const TOKEN_RAW_LEN: usize = 24;
57
58pub const MAX_EXPIRY_SECS: u64 = 60 * 60;
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct PairToken {
67 pub token_hash: String,
69 pub principal: PrincipalId,
71 pub expires_at_epoch: u64,
73 pub issued_at_epoch: u64,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub label: Option<String>,
79 #[serde(default = "default_full_scope")]
87 pub scope: DeviceScope,
88}
89
90pub const SYSTEM_KV_NAMESPACE: &str = "system:control:pair-tokens";
94const LEGACY_RECEIPT_KEY: &str = "migration:legacy-v1";
95const RECORD_PREFIX: &str = "record:";
96const MAX_RECORDS: usize = 4096;
97const MAX_RECORD_BYTES: usize = 16 * 1024;
98const MAX_LEGACY_BYTES: u64 = 4 * 1024 * 1024;
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(deny_unknown_fields)]
105pub struct DurablePairToken {
106 pub token_hash: String,
108 pub principal_uid: PrincipalUid,
110 pub expires_at_epoch: u64,
112 pub issued_at_epoch: u64,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub label: Option<String>,
117 pub scope: DeviceScope,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
122#[serde(deny_unknown_fields)]
123struct LegacyImportReceipt {
124 schema: u32,
125 source_digest: String,
126 record_count: u64,
127}
128
129#[derive(Clone)]
132pub struct DurablePairTokenStore {
133 backend: Arc<dyn astrid_storage::KvStore>,
134}
135
136impl DurablePairTokenStore {
137 pub fn new(backend: Arc<dyn astrid_storage::KvStore>) -> astrid_storage::StorageResult<Self> {
144 astrid_storage::ScopedKvStore::new(Arc::clone(&backend), SYSTEM_KV_NAMESPACE)?;
145 Ok(Self { backend })
146 }
147
148 fn key(hash: &TokenHash) -> String {
149 format!("{RECORD_PREFIX}{}", hash.as_str())
150 }
151
152 fn validate_record(token: &DurablePairToken) -> astrid_storage::StorageResult<()> {
153 let _ = TokenHash::parse(&token.token_hash)?;
154 if token.expires_at_epoch <= token.issued_at_epoch
155 || token.expires_at_epoch.saturating_sub(token.issued_at_epoch) > MAX_EXPIRY_SECS
156 || token.label.as_ref().is_some_and(|label| label.len() > 4096)
157 {
158 return Err(astrid_storage::StorageError::Serialization(
159 "pair-token record is outside its bounded schema".to_owned(),
160 ));
161 }
162 Ok(())
163 }
164
165 fn encode(token: &DurablePairToken) -> astrid_storage::StorageResult<Vec<u8>> {
166 Self::validate_record(token)?;
167 let value = serde_json::to_vec(token)
168 .map_err(|error| astrid_storage::StorageError::Serialization(error.to_string()))?;
169 if value.len() > MAX_RECORD_BYTES {
170 return Err(astrid_storage::StorageError::Serialization(
171 "pair-token record exceeds its bounded size".to_owned(),
172 ));
173 }
174 Ok(value)
175 }
176
177 fn decode(bytes: &[u8]) -> astrid_storage::StorageResult<DurablePairToken> {
178 if bytes.len() > MAX_RECORD_BYTES {
179 return Err(astrid_storage::StorageError::Serialization(
180 "pair-token record exceeds its bounded size".to_owned(),
181 ));
182 }
183 let token: DurablePairToken = serde_json::from_slice(bytes).map_err(|_| {
184 astrid_storage::StorageError::Serialization("invalid pair-token record".to_owned())
185 })?;
186 Self::validate_record(&token)?;
187 Ok(token)
188 }
189
190 async fn apply(
191 &self,
192 conditions: Vec<astrid_storage::KvBatchCondition>,
193 mutations: Vec<astrid_storage::KvBatchMutation>,
194 ) -> astrid_storage::StorageResult<bool> {
195 if !self.backend.supports_atomic_batch() {
196 return Err(astrid_storage::StorageError::Internal(
197 "pair-token storage requires an atomic KV backend".to_owned(),
198 ));
199 }
200 let batch = astrid_storage::KvMutationBatch::new(conditions, mutations)?;
201 Ok(self.backend.apply_batch(&batch).await?.applied)
202 }
203
204 pub async fn ensure_legacy_import(
213 &self,
214 home: &AstridHome,
215 principals: &astrid_storage::PrincipalDirectory,
216 ) -> astrid_storage::StorageResult<()> {
217 let path = PairTokenStore::path_for(home);
218 let source = read_legacy_source(&path, principals)?;
219 let receipt = self
220 .backend
221 .get(SYSTEM_KV_NAMESPACE, LEGACY_RECEIPT_KEY)
222 .await?;
223 if let Some(bytes) = receipt {
224 let receipt: LegacyImportReceipt = serde_json::from_slice(&bytes).map_err(|_| {
225 astrid_storage::StorageError::Internal(
226 "pair-token migration receipt is invalid".to_owned(),
227 )
228 })?;
229 if receipt.schema != 1 {
230 return Err(astrid_storage::StorageError::Internal(
231 "pair-token migration receipt schema is unsupported".to_owned(),
232 ));
233 }
234 if let Some((source_bytes, _)) = &source {
235 let digest = format!("blake3:{}", blake3::hash(source_bytes).to_hex());
236 if digest != receipt.source_digest {
237 return Err(astrid_storage::StorageError::Internal(
238 "legacy pair-token source conflicts with durable migration state"
239 .to_owned(),
240 ));
241 }
242 }
243 self.verify_count(receipt.record_count).await?;
244 if source.is_some() {
245 retire_legacy_file(&path, &receipt.source_digest)?;
246 }
247 return Ok(());
248 }
249
250 let Some((source_bytes, tokens)) = source else {
251 return Ok(());
252 };
253 if tokens.len() > MAX_RECORDS || tokens.len() > 500 {
254 return Err(astrid_storage::StorageError::Serialization(
255 "legacy pair-token store exceeds the bounded migration limit".to_owned(),
256 ));
257 }
258 let existing = self
259 .backend
260 .list_keys_with_prefix(SYSTEM_KV_NAMESPACE, RECORD_PREFIX)
261 .await?;
262 if !existing.is_empty() {
263 return Err(astrid_storage::StorageError::Internal(
264 "legacy pair-token source conflicts with existing durable state".to_owned(),
265 ));
266 }
267 let digest = format!("blake3:{}", blake3::hash(&source_bytes).to_hex());
268 let receipt = LegacyImportReceipt {
269 schema: 1,
270 source_digest: digest.clone(),
271 record_count: tokens.len() as u64,
272 };
273 let receipt_bytes = serde_json::to_vec(&receipt)
274 .map_err(|error| astrid_storage::StorageError::Serialization(error.to_string()))?;
275 let mut conditions = vec![astrid_storage::KvBatchCondition::ValueEquals {
276 key: astrid_storage::KvEntryKey::new(SYSTEM_KV_NAMESPACE, LEGACY_RECEIPT_KEY)?,
277 expected: None,
278 }];
279 let mut mutations = vec![astrid_storage::KvBatchMutation::Set {
280 key: astrid_storage::KvEntryKey::new(SYSTEM_KV_NAMESPACE, LEGACY_RECEIPT_KEY)?,
281 value: receipt_bytes,
282 }];
283 for token in &tokens {
284 let durable = DurablePairToken {
285 token_hash: token.token_hash.clone(),
286 principal_uid: principals.uid_for(&token.principal).map_err(|_| {
287 astrid_storage::StorageError::Internal(
288 "legacy pair-token principal is not an admitted immutable identity"
289 .to_owned(),
290 )
291 })?,
292 expires_at_epoch: token.expires_at_epoch,
293 issued_at_epoch: token.issued_at_epoch,
294 label: token.label.clone(),
295 scope: token.scope.clone(),
296 };
297 let value = Self::encode(&durable)?;
298 let key = Self::key(&TokenHash::parse(&durable.token_hash)?);
299 conditions.push(astrid_storage::KvBatchCondition::ValueEquals {
300 key: astrid_storage::KvEntryKey::new(SYSTEM_KV_NAMESPACE, &key)?,
301 expected: None,
302 });
303 mutations.push(astrid_storage::KvBatchMutation::Set {
304 key: astrid_storage::KvEntryKey::new(SYSTEM_KV_NAMESPACE, key)?,
305 value,
306 });
307 }
308 if !self.apply(conditions, mutations).await? {
309 return Err(astrid_storage::StorageError::Internal(
310 "legacy pair-token migration raced with another durable writer".to_owned(),
311 ));
312 }
313 self.verify_count(tokens.len() as u64).await?;
314 retire_legacy_file(&path, &digest)?;
315 Ok(())
316 }
317
318 async fn verify_count(&self, expected: u64) -> astrid_storage::StorageResult<()> {
319 let keys = self
320 .backend
321 .list_keys_with_prefix(SYSTEM_KV_NAMESPACE, RECORD_PREFIX)
322 .await?;
323 if keys.len() as u64 != expected || keys.len() > MAX_RECORDS {
324 return Err(astrid_storage::StorageError::Internal(
325 "durable pair-token migration read-back count mismatch".to_owned(),
326 ));
327 }
328 for key in keys {
329 let Some(value) = self.backend.get(SYSTEM_KV_NAMESPACE, &key).await? else {
330 return Err(astrid_storage::StorageError::Internal(
331 "durable pair-token migration read-back was incomplete".to_owned(),
332 ));
333 };
334 Self::decode(&value)?;
335 }
336 Ok(())
337 }
338
339 pub async fn list(&self) -> astrid_storage::StorageResult<Vec<DurablePairToken>> {
345 let mut keys = self
346 .backend
347 .list_keys_with_prefix(SYSTEM_KV_NAMESPACE, RECORD_PREFIX)
348 .await?;
349 if keys.len() > MAX_RECORDS {
350 return Err(astrid_storage::StorageError::Internal(
351 "pair-token storage exceeds its bounded record limit".to_owned(),
352 ));
353 }
354 keys.sort_unstable();
355 let mut records = Vec::with_capacity(keys.len());
356 for key in keys {
357 let Some(value) = self.backend.get(SYSTEM_KV_NAMESPACE, &key).await? else {
358 return Err(astrid_storage::StorageError::Internal(
359 "pair-token record disappeared during read".to_owned(),
360 ));
361 };
362 records.push(Self::decode(&value)?);
363 }
364 records.sort_by(|left, right| left.token_hash.cmp(&right.token_hash));
365 Ok(records)
366 }
367
368 pub async fn issue(&self, token: &DurablePairToken) -> astrid_storage::StorageResult<bool> {
374 let value = Self::encode(token)?;
375 let hash = TokenHash::parse(&token.token_hash)?;
376 let key = Self::key(&hash);
377 self.apply(
378 vec![astrid_storage::KvBatchCondition::ValueEquals {
379 key: astrid_storage::KvEntryKey::new(SYSTEM_KV_NAMESPACE, &key)?,
380 expected: None,
381 }],
382 vec![astrid_storage::KvBatchMutation::Set {
383 key: astrid_storage::KvEntryKey::new(SYSTEM_KV_NAMESPACE, key)?,
384 value,
385 }],
386 )
387 .await
388 }
389
390 pub async fn redeem(
397 &self,
398 token_hash: &str,
399 ) -> astrid_storage::StorageResult<Option<DurablePairToken>> {
400 let hash = TokenHash::parse(token_hash)?;
401 let key = Self::key(&hash);
402 let Some(value) = self.backend.get(SYSTEM_KV_NAMESPACE, &key).await? else {
403 return Ok(None);
404 };
405 let token = Self::decode(&value)?;
406 if token.expires_at_epoch <= now_epoch() {
407 let _ = self
408 .apply(
409 vec![astrid_storage::KvBatchCondition::ValueEquals {
410 key: astrid_storage::KvEntryKey::new(SYSTEM_KV_NAMESPACE, &key)?,
411 expected: Some(value),
412 }],
413 vec![astrid_storage::KvBatchMutation::Delete {
414 key: astrid_storage::KvEntryKey::new(SYSTEM_KV_NAMESPACE, key)?,
415 }],
416 )
417 .await?;
418 return Ok(None);
419 }
420 if self
421 .apply(
422 vec![astrid_storage::KvBatchCondition::ValueEquals {
423 key: astrid_storage::KvEntryKey::new(SYSTEM_KV_NAMESPACE, &key)?,
424 expected: Some(value),
425 }],
426 vec![astrid_storage::KvBatchMutation::Delete {
427 key: astrid_storage::KvEntryKey::new(SYSTEM_KV_NAMESPACE, key)?,
428 }],
429 )
430 .await?
431 {
432 Ok(Some(token))
433 } else {
434 Ok(None)
435 }
436 }
437}
438
439fn canonical_fingerprint(value: &str) -> Option<String> {
440 let (algorithm, digest) = value.split_once(':')?;
441 (algorithm == "blake3"
442 && digest.len() == 64
443 && digest.bytes().all(|byte| byte.is_ascii_hexdigit())
444 && digest == digest.to_ascii_lowercase())
445 .then(|| value.to_owned())
446}
447
448fn default_full_scope() -> DeviceScope {
451 DeviceScope::Full
452}
453
454#[derive(Debug)]
458pub struct PairTokenStore {
459 path: PathBuf,
460}
461
462impl PairTokenStore {
463 #[must_use]
465 pub const fn new(path: PathBuf) -> Self {
466 Self { path }
467 }
468
469 #[must_use]
471 pub fn path_for(home: &AstridHome) -> PathBuf {
472 home.etc_dir().join("pair-tokens.toml")
473 }
474
475 pub fn load(&self) -> Result<Vec<PairToken>, PairTokenStoreError> {
483 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
486 {
487 let _ = &self.path;
488 return Ok(Vec::new());
489 }
490 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
491 {
492 self.load_from_disk()
493 }
494 }
495
496 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
497 fn load_from_disk(&self) -> Result<Vec<PairToken>, PairTokenStoreError> {
498 let bytes = match std::fs::read(&self.path) {
499 Ok(b) => b,
500 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
501 Err(e) => return Err(PairTokenStoreError::Io(e)),
502 };
503 let text = std::str::from_utf8(&bytes).map_err(|e| {
504 PairTokenStoreError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e))
505 })?;
506 if text.trim().is_empty() {
507 if let Err(error) = self.save_to_disk(&[]) {
508 warn!(
509 path = %self.path.display(),
510 %error,
511 "could not normalize empty pair-token store"
512 );
513 }
514 return Ok(Vec::new());
515 }
516 let probe: SchemaProbe = toml::from_str(text).map_err(PairTokenStoreError::Toml)?;
517 if probe.schema_version > STORE_SCHEMA_VERSION {
518 return Err(PairTokenStoreError::Io(std::io::Error::new(
519 std::io::ErrorKind::InvalidData,
520 format!(
521 "pair-token store schema {} is newer than supported schema {STORE_SCHEMA_VERSION}",
522 probe.schema_version
523 ),
524 )));
525 }
526 let parsed: PersistedFile = toml::from_str(text).map_err(PairTokenStoreError::Toml)?;
527 if probe.schema_version == 0 {
528 let invalidated = parsed.pair_token.len();
529 self.save_to_disk(&[])?;
530 warn!(
531 path = %self.path.display(),
532 invalidated,
533 "invalidated legacy SHA-256 pair-token store"
534 );
535 return Ok(Vec::new());
536 }
537 Ok(parsed.pair_token)
538 }
539
540 pub fn save(&self, tokens: &[PairToken]) -> Result<(), PairTokenStoreError> {
546 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
549 {
550 let _ = (&self.path, tokens);
551 return Ok(());
552 }
553 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
554 {
555 self.save_to_disk(tokens)
556 }
557 }
558
559 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
560 fn save_to_disk(&self, tokens: &[PairToken]) -> Result<(), PairTokenStoreError> {
561 if let Some(parent) = self.path.parent() {
562 std::fs::create_dir_all(parent).map_err(PairTokenStoreError::Io)?;
563 }
564 let body = PersistedFile {
565 schema_version: STORE_SCHEMA_VERSION,
566 pair_token: tokens.to_vec(),
567 };
568 let text = toml::to_string_pretty(&body).map_err(PairTokenStoreError::TomlSer)?;
569
570 #[cfg(unix)]
571 {
572 use std::io::Write;
573 use std::os::unix::fs::OpenOptionsExt;
574 let tmp_path = self
575 .path
576 .with_extension(format!("{}.tmp", std::process::id()));
577 let mut f = std::fs::OpenOptions::new()
578 .write(true)
579 .create(true)
580 .truncate(true)
581 .mode(0o600)
582 .open(&tmp_path)
583 .map_err(PairTokenStoreError::Io)?;
584 f.write_all(text.as_bytes())
585 .map_err(PairTokenStoreError::Io)?;
586 f.sync_all().map_err(PairTokenStoreError::Io)?;
587 drop(f);
588 if let Err(e) = std::fs::rename(&tmp_path, &self.path) {
589 let _ = std::fs::remove_file(&tmp_path);
590 return Err(PairTokenStoreError::Io(e));
591 }
592 }
593 #[cfg(not(unix))]
594 {
595 std::fs::write(&self.path, text.as_bytes()).map_err(PairTokenStoreError::Io)?;
596 }
597 Ok(())
598 }
599}
600
601#[derive(Debug)]
603pub enum PairTokenStoreError {
604 Io(std::io::Error),
606 Toml(toml::de::Error),
608 TomlSer(toml::ser::Error),
610}
611
612impl std::fmt::Display for PairTokenStoreError {
613 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
614 match self {
615 Self::Io(e) => write!(f, "pair-token store io: {e}"),
616 Self::Toml(e) => write!(f, "pair-token store parse: {e}"),
617 Self::TomlSer(e) => write!(f, "pair-token store serialise: {e}"),
618 }
619 }
620}
621
622impl std::error::Error for PairTokenStoreError {}
623
624#[derive(Debug, Default, Deserialize)]
625struct SchemaProbe {
626 #[serde(default)]
627 schema_version: u32,
628}
629
630#[derive(Debug, Default, Serialize, Deserialize)]
631struct PersistedFile {
632 #[serde(default)]
633 schema_version: u32,
634 #[serde(default)]
635 pair_token: Vec<PairToken>,
636}
637
638#[must_use]
644pub fn generate_token() -> String {
645 let mut bytes = [0u8; TOKEN_RAW_LEN];
646 SysRng
647 .try_fill_bytes(&mut bytes)
648 .expect("OS CSPRNG unavailable while generating pair token");
649 format!(
650 "{TOKEN_PREFIX}{}",
651 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
652 )
653}
654
655#[must_use]
657pub fn hash_token(token: &str) -> String {
658 IdentifierHash::derive(TOKEN_HASH_CONTEXT, token.as_bytes()).to_prefixed_hex()
659}
660
661#[must_use]
663pub fn ct_hash_eq(a: &str, b: &str) -> bool {
664 if a.len() != b.len() {
665 return false;
666 }
667 a.as_bytes().ct_eq(b.as_bytes()).into()
668}
669
670#[must_use]
672pub fn now_epoch() -> u64 {
673 astrid_runtime::clock::now_epoch_secs()
674}
675
676pub fn prune_expired(tokens: &mut Vec<PairToken>) -> usize {
678 let now = now_epoch();
679 let before = tokens.len();
680 tokens.retain(|t| t.expires_at_epoch > now);
681 before.saturating_sub(tokens.len())
682}
683
684#[cfg(test)]
685mod tests {
686 use super::*;
687
688 #[test]
689 fn token_is_random_and_short() {
690 let a = generate_token();
691 let b = generate_token();
692 assert_ne!(a, b);
693 assert!(a.starts_with(TOKEN_PREFIX));
694 assert_eq!(a.strip_prefix(TOKEN_PREFIX).unwrap().len(), 32);
695 }
696
697 #[test]
698 fn hash_is_domain_separated_blake3() {
699 let h = hash_token("hello");
700 assert_eq!(
701 h,
702 "blake3:4e8275107b87254c5236647be8785404cdf3388d1ec2e149df1054de5a01e7a4"
703 );
704 assert_eq!(h.len(), 71);
705 assert_eq!(h, hash_token("hello"));
706 assert_ne!(h, hash_token("world"));
707 assert_ne!(h, crate::invite::hash_token("hello"));
708 }
709
710 #[test]
711 fn ct_hash_eq_checks_the_full_identifier_shape() {
712 let expected = hash_token("hello");
713 assert!(ct_hash_eq(&expected, &expected));
714 for index in [7, expected.len() / 2, expected.len() - 1] {
715 let mut different = expected.clone().into_bytes();
716 different[index] = if different[index] == b'0' { b'1' } else { b'0' };
717 assert!(!ct_hash_eq(
718 &expected,
719 std::str::from_utf8(&different).unwrap()
720 ));
721 }
722 assert!(!ct_hash_eq(&expected, &expected[..70]));
723 assert!(!ct_hash_eq(&expected, &format!("{expected}0")));
724 }
725
726 #[test]
727 fn round_trip_save_load() {
728 let dir = tempfile::tempdir().unwrap();
729 let store = PairTokenStore::new(dir.path().join("pair-tokens.toml"));
730 let token = PairToken {
731 token_hash: hash_token("pair alice phone"),
732 principal: PrincipalId::new("alice").unwrap(),
733 expires_at_epoch: 9_999_999_999,
734 issued_at_epoch: 1,
735 label: Some("phone".into()),
736 scope: DeviceScope::Scoped {
737 allow: vec!["self:*".into()],
738 deny: vec!["self:auth:pair".into()],
739 },
740 };
741 store.save(std::slice::from_ref(&token)).unwrap();
742 assert!(
743 std::fs::read_to_string(&store.path)
744 .unwrap()
745 .contains("schema_version = 1")
746 );
747 let loaded = store.load().unwrap();
748 assert_eq!(loaded, vec![token]);
749 }
750
751 #[test]
752 fn legacy_token_without_scope_loads_as_full() {
753 let dir = tempfile::tempdir().unwrap();
757 let path = dir.path().join("pair-tokens.toml");
758 let legacy = "schema_version = 1\n\
759 [[pair_token]]\n\
760 token_hash = \"blake3:4e8275107b87254c5236647be8785404cdf3388d1ec2e149df1054de5a01e7a4\"\n\
761 principal = \"alice\"\n\
762 expires_at_epoch = 9999999999\n\
763 issued_at_epoch = 1\n";
764 std::fs::write(&path, legacy).unwrap();
765 let loaded = PairTokenStore::new(path).load().unwrap();
766 assert_eq!(loaded.len(), 1);
767 assert_eq!(loaded[0].scope, DeviceScope::Full);
768 }
769
770 #[test]
771 fn legacy_sha256_store_is_invalidated_and_rewritten() {
772 let dir = tempfile::tempdir().unwrap();
773 let path = dir.path().join("pair-tokens.toml");
774 let legacy = "[[pair_token]]\n\
775 token_hash = \"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\"\n\
776 principal = \"alice\"\n\
777 expires_at_epoch = 9999999999\n\
778 issued_at_epoch = 1\n";
779 std::fs::write(&path, legacy).unwrap();
780
781 let store = PairTokenStore::new(path.clone());
782 assert!(store.load().unwrap().is_empty());
783 let rewritten = std::fs::read_to_string(&path).unwrap();
784 assert!(rewritten.contains("schema_version = 1"));
785 assert!(!rewritten.contains("[[pair_token]]"));
786 assert!(store.load().unwrap().is_empty());
787 }
788
789 #[cfg(unix)]
790 #[test]
791 fn read_only_legacy_sha256_store_fails_closed_without_rewrite() {
792 use std::os::unix::fs::PermissionsExt;
793
794 let dir = tempfile::tempdir().unwrap();
795 let path = dir.path().join("pair-tokens.toml");
796 let legacy = "[[pair_token]]\n\
797 token_hash = \"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\"\n\
798 principal = \"alice\"\n\
799 expires_at_epoch = 9999999999\n\
800 issued_at_epoch = 1\n";
801 std::fs::write(&path, legacy).unwrap();
802
803 let original = std::fs::metadata(dir.path()).unwrap().permissions();
804 let mut read_only = original.clone();
805 read_only.set_mode(0o500);
806 std::fs::set_permissions(dir.path(), read_only).unwrap();
807 let loaded = PairTokenStore::new(path.clone()).load();
808 std::fs::set_permissions(dir.path(), original).unwrap();
809
810 assert!(loaded.is_err());
811 assert_eq!(std::fs::read_to_string(path).unwrap(), legacy);
812 }
813
814 #[test]
815 fn future_store_is_rejected_without_rewrite() {
816 let dir = tempfile::tempdir().unwrap();
817 let path = dir.path().join("pair-tokens.toml");
818 let future = "schema_version = 2\nfuture_field = \"preserve me\"\n";
819 std::fs::write(&path, future).unwrap();
820
821 let err = PairTokenStore::new(path.clone()).load().unwrap_err();
822 assert!(err.to_string().contains("schema 2 is newer"));
823 assert_eq!(std::fs::read_to_string(path).unwrap(), future);
824 }
825
826 #[test]
827 fn malformed_store_is_rejected_without_rewrite() {
828 let dir = tempfile::tempdir().unwrap();
829 let path = dir.path().join("pair-tokens.toml");
830 let malformed = "schema_version = [not valid\n";
831 std::fs::write(&path, malformed).unwrap();
832
833 assert!(PairTokenStore::new(path.clone()).load().is_err());
834 assert_eq!(std::fs::read_to_string(path).unwrap(), malformed);
835 }
836
837 #[cfg(unix)]
838 #[test]
839 fn read_only_empty_file_still_loads_as_empty_vec() {
840 use std::os::unix::fs::PermissionsExt;
841
842 let dir = tempfile::tempdir().unwrap();
843 let store = PairTokenStore::new(dir.path().join("pair-tokens.toml"));
844 std::fs::write(&store.path, "").unwrap();
845
846 let original = std::fs::metadata(dir.path()).unwrap().permissions();
847 let mut read_only = original.clone();
848 read_only.set_mode(0o500);
849 std::fs::set_permissions(dir.path(), read_only).unwrap();
850 let loaded = store.load();
851 std::fs::set_permissions(dir.path(), original).unwrap();
852
853 assert_eq!(loaded.unwrap(), Vec::<PairToken>::new());
854 }
855
856 #[test]
857 fn prune_drops_expired() {
858 let now = now_epoch();
859 let mut v = vec![
860 PairToken {
861 token_hash: "a".into(),
862 principal: PrincipalId::default(),
863 expires_at_epoch: now.saturating_add(60),
864 issued_at_epoch: now,
865 label: None,
866 scope: DeviceScope::Full,
867 },
868 PairToken {
869 token_hash: "b".into(),
870 principal: PrincipalId::default(),
871 expires_at_epoch: now.saturating_sub(60),
872 issued_at_epoch: now.saturating_sub(120),
873 label: None,
874 scope: DeviceScope::Full,
875 },
876 ];
877 assert_eq!(prune_expired(&mut v), 1);
878 assert_eq!(v.len(), 1);
879 }
880}