Skip to main content

astrid_kernel/pair_token/
mod.rs

1//! Persistent pair-device token store (issue #756).
2//!
3//! Mirrors [`crate::invite`]'s shape but targets adding a NEW key
4//! to an EXISTING principal (the "pair device" flow) instead of
5//! minting a fresh principal.
6//!
7//! Durable records live in the fixed `system:control:pair-tokens` namespace,
8//! bound to immutable principal UIDs. `$ASTRID_HOME/etc/pair-tokens.toml` is
9//! accepted only by the bounded boot migration and is retired after verified
10//! readback.
11//!
12//! ## Threat model
13//!
14//! Same posture as the invite store: only domain-separated hashes are stored,
15//! redemption compares hashes in constant time, and mutation uses atomic
16//! system-owner KV batches. Pair-tokens are single-use only (no
17//! `remaining_uses` field). Redemption first claims an exact record with a
18//! durable reservation, performs the profile update, and then commits the
19//! deletion; a preparation failure releases only that reservation.
20//!
21//! Lifetime is capped at one hour (`MAX_EXPIRY_SECS`) — pair-tokens
22//! are meant for immediate use on a neighbouring device. Longer
23//! sharing windows are deliberately unsupported; if a user really
24//! wants a multi-day window they should redeem a separate invite
25//! (different principal) instead.
26
27use 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
51/// Type prefix carried by every raw device-pairing bearer token.
52pub const TOKEN_PREFIX: &str = "astrid_pair_";
53
54/// Length of the random token portion in bytes (192 bits → 32 chars
55/// URL-safe base64). Same sizing as invite tokens.
56pub const TOKEN_RAW_LEN: usize = 24;
57
58/// Hard cap on a single pair-token's lifetime. Pair-tokens are
59/// intended for immediate use ("scan this QR with your phone, now")
60/// — a longer window is deliberately unsupported.
61pub const MAX_EXPIRY_SECS: u64 = 60 * 60;
62
63/// On-disk persisted pair-token record. Raw token is never stored —
64/// only its domain-separated BLAKE3 identifier.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct PairToken {
67    /// `blake3:<hex>` identifier of the complete `astrid_pair_` bearer token.
68    pub token_hash: String,
69    /// Principal the new device's key will attach to.
70    pub principal: PrincipalId,
71    /// Wall-clock Unix-epoch at which this token expires.
72    pub expires_at_epoch: u64,
73    /// Wall-clock Unix-epoch at which the token was issued.
74    pub issued_at_epoch: u64,
75    /// Operator-supplied label (e.g. "alice's phone"). Persisted
76    /// alongside the new key entry once the token is redeemed.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub label: Option<String>,
79    /// Capability scope the redeemed device will authenticate under,
80    /// resolved + validated at issue time. Redeem stamps this onto the new
81    /// [`DeviceKey`](astrid_core::DeviceKey) so the paired device is
82    /// attenuated to exactly this scope on every transport. Defaults to
83    /// [`DeviceScope::Full`] when absent so any pre-scope on-disk token (and
84    /// older serialized records) round-trips as an unattenuated device,
85    /// preserving the prior behaviour.
86    #[serde(default = "default_full_scope")]
87    pub scope: DeviceScope,
88}
89
90/// Fixed host-only namespace for pair-device authority.  Records are keyed by
91/// token identifier and bind the immutable principal UID, never a mutable
92/// alias or alias-derived namespace.
93pub 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/// Durable UID-bound pair-token record.  The public [`PairToken`] remains the
101/// legacy-file compatibility type; runtime handlers use this record so alias
102/// renames cannot retarget an outstanding pairing authority.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(deny_unknown_fields)]
105pub struct DurablePairToken {
106    /// Canonical BLAKE3 identifier of the raw bearer token.
107    pub token_hash: String,
108    /// Immutable principal identity receiving the paired key.
109    pub principal_uid: PrincipalUid,
110    /// Wall-clock expiration.
111    pub expires_at_epoch: u64,
112    /// Wall-clock issuance time.
113    pub issued_at_epoch: u64,
114    /// Optional operator label.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub label: Option<String>,
117    /// Capability scope stamped onto the paired device.
118    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/// Storage-backed pair-token state with atomic conditional issue/consume/
130/// revoke operations and strict one-time legacy import.
131#[derive(Clone)]
132pub struct DurablePairTokenStore {
133    backend: Arc<dyn astrid_storage::KvStore>,
134}
135
136impl DurablePairTokenStore {
137    /// Bind the fixed system-control projection.
138    ///
139    /// # Errors
140    ///
141    /// Returns a storage error if the backend rejects the fixed pair-token
142    /// namespace.
143    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    /// Import the released alias-bearing TOML exactly once, resolving each
205    /// alias through the live immutable principal directory before mutation.
206    ///
207    /// # Errors
208    ///
209    /// Returns a storage error if the legacy source is unsafe or malformed,
210    /// contains an unknown alias, conflicts with durable state, or cannot be
211    /// durably imported.
212    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    /// List current UID-bound records in deterministic order.
340    ///
341    /// # Errors
342    ///
343    /// Returns a storage error if records cannot be listed or decoded.
344    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    /// Insert a UID-bound token iff its identifier is absent.
369    ///
370    /// # Errors
371    ///
372    /// Returns a storage error if the conditional batch cannot be applied.
373    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    /// Atomically consume one token; only one concurrent redeemer wins.
391    ///
392    /// # Errors
393    ///
394    /// Returns a storage error if the record cannot be read, decoded, or
395    /// conditionally removed.
396    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
448/// Serde default for [`PairToken::scope`] — `Full`, so an on-disk record
449/// written before scoping existed loads as an unattenuated device.
450fn default_full_scope() -> DeviceScope {
451    DeviceScope::Full
452}
453
454/// File-backed pair-token store. Read-modify-write uses atomic rename on Unix;
455/// all loads and mutators serialise on the kernel's `admin_write_lock` because
456/// a load can migrate legacy state.
457#[derive(Debug)]
458pub struct PairTokenStore {
459    path: PathBuf,
460}
461
462impl PairTokenStore {
463    /// Construct a store backed by `path`. Missing file → empty list.
464    #[must_use]
465    pub const fn new(path: PathBuf) -> Self {
466        Self { path }
467    }
468
469    /// Convenience: canonical path under `$ASTRID_HOME/etc`.
470    #[must_use]
471    pub fn path_for(home: &AstridHome) -> PathBuf {
472        home.etc_dir().join("pair-tokens.toml")
473    }
474
475    /// Read the persisted list. Missing file → empty Vec. A schema-0 store is
476    /// invalidated because its SHA-256 token identifiers cannot be
477    /// converted without the raw tokens.
478    ///
479    /// # Errors
480    /// Returns an error if the file exists but is unreadable or
481    /// malformed.
482    pub fn load(&self) -> Result<Vec<PairToken>, PairTokenStoreError> {
483        // Pairing persistence is native-only; the browser store is in-memory
484        // (always empty) and never reads disk.
485        #[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    /// Write the supplied list with write-then-rename and 0600 permissions on
541    /// Unix. An empty list retains the versioned TOML envelope.
542    ///
543    /// # Errors
544    /// Returns an error if the file cannot be written.
545    pub fn save(&self, tokens: &[PairToken]) -> Result<(), PairTokenStoreError> {
546        // Pairing persistence is native-only; the browser store is in-memory
547        // and silently drops writes rather than touching disk.
548        #[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/// Errors surfaced by [`PairTokenStore`] operations.
602#[derive(Debug)]
603pub enum PairTokenStoreError {
604    /// File-system IO error.
605    Io(std::io::Error),
606    /// `pair-tokens.toml` failed to parse.
607    Toml(toml::de::Error),
608    /// `pair-tokens.toml` failed to serialise.
609    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/// Generate a typed token with a random URL-safe-base64 secret from the OS CSPRNG.
639///
640/// # Panics
641///
642/// Panics if the OS CSPRNG is unavailable.
643#[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/// Derive a token identifier for storage and lookup.
656#[must_use]
657pub fn hash_token(token: &str) -> String {
658    IdentifierHash::derive(TOKEN_HASH_CONTEXT, token.as_bytes()).to_prefixed_hex()
659}
660
661/// Constant-time hash comparison.
662#[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/// Current wall-clock seconds since Unix epoch.
671#[must_use]
672pub fn now_epoch() -> u64 {
673    astrid_runtime::clock::now_epoch_secs()
674}
675
676/// Prune expired pair-tokens in place. Returns the count removed.
677pub 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        // A pair-token record written before the `scope` field existed has no
754        // `scope` key on disk; it must load as a Full-scope (unattenuated)
755        // device so the round-trip preserves the prior behaviour.
756        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}