use std::fs;
use std::io::Write;
use std::path::PathBuf;
use auths_verifier::PublicKeyHex;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::error::TrustError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PinnedIdentity {
pub did: String,
pub public_key_hex: PublicKeyHex,
#[serde(default)]
pub curve: auths_crypto::CurveType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kel_tip_said: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kel_sequence: Option<u128>,
pub first_seen: DateTime<Utc>,
pub origin: String,
pub trust_level: TrustLevel,
}
impl PinnedIdentity {
pub fn public_key_bytes(&self) -> Result<Vec<u8>, TrustError> {
hex::decode(self.public_key_hex.as_str()).map_err(|e| {
TrustError::InvalidData(format!("Corrupt pin for {}: invalid hex: {}", self.did, e))
})
}
pub fn key_matches(&self, presented_pk: &[u8]) -> Result<bool, TrustError> {
use subtle::ConstantTimeEq;
Ok(self.public_key_bytes()?.ct_eq(presented_pk).into())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TrustLevel {
Tofu,
Manual,
OrgPolicy,
}
pub struct PinnedIdentityStore {
path: PathBuf,
}
#[allow(clippy::disallowed_methods)] #[allow(clippy::disallowed_types)]
impl PinnedIdentityStore {
pub fn new(path: PathBuf) -> Self {
Self { path }
}
#[allow(clippy::disallowed_methods)] pub fn default_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".auths")
.join("known_identities.json")
}
pub fn lookup(&self, did: &str) -> Result<Option<PinnedIdentity>, TrustError> {
let _lock = self.lock()?;
Ok(self.read_all()?.into_iter().find(|e| e.did == did))
}
pub fn pin(&self, identity: PinnedIdentity) -> Result<(), TrustError> {
let _lock = self.lock()?;
let mut entries = self.read_all()?;
if entries.iter().any(|e| e.did == identity.did) {
return Err(TrustError::AlreadyExists(format!(
"Identity {} already pinned. Use `auths trust remove` first, or rotation \
will be handled automatically via continuity checking.",
identity.did
)));
}
entries.push(identity);
self.write_all(&entries)
}
pub fn update(&self, identity: PinnedIdentity) -> Result<(), TrustError> {
let _lock = self.lock()?;
let mut entries = self.read_all()?;
let pos = entries
.iter()
.position(|e| e.did == identity.did)
.ok_or_else(|| {
TrustError::NotFound(format!(
"Cannot update: identity {} not found in pin store.",
identity.did
))
})?;
entries[pos] = identity;
self.write_all(&entries)
}
pub fn remove(&self, did: &str) -> Result<bool, TrustError> {
let _lock = self.lock()?;
let mut entries = self.read_all()?;
let before = entries.len();
entries.retain(|e| e.did != did);
if entries.len() < before {
self.write_all(&entries)?;
Ok(true)
} else {
Ok(false)
}
}
pub fn is_pinned(&self, did: &str) -> Result<bool, TrustError> {
let _lock = self.lock()?;
Ok(self.read_all()?.iter().any(|e| e.did == did))
}
pub fn list(&self) -> Result<Vec<PinnedIdentity>, TrustError> {
let _lock = self.lock()?;
self.read_all()
}
fn read_all(&self) -> Result<Vec<PinnedIdentity>, TrustError> {
if !self.path.exists() {
return Ok(vec![]);
}
let content = fs::read_to_string(&self.path)?;
let entries: Vec<PinnedIdentity> = serde_json::from_str(&content).map_err(|e| {
TrustError::InvalidData(format!(
"Corrupt pin store at {:?}: {}. Consider deleting and re-pinning.",
self.path, e
))
})?;
Ok(entries)
}
fn write_all(&self, entries: &[PinnedIdentity]) -> Result<(), TrustError> {
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent)?;
}
let tmp = self.path.with_extension("tmp");
{
let mut file = fs::File::create(&tmp)?;
let json = serde_json::to_string_pretty(entries)?;
file.write_all(json.as_bytes())?;
file.write_all(b"\n")?;
file.sync_all()?;
}
fs::rename(&tmp, &self.path)?;
Ok(())
}
fn lock(&self) -> Result<LockGuard, TrustError> {
let lock_path = self.path.with_extension("lock");
if let Some(parent) = lock_path.parent() {
fs::create_dir_all(parent)?;
}
LockGuard::acquire(lock_path)
}
}
#[allow(clippy::disallowed_types)] struct LockGuard {
_file: fs::File,
}
#[allow(clippy::disallowed_methods)] #[allow(clippy::disallowed_types)]
impl LockGuard {
fn acquire(path: PathBuf) -> Result<Self, TrustError> {
let file = fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&path)?;
#[cfg(unix)]
{
use std::os::unix::io::AsRawFd;
let fd = file.as_raw_fd();
let ret = unsafe { libc::flock(fd, libc::LOCK_EX) };
if ret != 0 {
return Err(TrustError::Lock(format!(
"Failed to acquire lock on {:?}",
path
)));
}
}
#[cfg(not(unix))]
{
}
Ok(Self { _file: file })
}
}
#[cfg(test)]
#[allow(clippy::disallowed_methods)]
mod tests {
use super::*;
fn make_test_pin() -> PinnedIdentity {
PinnedIdentity {
did: "did:keri:ETest123".to_string(),
public_key_hex: PublicKeyHex::new_unchecked(
"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
),
curve: auths_crypto::CurveType::Ed25519,
kel_tip_said: Some("ETip".to_string()),
kel_sequence: Some(0),
first_seen: Utc::now(),
origin: "test".to_string(),
trust_level: TrustLevel::Tofu,
}
}
#[test]
fn test_public_key_bytes_valid() {
let pin = make_test_pin();
let bytes = pin.public_key_bytes().unwrap();
assert_eq!(bytes.len(), 32);
assert_eq!(bytes[0], 0x01);
assert_eq!(bytes[31], 0x20);
}
#[test]
fn test_serde_rejects_invalid_hex() {
let json = r#"{
"did": "did:keri:ETest123",
"public_key_hex": "not-valid-hex",
"first_seen": "2024-01-01T00:00:00Z",
"origin": "test",
"trust_level": "tofu"
}"#;
let result: Result<PinnedIdentity, _> = serde_json::from_str(json);
assert!(result.is_err());
}
#[test]
fn test_key_matches_true() {
let pin = make_test_pin();
let expected: Vec<u8> = (1..=32).collect();
assert!(pin.key_matches(&expected).unwrap());
}
#[test]
fn test_key_matches_false() {
let pin = make_test_pin();
let wrong: Vec<u8> = vec![0; 32];
assert!(!pin.key_matches(&wrong).unwrap());
}
#[test]
fn test_key_matches_case_insensitive() {
let mut pin = make_test_pin();
pin.public_key_hex = PublicKeyHex::new_unchecked(
"0102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F20",
);
let expected: Vec<u8> = (1..=32).collect();
assert!(pin.key_matches(&expected).unwrap());
}
#[test]
fn test_trust_level_serialization() {
assert_eq!(
serde_json::to_string(&TrustLevel::Tofu).unwrap(),
"\"tofu\""
);
assert_eq!(
serde_json::to_string(&TrustLevel::Manual).unwrap(),
"\"manual\""
);
assert_eq!(
serde_json::to_string(&TrustLevel::OrgPolicy).unwrap(),
"\"org_policy\""
);
}
#[test]
fn test_pinned_identity_serialization_roundtrip() {
let pin = make_test_pin();
let json = serde_json::to_string(&pin).unwrap();
let parsed: PinnedIdentity = serde_json::from_str(&json).unwrap();
assert_eq!(pin.did, parsed.did);
assert_eq!(pin.public_key_hex, parsed.public_key_hex);
assert_eq!(pin.kel_tip_said, parsed.kel_tip_said);
assert_eq!(pin.kel_sequence, parsed.kel_sequence);
assert_eq!(pin.trust_level, parsed.trust_level);
}
#[test]
fn test_optional_fields_skipped() {
let mut pin = make_test_pin();
pin.kel_tip_said = None;
pin.kel_sequence = None;
let json = serde_json::to_string(&pin).unwrap();
assert!(!json.contains("kel_tip_said"));
assert!(!json.contains("kel_sequence"));
}
fn temp_store() -> (tempfile::TempDir, PinnedIdentityStore) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("known_identities.json");
let store = PinnedIdentityStore::new(path);
(dir, store)
}
#[test]
fn test_store_lookup_empty() {
let (_dir, store) = temp_store();
let result = store.lookup("did:keri:ENonexistent").unwrap();
assert!(result.is_none());
}
#[test]
fn test_store_pin_and_lookup() {
let (_dir, store) = temp_store();
let pin = make_test_pin();
store.pin(pin.clone()).unwrap();
let found = store.lookup(&pin.did).unwrap();
assert!(found.is_some());
let found = found.unwrap();
assert_eq!(found.did, pin.did);
assert_eq!(found.public_key_hex, pin.public_key_hex);
}
#[test]
fn test_public_key_hex_rejects_invalid_at_parse() {
let result = PublicKeyHex::parse("not-valid-hex");
assert!(result.is_err());
}
#[test]
fn test_store_pin_rejects_duplicate() {
let (_dir, store) = temp_store();
let pin = make_test_pin();
store.pin(pin.clone()).unwrap();
let result = store.pin(pin);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("already pinned"));
}
#[test]
fn test_store_update() {
let (_dir, store) = temp_store();
let mut pin = make_test_pin();
store.pin(pin.clone()).unwrap();
pin.public_key_hex = PublicKeyHex::new_unchecked(
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
);
pin.kel_sequence = Some(1);
store.update(pin.clone()).unwrap();
let found = store.lookup(&pin.did).unwrap().unwrap();
assert_eq!(found.kel_sequence, Some(1));
assert!(found.public_key_hex.as_str().starts_with("aaaa"));
}
#[test]
fn test_store_update_nonexistent() {
let (_dir, store) = temp_store();
let pin = make_test_pin();
let result = store.update(pin);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
#[test]
fn test_store_remove() {
let (_dir, store) = temp_store();
let pin = make_test_pin();
store.pin(pin.clone()).unwrap();
assert!(store.remove(&pin.did).unwrap());
assert!(store.lookup(&pin.did).unwrap().is_none());
}
#[test]
fn test_store_remove_nonexistent() {
let (_dir, store) = temp_store();
assert!(!store.remove("did:keri:ENonexistent").unwrap());
}
#[test]
fn test_store_list() {
let (_dir, store) = temp_store();
let mut pin1 = make_test_pin();
pin1.did = "did:keri:E111".to_string();
let mut pin2 = make_test_pin();
pin2.did = "did:keri:E222".to_string();
store.pin(pin1).unwrap();
store.pin(pin2).unwrap();
let all = store.list().unwrap();
assert_eq!(all.len(), 2);
}
#[test]
fn test_concurrent_access_no_corruption() {
use std::sync::Arc;
use std::thread;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("known_identities.json");
std::fs::write(&path, "[]").unwrap();
let store = Arc::new(PinnedIdentityStore::new(path));
let handles: Vec<_> = (0..10)
.map(|i| {
let store = Arc::clone(&store);
thread::spawn(move || {
let mut pin = make_test_pin();
pin.did = format!("did:keri:E{:03}", i);
store.pin(pin).unwrap();
})
})
.collect();
for handle in handles {
handle.join().unwrap();
}
let all = store.list().unwrap();
assert_eq!(all.len(), 10);
for i in 0..10 {
let did = format!("did:keri:E{:03}", i);
assert!(
store.lookup(&did).unwrap().is_some(),
"Missing pin: {}",
did
);
}
}
}