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