use auths_core::error::AuthsErrorInfo;
use thiserror::Error;
#[cfg(feature = "backend-git")]
use crate::keri::{CurrentKeyError, resolve_current_public_key};
#[cfg(feature = "backend-git")]
use crate::ports::RegistryBackend;
#[derive(Debug, Clone)]
pub struct SignedAuthChallenge {
pub signature_hex: String,
pub public_key_hex: String,
pub did: String,
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum AuthChallengeError {
#[error("nonce must not be empty")]
EmptyNonce,
#[error("domain must not be empty")]
EmptyDomain,
#[error("canonical JSON serialization failed: {0}")]
Canonicalization(String),
}
impl AuthsErrorInfo for AuthChallengeError {
fn error_code(&self) -> &'static str {
match self {
Self::EmptyNonce => "AUTHS-E6001",
Self::EmptyDomain => "AUTHS-E6002",
Self::Canonicalization(_) => "AUTHS-E6003",
}
}
fn suggestion(&self) -> Option<&'static str> {
match self {
Self::EmptyNonce => Some("Provide the nonce from the authentication challenge"),
Self::EmptyDomain => Some("Provide the domain (e.g. auths.dev)"),
Self::Canonicalization(_) => {
Some("This is an internal error; please report it as a bug")
}
}
}
}
pub fn build_auth_challenge_message(
nonce: &str,
domain: &str,
) -> Result<String, AuthChallengeError> {
if nonce.is_empty() {
return Err(AuthChallengeError::EmptyNonce);
}
if domain.is_empty() {
return Err(AuthChallengeError::EmptyDomain);
}
let payload = serde_json::json!({
"domain": domain,
"nonce": nonce,
});
json_canon::to_string(&payload).map_err(|e| AuthChallengeError::Canonicalization(e.to_string()))
}
#[cfg(feature = "backend-git")]
#[derive(Debug, Clone)]
pub struct VerifiedAuthChallenge {
pub did: String,
pub public_key_hex: String,
pub curve: auths_crypto::CurveType,
}
#[cfg(feature = "backend-git")]
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum AuthChallengeVerifyError {
#[error(transparent)]
Challenge(#[from] AuthChallengeError),
#[error(transparent)]
CurrentKey(#[from] CurrentKeyError),
#[error("signature does not verify under the registry's current key for {did}: {reason}")]
SignatureInvalid {
did: String,
reason: String,
},
}
#[cfg(feature = "backend-git")]
pub fn verify_auth_challenge(
registry: &dyn RegistryBackend,
did: &str,
nonce: &str,
domain: &str,
signature: &[u8],
) -> Result<VerifiedAuthChallenge, AuthChallengeVerifyError> {
let message = build_auth_challenge_message(nonce, domain)?;
let (pk_bytes, curve) = resolve_current_public_key(registry, did)?;
let key = auths_keri::KeriPublicKey::from_verkey_bytes(&pk_bytes, curve).map_err(|e| {
CurrentKeyError::UnsupportedKey {
did: did.to_string(),
reason: e.to_string(),
}
})?;
key.verify_signature(message.as_bytes(), signature)
.map_err(|reason| AuthChallengeVerifyError::SignatureInvalid {
did: did.to_string(),
reason,
})?;
Ok(VerifiedAuthChallenge {
did: did.to_string(),
public_key_hex: hex::encode(&pk_bytes),
curve,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_json_sorts_keys_alphabetically() {
let payload = serde_json::json!({
"nonce": "abc",
"domain": "xyz",
});
let canonical = json_canon::to_string(&payload).expect("canonical");
assert_eq!(canonical, r#"{"domain":"xyz","nonce":"abc"}"#);
}
#[test]
fn build_auth_challenge_message_produces_canonical_json() {
let msg = build_auth_challenge_message("abc123", "auths.dev").unwrap();
assert_eq!(msg, r#"{"domain":"auths.dev","nonce":"abc123"}"#);
}
#[test]
fn build_auth_challenge_message_rejects_empty_nonce() {
let err = build_auth_challenge_message("", "auths.dev").unwrap_err();
assert!(matches!(err, AuthChallengeError::EmptyNonce));
}
#[test]
fn build_auth_challenge_message_rejects_empty_domain() {
let err = build_auth_challenge_message("abc", "").unwrap_err();
assert!(matches!(err, AuthChallengeError::EmptyDomain));
}
#[cfg(feature = "backend-git")]
mod verify {
use super::*;
use auths_id::testing::fakes::FakeRegistryBackend;
use auths_keri::{
CesrKey, Event, IcpEvent, KeriPublicKey, KeriSequence, Prefix, Said, Threshold,
VersionString, compute_next_commitment, finalize_icp_event,
};
use ring::signature::{Ed25519KeyPair, KeyPair};
fn registry_with_identity(seed: [u8; 32]) -> (FakeRegistryBackend, String, Ed25519KeyPair) {
let keypair = Ed25519KeyPair::from_seed_unchecked(&seed).unwrap();
let key = KeriPublicKey::ed25519(keypair.public_key().as_ref()).unwrap();
let next = KeriPublicKey::ed25519(&[9u8; 32]).unwrap();
let icp = IcpEvent {
v: VersionString::placeholder(),
d: Said::default(),
i: Prefix::default(),
s: KeriSequence::new(0),
kt: Threshold::Simple(1),
k: vec![CesrKey::new_unchecked(key.to_qb64().unwrap())],
nt: Threshold::Simple(1),
n: vec![compute_next_commitment(&next)],
bt: Threshold::Simple(0),
b: vec![],
c: vec![],
a: vec![],
};
let finalized = finalize_icp_event(icp).unwrap();
let prefix = finalized.i.clone();
let did = format!("did:keri:{prefix}");
let registry = FakeRegistryBackend::new();
registry
.append_event(&prefix, &Event::Icp(finalized))
.unwrap();
(registry, did, keypair)
}
#[test]
fn challenge_signed_by_registry_key_verifies() {
let (registry, did, keypair) = registry_with_identity([7u8; 32]);
let message = build_auth_challenge_message("abc123", "auths.dev").unwrap();
let signature = keypair.sign(message.as_bytes());
let verified =
verify_auth_challenge(®istry, &did, "abc123", "auths.dev", signature.as_ref())
.unwrap();
assert_eq!(verified.did, did);
assert_eq!(
verified.public_key_hex,
hex::encode(keypair.public_key().as_ref())
);
assert_eq!(verified.curve, auths_crypto::CurveType::Ed25519);
}
#[test]
fn foreign_key_signature_is_rejected() {
let (registry, did, _keypair) = registry_with_identity([7u8; 32]);
let thief = Ed25519KeyPair::from_seed_unchecked(&[8u8; 32]).unwrap();
let message = build_auth_challenge_message("abc123", "auths.dev").unwrap();
let signature = thief.sign(message.as_bytes());
let err =
verify_auth_challenge(®istry, &did, "abc123", "auths.dev", signature.as_ref())
.unwrap_err();
assert!(matches!(
err,
AuthChallengeVerifyError::SignatureInvalid { .. }
));
}
#[test]
fn nonce_is_bound_into_the_verdict() {
let (registry, did, keypair) = registry_with_identity([7u8; 32]);
let message = build_auth_challenge_message("old-nonce", "auths.dev").unwrap();
let signature = keypair.sign(message.as_bytes());
let err = verify_auth_challenge(
®istry,
&did,
"fresh-nonce",
"auths.dev",
signature.as_ref(),
)
.unwrap_err();
assert!(matches!(
err,
AuthChallengeVerifyError::SignatureInvalid { .. }
));
}
#[test]
fn unknown_did_fails_resolution() {
let registry = FakeRegistryBackend::new();
let err = verify_auth_challenge(
®istry,
"did:keri:ENotHere0000000000000000000000000000000000",
"abc123",
"auths.dev",
&[0u8; 64],
)
.unwrap_err();
assert!(matches!(err, AuthChallengeVerifyError::CurrentKey(_)));
}
#[test]
fn empty_nonce_is_refused_before_any_resolution() {
let registry = FakeRegistryBackend::new();
let err = verify_auth_challenge(®istry, "did:keri:E", "", "auths.dev", &[0u8; 64])
.unwrap_err();
assert!(matches!(
err,
AuthChallengeVerifyError::Challenge(AuthChallengeError::EmptyNonce)
));
}
}
}