1use auths_core::error::AuthsErrorInfo;
2use thiserror::Error;
3
4#[cfg(feature = "backend-git")]
9use crate::keri::{CurrentKeyError, resolve_current_public_key};
10#[cfg(feature = "backend-git")]
11use crate::ports::RegistryBackend;
12
13#[derive(Debug, Clone)]
31pub struct SignedAuthChallenge {
32 pub signature_hex: String,
34 pub public_key_hex: String,
36 pub did: String,
38}
39
40#[derive(Debug, Error)]
42#[non_exhaustive]
43pub enum AuthChallengeError {
44 #[error("nonce must not be empty")]
46 EmptyNonce,
47
48 #[error("domain must not be empty")]
50 EmptyDomain,
51
52 #[error("canonical JSON serialization failed: {0}")]
54 Canonicalization(String),
55}
56
57impl AuthsErrorInfo for AuthChallengeError {
58 fn error_code(&self) -> &'static str {
59 match self {
60 Self::EmptyNonce => "AUTHS-E6001",
61 Self::EmptyDomain => "AUTHS-E6002",
62 Self::Canonicalization(_) => "AUTHS-E6003",
63 }
64 }
65
66 fn suggestion(&self) -> Option<&'static str> {
67 match self {
68 Self::EmptyNonce => Some("Provide the nonce from the authentication challenge"),
69 Self::EmptyDomain => Some("Provide the domain (e.g. auths.dev)"),
70 Self::Canonicalization(_) => {
71 Some("This is an internal error; please report it as a bug")
72 }
73 }
74 }
75}
76
77pub fn build_auth_challenge_message(
93 nonce: &str,
94 domain: &str,
95) -> Result<String, AuthChallengeError> {
96 if nonce.is_empty() {
97 return Err(AuthChallengeError::EmptyNonce);
98 }
99 if domain.is_empty() {
100 return Err(AuthChallengeError::EmptyDomain);
101 }
102 let payload = serde_json::json!({
103 "domain": domain,
104 "nonce": nonce,
105 });
106 json_canon::to_string(&payload).map_err(|e| AuthChallengeError::Canonicalization(e.to_string()))
107}
108
109#[cfg(feature = "backend-git")]
115#[derive(Debug, Clone)]
116pub struct VerifiedAuthChallenge {
117 pub did: String,
119 pub public_key_hex: String,
121 pub curve: auths_crypto::CurveType,
123}
124
125#[cfg(feature = "backend-git")]
127#[derive(Debug, Error)]
128#[non_exhaustive]
129pub enum AuthChallengeVerifyError {
130 #[error(transparent)]
133 Challenge(#[from] AuthChallengeError),
134
135 #[error(transparent)]
140 CurrentKey(#[from] CurrentKeyError),
141
142 #[error("signature does not verify under the registry's current key for {did}: {reason}")]
146 SignatureInvalid {
147 did: String,
149 reason: String,
151 },
152}
153
154#[cfg(feature = "backend-git")]
176pub fn verify_auth_challenge(
177 registry: &dyn RegistryBackend,
178 did: &str,
179 nonce: &str,
180 domain: &str,
181 signature: &[u8],
182) -> Result<VerifiedAuthChallenge, AuthChallengeVerifyError> {
183 let message = build_auth_challenge_message(nonce, domain)?;
184 let (pk_bytes, curve) = resolve_current_public_key(registry, did)?;
185 let key = auths_keri::KeriPublicKey::from_verkey_bytes(&pk_bytes, curve).map_err(|e| {
186 CurrentKeyError::UnsupportedKey {
189 did: did.to_string(),
190 reason: e.to_string(),
191 }
192 })?;
193 key.verify_signature(message.as_bytes(), signature)
194 .map_err(|reason| AuthChallengeVerifyError::SignatureInvalid {
195 did: did.to_string(),
196 reason,
197 })?;
198 Ok(VerifiedAuthChallenge {
199 did: did.to_string(),
200 public_key_hex: hex::encode(&pk_bytes),
201 curve,
202 })
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 #[test]
210 fn canonical_json_sorts_keys_alphabetically() {
211 let payload = serde_json::json!({
212 "nonce": "abc",
213 "domain": "xyz",
214 });
215 let canonical = json_canon::to_string(&payload).expect("canonical");
216 assert_eq!(canonical, r#"{"domain":"xyz","nonce":"abc"}"#);
217 }
218
219 #[test]
220 fn build_auth_challenge_message_produces_canonical_json() {
221 let msg = build_auth_challenge_message("abc123", "auths.dev").unwrap();
222 assert_eq!(msg, r#"{"domain":"auths.dev","nonce":"abc123"}"#);
223 }
224
225 #[test]
226 fn build_auth_challenge_message_rejects_empty_nonce() {
227 let err = build_auth_challenge_message("", "auths.dev").unwrap_err();
228 assert!(matches!(err, AuthChallengeError::EmptyNonce));
229 }
230
231 #[test]
232 fn build_auth_challenge_message_rejects_empty_domain() {
233 let err = build_auth_challenge_message("abc", "").unwrap_err();
234 assert!(matches!(err, AuthChallengeError::EmptyDomain));
235 }
236
237 #[cfg(feature = "backend-git")]
238 mod verify {
239 use super::*;
240 use auths_id::testing::fakes::FakeRegistryBackend;
241 use auths_keri::{
242 CesrKey, Event, IcpEvent, KeriPublicKey, KeriSequence, Prefix, Said, Threshold,
243 VersionString, compute_next_commitment, finalize_icp_event,
244 };
245 use ring::signature::{Ed25519KeyPair, KeyPair};
246
247 fn registry_with_identity(seed: [u8; 32]) -> (FakeRegistryBackend, String, Ed25519KeyPair) {
249 let keypair = Ed25519KeyPair::from_seed_unchecked(&seed).unwrap();
250 let key = KeriPublicKey::ed25519(keypair.public_key().as_ref()).unwrap();
251 let next = KeriPublicKey::ed25519(&[9u8; 32]).unwrap();
252 let icp = IcpEvent {
253 v: VersionString::placeholder(),
254 d: Said::default(),
255 i: Prefix::default(),
256 s: KeriSequence::new(0),
257 kt: Threshold::Simple(1),
258 k: vec![CesrKey::new_unchecked(key.to_qb64().unwrap())],
259 nt: Threshold::Simple(1),
260 n: vec![compute_next_commitment(&next)],
261 bt: Threshold::Simple(0),
262 b: vec![],
263 c: vec![],
264 a: vec![],
265 };
266 let finalized = finalize_icp_event(icp).unwrap();
267 let prefix = finalized.i.clone();
268 let did = format!("did:keri:{prefix}");
269 let registry = FakeRegistryBackend::new();
270 registry
271 .append_event(&prefix, &Event::Icp(finalized))
272 .unwrap();
273 (registry, did, keypair)
274 }
275
276 #[test]
277 fn challenge_signed_by_registry_key_verifies() {
278 let (registry, did, keypair) = registry_with_identity([7u8; 32]);
279 let message = build_auth_challenge_message("abc123", "auths.dev").unwrap();
280 let signature = keypair.sign(message.as_bytes());
281
282 let verified =
283 verify_auth_challenge(®istry, &did, "abc123", "auths.dev", signature.as_ref())
284 .unwrap();
285 assert_eq!(verified.did, did);
286 assert_eq!(
287 verified.public_key_hex,
288 hex::encode(keypair.public_key().as_ref())
289 );
290 assert_eq!(verified.curve, auths_crypto::CurveType::Ed25519);
291 }
292
293 #[test]
294 fn foreign_key_signature_is_rejected() {
295 let (registry, did, _keypair) = registry_with_identity([7u8; 32]);
298 let thief = Ed25519KeyPair::from_seed_unchecked(&[8u8; 32]).unwrap();
299 let message = build_auth_challenge_message("abc123", "auths.dev").unwrap();
300 let signature = thief.sign(message.as_bytes());
301
302 let err =
303 verify_auth_challenge(®istry, &did, "abc123", "auths.dev", signature.as_ref())
304 .unwrap_err();
305 assert!(matches!(
306 err,
307 AuthChallengeVerifyError::SignatureInvalid { .. }
308 ));
309 }
310
311 #[test]
312 fn nonce_is_bound_into_the_verdict() {
313 let (registry, did, keypair) = registry_with_identity([7u8; 32]);
316 let message = build_auth_challenge_message("old-nonce", "auths.dev").unwrap();
317 let signature = keypair.sign(message.as_bytes());
318
319 let err = verify_auth_challenge(
320 ®istry,
321 &did,
322 "fresh-nonce",
323 "auths.dev",
324 signature.as_ref(),
325 )
326 .unwrap_err();
327 assert!(matches!(
328 err,
329 AuthChallengeVerifyError::SignatureInvalid { .. }
330 ));
331 }
332
333 #[test]
334 fn unknown_did_fails_resolution() {
335 let registry = FakeRegistryBackend::new();
336 let err = verify_auth_challenge(
337 ®istry,
338 "did:keri:ENotHere0000000000000000000000000000000000",
339 "abc123",
340 "auths.dev",
341 &[0u8; 64],
342 )
343 .unwrap_err();
344 assert!(matches!(err, AuthChallengeVerifyError::CurrentKey(_)));
345 }
346
347 #[test]
348 fn empty_nonce_is_refused_before_any_resolution() {
349 let registry = FakeRegistryBackend::new();
350 let err = verify_auth_challenge(®istry, "did:keri:E", "", "auths.dev", &[0u8; 64])
351 .unwrap_err();
352 assert!(matches!(
353 err,
354 AuthChallengeVerifyError::Challenge(AuthChallengeError::EmptyNonce)
355 ));
356 }
357 }
358}