Skip to main content

auths_core/trust/
pinned.rs

1//! Pin records for trusted identities.
2//!
3//! This module defines the data structures for storing pinned identity roots,
4//! enabling TOFU (Trust On First Use) and key rotation tracking.
5
6use std::fs;
7use std::io::Write;
8use std::path::PathBuf;
9
10use auths_verifier::PublicKeyHex;
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13
14use crate::error::TrustError;
15
16/// A pinned identity root — what we trusted and when.
17///
18/// This record stores the state of a trusted identity at the time of pinning,
19/// including KEL context for rotation-aware verification.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct PinnedIdentity {
22    /// The DID being pinned (e.g., "did:keri:EXq5...")
23    pub did: String,
24
25    /// Root public key, raw bytes stored as lowercase hex.
26    pub public_key_hex: PublicKeyHex,
27
28    /// Curve of the pinned key. Defaults to P-256 when absent (backward compat
29    /// with pin files created before this field existed).
30    #[serde(default)]
31    pub curve: auths_crypto::CurveType,
32
33    /// KEL tip SAID at the time of pinning (enables rotation continuity check).
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub kel_tip_said: Option<String>,
36
37    /// KEL sequence number at time of pinning.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub kel_sequence: Option<u128>,
40
41    /// When this pin was created.
42    pub first_seen: DateTime<Utc>,
43
44    /// Where we learned this identity (repo URL, file path, "manual", etc.)
45    pub origin: String,
46
47    /// How this pin was established.
48    pub trust_level: TrustLevel,
49}
50
51impl PinnedIdentity {
52    /// Decode the pinned public key to raw bytes.
53    ///
54    /// Validates hex at construction; this should never fail on a well-formed pin.
55    pub fn public_key_bytes(&self) -> Result<Vec<u8>, TrustError> {
56        hex::decode(self.public_key_hex.as_str()).map_err(|e| {
57            TrustError::InvalidData(format!("Corrupt pin for {}: invalid hex: {}", self.did, e))
58        })
59    }
60
61    /// Check if the pinned key matches the given raw bytes.
62    ///
63    /// Comparison is always on decoded bytes, never on string representation.
64    /// This handles case differences and other encoding variations.
65    pub fn key_matches(&self, presented_pk: &[u8]) -> Result<bool, TrustError> {
66        use subtle::ConstantTimeEq;
67        Ok(self.public_key_bytes()?.ct_eq(presented_pk).into())
68    }
69}
70
71/// How a pin was established.
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
73#[serde(rename_all = "snake_case")]
74pub enum TrustLevel {
75    /// Accepted on first use (interactive)
76    Tofu,
77
78    /// Manually pinned via `auths trust pin` or `--issuer-pk`
79    Manual,
80
81    /// Loaded from a roots.json org policy file
82    OrgPolicy,
83}
84
85/// File-backed store of pinned identities.
86///
87/// Storage format: a single JSON array file (`~/.auths/known_identities.json`).
88/// All mutations are atomic (write to temp + rename).
89/// Concurrent access is guarded by an advisory lock file.
90///
91/// # Example
92///
93/// ```ignore
94/// use auths_core::trust::PinnedIdentityStore;
95///
96/// let store = PinnedIdentityStore::new(PinnedIdentityStore::default_path());
97///
98/// // Look up a pinned identity
99/// if let Some(pin) = store.lookup("did:keri:ETest...")? {
100///     println!("Pinned key: {}", pin.public_key_hex);
101/// }
102/// ```
103pub struct PinnedIdentityStore {
104    path: PathBuf,
105}
106
107#[allow(clippy::disallowed_methods)] // INVARIANT: PinnedIdentityStore is a file-backed adapter — I/O is its purpose
108#[allow(clippy::disallowed_types)]
109impl PinnedIdentityStore {
110    /// Create a store at the given path.
111    pub fn new(path: PathBuf) -> Self {
112        Self { path }
113    }
114
115    /// Default path: `~/.auths/known_identities.json`
116    #[allow(clippy::disallowed_methods)] // INVARIANT: designated home-dir resolution for pin store default
117    pub fn default_path() -> PathBuf {
118        dirs::home_dir()
119            .unwrap_or_else(|| PathBuf::from("."))
120            .join(".auths")
121            .join("known_identities.json")
122    }
123
124    /// Look up a pinned identity by DID.
125    pub fn lookup(&self, did: &str) -> Result<Option<PinnedIdentity>, TrustError> {
126        let _lock = self.lock()?;
127        Ok(self.read_all()?.into_iter().find(|e| e.did == did))
128    }
129
130    /// Pin a new identity.
131    ///
132    /// The public key hex is validated at pin-time.
133    /// Errors if the DID is already pinned (use `update` for rotation).
134    pub fn pin(&self, identity: PinnedIdentity) -> Result<(), TrustError> {
135        let _lock = self.lock()?;
136        let mut entries = self.read_all()?;
137        if entries.iter().any(|e| e.did == identity.did) {
138            return Err(TrustError::AlreadyExists(format!(
139                "Identity {} already pinned. Use `auths trust remove` first, or rotation \
140                 will be handled automatically via continuity checking.",
141                identity.did
142            )));
143        }
144        entries.push(identity);
145        self.write_all(&entries)
146    }
147
148    /// Update an existing pin (after verified rotation).
149    pub fn update(&self, identity: PinnedIdentity) -> Result<(), TrustError> {
150        let _lock = self.lock()?;
151        let mut entries = self.read_all()?;
152        let pos = entries
153            .iter()
154            .position(|e| e.did == identity.did)
155            .ok_or_else(|| {
156                TrustError::NotFound(format!(
157                    "Cannot update: identity {} not found in pin store.",
158                    identity.did
159                ))
160            })?;
161        entries[pos] = identity;
162        self.write_all(&entries)
163    }
164
165    /// Remove a pinned identity by DID.
166    ///
167    /// Returns `true` if an entry was removed, `false` if not found.
168    pub fn remove(&self, did: &str) -> Result<bool, TrustError> {
169        let _lock = self.lock()?;
170        let mut entries = self.read_all()?;
171        let before = entries.len();
172        entries.retain(|e| e.did != did);
173        if entries.len() < before {
174            self.write_all(&entries)?;
175            Ok(true)
176        } else {
177            Ok(false)
178        }
179    }
180
181    /// Check if an identity is pinned (lightweight existence check).
182    ///
183    /// More efficient than `lookup` when you only need a yes/no answer.
184    pub fn is_pinned(&self, did: &str) -> Result<bool, TrustError> {
185        let _lock = self.lock()?;
186        Ok(self.read_all()?.iter().any(|e| e.did == did))
187    }
188
189    /// List all pinned identities.
190    pub fn list(&self) -> Result<Vec<PinnedIdentity>, TrustError> {
191        let _lock = self.lock()?;
192        self.read_all()
193    }
194
195    // --- Internal ---
196
197    fn read_all(&self) -> Result<Vec<PinnedIdentity>, TrustError> {
198        if !self.path.exists() {
199            return Ok(vec![]);
200        }
201        let content = fs::read_to_string(&self.path)?;
202        let entries: Vec<PinnedIdentity> = serde_json::from_str(&content).map_err(|e| {
203            TrustError::InvalidData(format!(
204                "Corrupt pin store at {:?}: {}. Consider deleting and re-pinning.",
205                self.path, e
206            ))
207        })?;
208        Ok(entries)
209    }
210
211    fn write_all(&self, entries: &[PinnedIdentity]) -> Result<(), TrustError> {
212        if let Some(parent) = self.path.parent() {
213            fs::create_dir_all(parent)?;
214        }
215        let tmp = self.path.with_extension("tmp");
216        {
217            let mut file = fs::File::create(&tmp)?;
218            let json = serde_json::to_string_pretty(entries)?;
219            file.write_all(json.as_bytes())?;
220            file.write_all(b"\n")?;
221            file.sync_all()?;
222        }
223        fs::rename(&tmp, &self.path)?;
224        Ok(())
225    }
226
227    fn lock(&self) -> Result<LockGuard, TrustError> {
228        let lock_path = self.path.with_extension("lock");
229        if let Some(parent) = lock_path.parent() {
230            fs::create_dir_all(parent)?;
231        }
232        LockGuard::acquire(lock_path)
233    }
234}
235
236/// Simple advisory file lock. Blocks until acquired. Released on drop by closing the fd.
237///
238/// The lock file is NOT deleted on drop. Deleting creates a race where two
239/// threads acquire flock on different inodes simultaneously.
240#[allow(clippy::disallowed_types)] // INVARIANT: file-lock guard — holds an open file descriptor
241struct LockGuard {
242    _file: fs::File,
243}
244
245#[allow(clippy::disallowed_methods)] // INVARIANT: file locking is inherently I/O
246#[allow(clippy::disallowed_types)]
247impl LockGuard {
248    fn acquire(path: PathBuf) -> Result<Self, TrustError> {
249        let file = fs::OpenOptions::new()
250            .create(true)
251            .truncate(true)
252            .write(true)
253            .open(&path)?;
254
255        #[cfg(unix)]
256        {
257            use std::os::unix::io::AsRawFd;
258            let fd = file.as_raw_fd();
259            let ret = unsafe { libc::flock(fd, libc::LOCK_EX) };
260            if ret != 0 {
261                return Err(TrustError::Lock(format!(
262                    "Failed to acquire lock on {:?}",
263                    path
264                )));
265            }
266        }
267
268        #[cfg(not(unix))]
269        {
270            // On non-Unix, best-effort: existence of lock file is the lock.
271        }
272
273        Ok(Self { _file: file })
274    }
275}
276
277#[cfg(test)]
278#[allow(clippy::disallowed_methods)]
279mod tests {
280    use super::*;
281
282    fn make_test_pin() -> PinnedIdentity {
283        PinnedIdentity {
284            did: "did:keri:ETest123".to_string(),
285            public_key_hex: PublicKeyHex::new_unchecked(
286                "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
287            ),
288            curve: auths_crypto::CurveType::Ed25519,
289            kel_tip_said: Some("ETip".to_string()),
290            kel_sequence: Some(0),
291            first_seen: Utc::now(),
292            origin: "test".to_string(),
293            trust_level: TrustLevel::Tofu,
294        }
295    }
296
297    #[test]
298    fn test_public_key_bytes_valid() {
299        let pin = make_test_pin();
300        let bytes = pin.public_key_bytes().unwrap();
301        assert_eq!(bytes.len(), 32);
302        assert_eq!(bytes[0], 0x01);
303        assert_eq!(bytes[31], 0x20);
304    }
305
306    #[test]
307    fn test_serde_rejects_invalid_hex() {
308        let json = r#"{
309            "did": "did:keri:ETest123",
310            "public_key_hex": "not-valid-hex",
311            "first_seen": "2024-01-01T00:00:00Z",
312            "origin": "test",
313            "trust_level": "tofu"
314        }"#;
315        let result: Result<PinnedIdentity, _> = serde_json::from_str(json);
316        assert!(result.is_err());
317    }
318
319    #[test]
320    fn test_key_matches_true() {
321        let pin = make_test_pin();
322        let expected: Vec<u8> = (1..=32).collect();
323        assert!(pin.key_matches(&expected).unwrap());
324    }
325
326    #[test]
327    fn test_key_matches_false() {
328        let pin = make_test_pin();
329        let wrong: Vec<u8> = vec![0; 32];
330        assert!(!pin.key_matches(&wrong).unwrap());
331    }
332
333    #[test]
334    fn test_key_matches_case_insensitive() {
335        let mut pin = make_test_pin();
336        pin.public_key_hex = PublicKeyHex::new_unchecked(
337            "0102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F20",
338        );
339        let expected: Vec<u8> = (1..=32).collect();
340        assert!(pin.key_matches(&expected).unwrap());
341    }
342
343    #[test]
344    fn test_trust_level_serialization() {
345        assert_eq!(
346            serde_json::to_string(&TrustLevel::Tofu).unwrap(),
347            "\"tofu\""
348        );
349        assert_eq!(
350            serde_json::to_string(&TrustLevel::Manual).unwrap(),
351            "\"manual\""
352        );
353        assert_eq!(
354            serde_json::to_string(&TrustLevel::OrgPolicy).unwrap(),
355            "\"org_policy\""
356        );
357    }
358
359    #[test]
360    fn test_pinned_identity_serialization_roundtrip() {
361        let pin = make_test_pin();
362        let json = serde_json::to_string(&pin).unwrap();
363        let parsed: PinnedIdentity = serde_json::from_str(&json).unwrap();
364
365        assert_eq!(pin.did, parsed.did);
366        assert_eq!(pin.public_key_hex, parsed.public_key_hex);
367        assert_eq!(pin.kel_tip_said, parsed.kel_tip_said);
368        assert_eq!(pin.kel_sequence, parsed.kel_sequence);
369        assert_eq!(pin.trust_level, parsed.trust_level);
370    }
371
372    #[test]
373    fn test_optional_fields_skipped() {
374        let mut pin = make_test_pin();
375        pin.kel_tip_said = None;
376        pin.kel_sequence = None;
377
378        let json = serde_json::to_string(&pin).unwrap();
379        assert!(!json.contains("kel_tip_said"));
380        assert!(!json.contains("kel_sequence"));
381    }
382
383    // --- PinnedIdentityStore tests ---
384
385    fn temp_store() -> (tempfile::TempDir, PinnedIdentityStore) {
386        let dir = tempfile::tempdir().unwrap();
387        let path = dir.path().join("known_identities.json");
388        let store = PinnedIdentityStore::new(path);
389        (dir, store)
390    }
391
392    #[test]
393    fn test_store_lookup_empty() {
394        let (_dir, store) = temp_store();
395        let result = store.lookup("did:keri:ENonexistent").unwrap();
396        assert!(result.is_none());
397    }
398
399    #[test]
400    fn test_store_pin_and_lookup() {
401        let (_dir, store) = temp_store();
402        let pin = make_test_pin();
403
404        store.pin(pin.clone()).unwrap();
405
406        let found = store.lookup(&pin.did).unwrap();
407        assert!(found.is_some());
408        let found = found.unwrap();
409        assert_eq!(found.did, pin.did);
410        assert_eq!(found.public_key_hex, pin.public_key_hex);
411    }
412
413    #[test]
414    fn test_public_key_hex_rejects_invalid_at_parse() {
415        let result = PublicKeyHex::parse("not-valid-hex");
416        assert!(result.is_err());
417    }
418
419    #[test]
420    fn test_store_pin_rejects_duplicate() {
421        let (_dir, store) = temp_store();
422        let pin = make_test_pin();
423
424        store.pin(pin.clone()).unwrap();
425        let result = store.pin(pin);
426
427        assert!(result.is_err());
428        assert!(result.unwrap_err().to_string().contains("already pinned"));
429    }
430
431    #[test]
432    fn test_store_update() {
433        let (_dir, store) = temp_store();
434        let mut pin = make_test_pin();
435        store.pin(pin.clone()).unwrap();
436
437        pin.public_key_hex = PublicKeyHex::new_unchecked(
438            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
439        );
440        pin.kel_sequence = Some(1);
441        store.update(pin.clone()).unwrap();
442
443        let found = store.lookup(&pin.did).unwrap().unwrap();
444        assert_eq!(found.kel_sequence, Some(1));
445        assert!(found.public_key_hex.as_str().starts_with("aaaa"));
446    }
447
448    #[test]
449    fn test_store_update_nonexistent() {
450        let (_dir, store) = temp_store();
451        let pin = make_test_pin();
452
453        let result = store.update(pin);
454        assert!(result.is_err());
455        assert!(result.unwrap_err().to_string().contains("not found"));
456    }
457
458    #[test]
459    fn test_store_remove() {
460        let (_dir, store) = temp_store();
461        let pin = make_test_pin();
462        store.pin(pin.clone()).unwrap();
463
464        assert!(store.remove(&pin.did).unwrap());
465        assert!(store.lookup(&pin.did).unwrap().is_none());
466    }
467
468    #[test]
469    fn test_store_remove_nonexistent() {
470        let (_dir, store) = temp_store();
471        assert!(!store.remove("did:keri:ENonexistent").unwrap());
472    }
473
474    #[test]
475    fn test_store_list() {
476        let (_dir, store) = temp_store();
477
478        let mut pin1 = make_test_pin();
479        pin1.did = "did:keri:E111".to_string();
480        let mut pin2 = make_test_pin();
481        pin2.did = "did:keri:E222".to_string();
482
483        store.pin(pin1).unwrap();
484        store.pin(pin2).unwrap();
485
486        let all = store.list().unwrap();
487        assert_eq!(all.len(), 2);
488    }
489
490    #[test]
491    fn test_concurrent_access_no_corruption() {
492        use std::sync::Arc;
493        use std::thread;
494
495        let dir = tempfile::tempdir().unwrap();
496        let path = dir.path().join("known_identities.json");
497
498        // Seed the store file so concurrent threads don't race on first-create
499        std::fs::write(&path, "[]").unwrap();
500
501        let store = Arc::new(PinnedIdentityStore::new(path));
502
503        let handles: Vec<_> = (0..10)
504            .map(|i| {
505                let store = Arc::clone(&store);
506                thread::spawn(move || {
507                    let mut pin = make_test_pin();
508                    pin.did = format!("did:keri:E{:03}", i);
509                    store.pin(pin).unwrap();
510                })
511            })
512            .collect();
513
514        for handle in handles {
515            handle.join().unwrap();
516        }
517
518        let all = store.list().unwrap();
519        assert_eq!(all.len(), 10);
520
521        for i in 0..10 {
522            let did = format!("did:keri:E{:03}", i);
523            assert!(
524                store.lookup(&did).unwrap().is_some(),
525                "Missing pin: {}",
526                did
527            );
528        }
529    }
530}