Skip to main content

auths_id/keri/
resolve.rs

1//! did:keri resolution via KEL replay.
2//!
3//! Resolves a `did:keri:<prefix>` to its current public key by:
4//! 1. Loading the KEL from Git
5//! 2. Replaying all events to derive current KeyState
6//! 3. Decoding the current public key
7
8use auths_crypto::KeriPublicKey;
9use auths_verifier::types::IdentityDID;
10use git2::Repository;
11
12use super::types::Prefix;
13use super::{GitKel, KelError, ValidationError, validate_kel};
14
15/// Error type for did:keri resolution.
16#[derive(Debug, thiserror::Error)]
17#[non_exhaustive]
18pub enum ResolveError {
19    #[error("Invalid DID format: {0}")]
20    InvalidFormat(String),
21
22    #[error("KEL not found for prefix: {0}")]
23    NotFound(String),
24
25    #[error("KEL error: {0}")]
26    Kel(#[from] KelError),
27
28    #[error("Validation error: {0}")]
29    Validation(#[from] ValidationError),
30
31    #[error("Invalid key encoding: {0}")]
32    InvalidKeyEncoding(String),
33
34    #[error("No current key in identity")]
35    NoCurrentKey,
36
37    #[error("Unknown key type: {0}")]
38    UnknownKeyType(String),
39}
40
41impl auths_core::error::AuthsErrorInfo for ResolveError {
42    fn error_code(&self) -> &'static str {
43        match self {
44            Self::InvalidFormat(_) => "AUTHS-E4801",
45            Self::NotFound(_) => "AUTHS-E4802",
46            Self::Kel(_) => "AUTHS-E4803",
47            Self::Validation(_) => "AUTHS-E4804",
48            Self::InvalidKeyEncoding(_) => "AUTHS-E4805",
49            Self::NoCurrentKey => "AUTHS-E4806",
50            Self::UnknownKeyType(_) => "AUTHS-E4807",
51        }
52    }
53
54    fn suggestion(&self) -> Option<&'static str> {
55        match self {
56            Self::InvalidFormat(_) => Some("Use the format 'did:keri:E<prefix>'"),
57            Self::NotFound(_) => Some("The identity does not exist; check the DID prefix"),
58            Self::Kel(_) => None,
59            Self::Validation(_) => None,
60            Self::InvalidKeyEncoding(_) => None,
61            Self::NoCurrentKey => Some("The identity has no active key; it may be abandoned"),
62            Self::UnknownKeyType(_) => Some("Only Ed25519 keys (D prefix) are currently supported"),
63        }
64    }
65}
66
67/// Result of resolving a did:keri.
68#[derive(Debug, Clone)]
69pub struct DidKeriResolution {
70    /// The full DID string
71    pub did: IdentityDID,
72
73    /// The KERI prefix
74    pub prefix: Prefix,
75
76    /// The current public key (raw bytes, 32 bytes for Ed25519)
77    pub public_key: Vec<u8>,
78
79    /// The current sequence number
80    pub sequence: u64,
81
82    /// Whether the identity can still be rotated
83    pub can_rotate: bool,
84
85    /// Whether the identity has been abandoned
86    pub is_abandoned: bool,
87}
88
89/// Resolve a did:keri to its current public key.
90///
91/// This replays the entire KEL to derive the current key state.
92///
93/// # Arguments
94/// * `repo` - Git repository containing the KEL
95/// * `did` - The did:keri string (e.g., "did:keri:EXq5YqaL...")
96///
97/// # Returns
98/// * `DidKeriResolution` with the current public key and state
99pub fn resolve_did_keri(repo: &Repository, did: &str) -> Result<DidKeriResolution, ResolveError> {
100    let prefix = parse_did_keri(did)?;
101
102    // Load KEL
103    let kel = GitKel::new(repo, prefix.as_str());
104    if !kel.exists() {
105        return Err(ResolveError::NotFound(prefix.as_str().to_string()));
106    }
107
108    // Replay KEL to get current state
109    let events = kel.get_events()?;
110    let state = validate_kel(&events)?;
111
112    // Decode current public key
113    let key_encoded = state.current_key().ok_or(ResolveError::NoCurrentKey)?;
114
115    let public_key = KeriPublicKey::parse(key_encoded)
116        .map(|k| k.as_bytes().to_vec())
117        .map_err(|e| ResolveError::InvalidKeyEncoding(e.to_string()))?;
118
119    Ok(DidKeriResolution {
120        #[allow(clippy::disallowed_methods)] // INVARIANT: parse_did_keri() above validated the did:keri format
121        did: IdentityDID::new_unchecked(did),
122        prefix,
123        public_key,
124        sequence: state.sequence,
125        can_rotate: state.can_rotate(),
126        is_abandoned: state.is_abandoned,
127    })
128}
129
130/// Resolve a did:keri at a specific sequence number (historical lookup).
131///
132/// This replays the KEL only up to the target sequence.
133///
134/// # Arguments
135/// * `repo` - Git repository containing the KEL
136/// * `did` - The did:keri string
137/// * `target_sequence` - The sequence number to resolve at
138pub fn resolve_did_keri_at_sequence(
139    repo: &Repository,
140    did: &str,
141    target_sequence: u64,
142) -> Result<DidKeriResolution, ResolveError> {
143    let prefix = parse_did_keri(did)?;
144
145    let kel = GitKel::new(repo, prefix.as_str());
146    if !kel.exists() {
147        return Err(ResolveError::NotFound(prefix.as_str().to_string()));
148    }
149
150    let events = kel.get_events()?;
151
152    // Only process events up to target sequence
153    let events_subset: Vec<_> = events
154        .into_iter()
155        .take_while(|e| e.sequence().value() <= target_sequence)
156        .collect();
157
158    if events_subset.is_empty() {
159        return Err(ResolveError::NotFound(format!(
160            "No events at sequence {}",
161            target_sequence
162        )));
163    }
164
165    let state = validate_kel(&events_subset)?;
166
167    let key_encoded = state.current_key().ok_or(ResolveError::NoCurrentKey)?;
168    let public_key = KeriPublicKey::parse(key_encoded)
169        .map(|k| k.as_bytes().to_vec())
170        .map_err(|e| ResolveError::InvalidKeyEncoding(e.to_string()))?;
171
172    Ok(DidKeriResolution {
173        #[allow(clippy::disallowed_methods)] // INVARIANT: parse_did_keri() above validated the did:keri format
174        did: IdentityDID::new_unchecked(did),
175        prefix,
176        public_key,
177        sequence: state.sequence,
178        can_rotate: state.can_rotate(),
179        is_abandoned: state.is_abandoned,
180    })
181}
182
183/// Parse a did:keri string to extract the prefix.
184pub fn parse_did_keri(did: &str) -> Result<Prefix, ResolveError> {
185    const PREFIX: &str = "did:keri:";
186
187    if !did.starts_with(PREFIX) {
188        return Err(ResolveError::InvalidFormat(format!(
189            "Expected did:keri: prefix, got: {}",
190            did
191        )));
192    }
193
194    let prefix = &did[PREFIX.len()..];
195    if prefix.is_empty() {
196        return Err(ResolveError::InvalidFormat("Empty KERI prefix".into()));
197    }
198
199    // Validate prefix format (starts with E for Blake3 SAID)
200    if !prefix.starts_with('E') {
201        return Err(ResolveError::InvalidFormat(format!(
202            "Invalid KERI prefix format (expected E prefix): {}",
203            prefix
204        )));
205    }
206
207    Ok(Prefix::new_unchecked(prefix.to_string()))
208}
209
210#[cfg(test)]
211#[allow(clippy::disallowed_methods)]
212mod tests {
213    use super::*;
214    use crate::keri::{create_keri_identity, rotate_keys};
215    use tempfile::TempDir;
216
217    fn setup_repo() -> (TempDir, Repository) {
218        let dir = TempDir::new().unwrap();
219        let repo = Repository::init(dir.path()).unwrap();
220
221        let mut config = repo.config().unwrap();
222        config.set_str("user.name", "Test User").unwrap();
223        config.set_str("user.email", "test@example.com").unwrap();
224
225        (dir, repo)
226    }
227
228    #[test]
229    fn parse_did_keri_valid() {
230        let prefix = parse_did_keri("did:keri:EXq5YqaL6L48pf0fu7IUhL0JRaU2").unwrap();
231        assert_eq!(prefix, "EXq5YqaL6L48pf0fu7IUhL0JRaU2");
232    }
233
234    #[test]
235    fn parse_did_keri_rejects_wrong_method() {
236        let result = parse_did_keri("did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK");
237        assert!(matches!(result, Err(ResolveError::InvalidFormat(_))));
238    }
239
240    #[test]
241    fn parse_did_keri_rejects_empty_prefix() {
242        let result = parse_did_keri("did:keri:");
243        assert!(matches!(result, Err(ResolveError::InvalidFormat(_))));
244    }
245
246    #[test]
247    fn parse_did_keri_rejects_invalid_prefix() {
248        let result = parse_did_keri("did:keri:invalid");
249        assert!(matches!(result, Err(ResolveError::InvalidFormat(_))));
250    }
251
252    #[test]
253    fn resolves_after_inception() {
254        let (_dir, repo) = setup_repo();
255
256        let init = create_keri_identity(&repo, None, chrono::Utc::now()).unwrap();
257        let did = format!("did:keri:{}", init.prefix);
258
259        let resolution = resolve_did_keri(&repo, &did).unwrap();
260
261        assert_eq!(resolution.prefix, init.prefix);
262        assert_eq!(resolution.public_key, init.current_public_key);
263        assert_eq!(resolution.sequence, 0);
264        assert!(resolution.can_rotate);
265        assert!(!resolution.is_abandoned);
266    }
267
268    #[test]
269    fn resolves_after_rotation() {
270        let (_dir, repo) = setup_repo();
271
272        let init = create_keri_identity(&repo, None, chrono::Utc::now()).unwrap();
273        let rot = rotate_keys(
274            &repo,
275            &init.prefix,
276            &init.next_keypair_pkcs8,
277            None,
278            chrono::Utc::now(),
279        )
280        .unwrap();
281
282        let did = format!("did:keri:{}", init.prefix);
283        let resolution = resolve_did_keri(&repo, &did).unwrap();
284
285        // Should return the NEW key (former next key)
286        assert_eq!(resolution.public_key, rot.new_current_public_key);
287        assert_eq!(resolution.sequence, 1);
288    }
289
290    #[test]
291    fn resolves_at_historical_sequence() {
292        let (_dir, repo) = setup_repo();
293
294        let init = create_keri_identity(&repo, None, chrono::Utc::now()).unwrap();
295        let _rot = rotate_keys(
296            &repo,
297            &init.prefix,
298            &init.next_keypair_pkcs8,
299            None,
300            chrono::Utc::now(),
301        )
302        .unwrap();
303
304        let did = format!("did:keri:{}", init.prefix);
305
306        // Resolve at sequence 0 should return inception key
307        let resolution = resolve_did_keri_at_sequence(&repo, &did, 0).unwrap();
308        assert_eq!(resolution.public_key, init.current_public_key);
309        assert_eq!(resolution.sequence, 0);
310    }
311
312    #[test]
313    fn not_found_for_missing_kel() {
314        let (_dir, repo) = setup_repo();
315
316        let result = resolve_did_keri(&repo, "did:keri:ENotExist123");
317        assert!(matches!(result, Err(ResolveError::NotFound(_))));
318    }
319
320    #[test]
321    fn decode_ed25519_key() {
322        use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
323        let key_bytes = [1u8; 32];
324        let encoded = format!("D{}", URL_SAFE_NO_PAD.encode(key_bytes));
325
326        let key = KeriPublicKey::parse(&encoded).unwrap();
327        assert_eq!(key.as_bytes(), &key_bytes);
328    }
329
330    #[test]
331    fn decode_unknown_key_type_fails() {
332        let result = KeriPublicKey::parse("Xsomething");
333        assert!(result.is_err());
334    }
335}