Skip to main content

auths_sdk/workflows/
auth.rs

1use auths_core::error::AuthsErrorInfo;
2use thiserror::Error;
3
4// The offline verifier resolves keys through the git-backed registry, so it is
5// gated like the rest of the registry-reading workflows (`commit_trust`) —
6// `auths-sdk` still builds without `backend-git` (the challenge builder does not
7// need it).
8#[cfg(feature = "backend-git")]
9use crate::keri::{CurrentKeyError, resolve_current_public_key};
10#[cfg(feature = "backend-git")]
11use crate::ports::RegistryBackend;
12
13/// Result of signing an authentication challenge.
14///
15/// Args:
16/// * `signature_hex`: Hex-encoded Ed25519 signature over the canonical payload.
17/// * `public_key_hex`: Hex-encoded Ed25519 public key.
18/// * `did`: The identity's DID (e.g. `"did:keri:EPREFIX"`).
19///
20/// Usage:
21/// ```ignore
22/// let msg = build_auth_challenge_message("abc123", "auths.dev")?;
23/// let (sig, pubkey, _curve) = sign_with_key(&keychain, &alias, &provider, msg.as_bytes())?;
24/// let result = SignedAuthChallenge {
25///     signature_hex: hex::encode(sig),
26///     public_key_hex: hex::encode(pubkey),
27///     did: "did:keri:E...".to_string(),
28/// };
29/// ```
30#[derive(Debug, Clone)]
31pub struct SignedAuthChallenge {
32    /// Hex-encoded Ed25519 signature over the canonical JSON payload.
33    pub signature_hex: String,
34    /// Hex-encoded Ed25519 public key of the signer.
35    pub public_key_hex: String,
36    /// The signer's identity DID (e.g. `"did:keri:EPREFIX"`).
37    pub did: String,
38}
39
40/// Errors from the auth challenge signing workflow.
41#[derive(Debug, Error)]
42#[non_exhaustive]
43pub enum AuthChallengeError {
44    /// The nonce was empty.
45    #[error("nonce must not be empty")]
46    EmptyNonce,
47
48    /// The domain was empty.
49    #[error("domain must not be empty")]
50    EmptyDomain,
51
52    /// Canonical JSON serialization failed.
53    #[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
77/// Builds the canonical JSON message for an auth challenge signature.
78///
79/// The auth-server verifies the signature over exactly these bytes. Callers
80/// that sign through a keychain (SE-safe) should use this helper to get the
81/// message, then feed it into `keychain::sign_with_key`.
82///
83/// Args:
84/// * `nonce`: The challenge nonce from the authentication server.
85/// * `domain`: The domain requesting authentication (e.g. `"auths.dev"`).
86///
87/// Usage:
88/// ```ignore
89/// let msg = build_auth_challenge_message("abc123", "auths.dev")?;
90/// let (sig, pubkey, _curve) = sign_with_key(&keychain, &alias, &provider, msg.as_bytes())?;
91/// ```
92pub 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/// A challenge response proven against the registry's in-force key.
110///
111/// Returned only when the signature verifies under the **registry's** current
112/// signing key for the DID — never under a key the responder supplied. This is
113/// what makes the liveness claim third-party-checkable instead of self-vouched.
114#[cfg(feature = "backend-git")]
115#[derive(Debug, Clone)]
116pub struct VerifiedAuthChallenge {
117    /// The DID whose registry key state verified the signature.
118    pub did: String,
119    /// Hex-encoded public key that verified — the registry's current key.
120    pub public_key_hex: String,
121    /// The verified key's curve.
122    pub curve: auths_crypto::CurveType,
123}
124
125/// Errors from the offline auth-challenge verification workflow.
126#[cfg(feature = "backend-git")]
127#[derive(Debug, Error)]
128#[non_exhaustive]
129pub enum AuthChallengeVerifyError {
130    /// The challenge inputs were invalid (empty nonce/domain, or the canonical
131    /// payload could not be built).
132    #[error(transparent)]
133    Challenge(#[from] AuthChallengeError),
134
135    /// The DID's current key could not be resolved from the local registry.
136    /// Delegated identifiers fail here by design: their in-force status needs
137    /// the delegator's revocation verdict, which a single-KEL replay cannot
138    /// supply — verify against the identity's controller DID instead.
139    #[error(transparent)]
140    CurrentKey(#[from] CurrentKeyError),
141
142    /// The signature does not verify under the registry's current key. Either
143    /// the response was tampered with, signed by a different key (e.g. a stale
144    /// pre-rotation key or a stolen device key), or bound to other inputs.
145    #[error("signature does not verify under the registry's current key for {did}: {reason}")]
146    SignatureInvalid {
147        /// The DID whose registry key rejected the signature.
148        did: String,
149        /// The verification failure, rendered for display.
150        reason: String,
151    },
152}
153
154/// Verify an auth-challenge response **offline** against the registry's
155/// in-force key for `did`.
156///
157/// The counterpart of [`build_auth_challenge_message`]: rebuilds the exact
158/// canonical payload the signer signed, resolves the DID's *current* signing
159/// key by replaying its KEL from the local registry (post-rotation key, never
160/// a stale inception key), and checks the signature in-process. No network,
161/// no auth server — the registry is the only authority consulted.
162///
163/// Args:
164/// * `registry`: The backend holding the DID's KEL (the trusted floor).
165/// * `did`: The signer's `did:keri:` controller DID.
166/// * `nonce`: The challenge nonce the verifier issued.
167/// * `domain`: The domain the challenge was bound to.
168/// * `signature`: The raw signature bytes from the challenge response.
169///
170/// Usage:
171/// ```ignore
172/// let verified = verify_auth_challenge(&registry, &did, &nonce, "auths.dev", &sig)?;
173/// println!("alive: {} under {}", verified.did, verified.public_key_hex);
174/// ```
175#[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        // The resolver already parsed this key; a length mismatch here is a
187        // registry inconsistency, not a caller error.
188        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        /// A registry holding one identity whose signing key the test controls.
248        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(&registry, &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            // A different keypair than the one the registry holds — the
296            // stolen-device / stale-key case.
297            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(&registry, &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            // A valid signature over a DIFFERENT nonce must not verify — the
314            // replayed-response case.
315            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                &registry,
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                &registry,
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(&registry, "did:keri:E", "", "auths.dev", &[0u8; 64])
351                .unwrap_err();
352            assert!(matches!(
353                err,
354                AuthChallengeVerifyError::Challenge(AuthChallengeError::EmptyNonce)
355            ));
356        }
357    }
358}