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