//! Bidirectional handle verification. //! //! OAuth returns a DID. The DID document advertises a handle in //! `alsoKnownAs`, but the account controls that document, so read alone it is //! a claim anybody can make about any name. The binding only holds if the //! handle also resolves *back* to the DID through the mechanism the domain //! owner controls (docs/identity-and-sessions.md). atrium's handle resolver //! is exactly that forward resolution; this module adds only the comparison. use atrium_api::did_doc::DidDocument; use atrium_api::types::string::{Did, Handle}; use atrium_common::resolver::Resolver; /// The verified handle, or None for unverified — which the caller stores as /// such and the UI shows next to the word "unverified". Any resolution /// failure is unverified rather than an error: for some hosting setups that /// is the honest answer, not a fault (docs/local-dev.md). /// /// Generic over the two resolvers rather than naming the concrete ones, so /// the comparison this module exists for can be tested against fakes without /// a DNS or HTTPS round trip. atrium still stops at this module's door: the /// bounds are its traits and its types. pub async fn verify_handle(did_resolver: &D, handle_resolver: &H, did: &Did) -> Option where D: Resolver, H: Resolver, { let document = match did_resolver.resolve(did).await { Ok(document) => document, Err(_) => { tracing::warn!("handle verification: DID document fetch failed"); return None; } }; let claimed = document .also_known_as .as_ref()? .iter() .find_map(|aka| aka.strip_prefix("at://"))? .to_owned(); let handle = Handle::new(claimed).ok()?; match handle_resolver.resolve(&handle).await { Ok(resolved) if resolved == *did => Some(handle.as_str().to_owned()), Ok(_) => { tracing::warn!("handle verification: handle resolves to a different DID"); None } Err(_) => { tracing::warn!("handle verification: handle does not resolve"); None } } } #[cfg(test)] mod tests { use super::*; struct Unreachable; /// Answers with whatever the test set up, so each half of the round trip /// can fail on its own. struct Fake(Result, std::marker::PhantomData); impl Fake { fn ok(value: O) -> Self { Fake(Ok(value), std::marker::PhantomData) } fn err() -> Self { Fake(Err(Unreachable), std::marker::PhantomData) } } impl Resolver for Fake { type Input = I; type Output = O; type Error = Unreachable; async fn resolve(&self, _input: &I) -> Result { self.0.as_ref().cloned().map_err(|_| Unreachable) } } fn did() -> Did { Did::new("did:plc:abc".to_owned()).unwrap() } fn document(also_known_as: Option>) -> DidDocument { DidDocument { context: None, id: did().as_str().to_owned(), also_known_as: also_known_as.map(|aka| aka.into_iter().map(str::to_owned).collect()), verification_method: None, service: None, } } #[tokio::test] async fn both_directions_agree() { let handle = verify_handle( &Fake::ok(document(Some(vec!["at://someone.example"]))), &Fake::ok(did()), &did(), ) .await; assert_eq!(handle.as_deref(), Some("someone.example")); } #[tokio::test] async fn a_handle_pointing_at_another_did_is_unverified() { // The whole point of the second direction: the DID document is // written by the account, so this is someone claiming a name they do // not own. let other = Did::new("did:plc:somebody-else".to_owned()).unwrap(); let handle = verify_handle( &Fake::ok(document(Some(vec!["at://someone.example"]))), &Fake::ok(other), &did(), ) .await; assert_eq!(handle, None); } #[tokio::test] async fn a_handle_that_does_not_resolve_is_unverified() { let handle = verify_handle( &Fake::ok(document(Some(vec!["at://someone.example"]))), &Fake::::err(), &did(), ) .await; assert_eq!(handle, None); } #[tokio::test] async fn an_unreachable_did_document_is_unverified() { let handle = verify_handle(&Fake::::err(), &Fake::ok(did()), &did()).await; assert_eq!(handle, None); } #[tokio::test] async fn a_document_claiming_no_handle_is_unverified() { for aka in [None, Some(vec![]), Some(vec!["https://example.com"])] { let handle = verify_handle(&Fake::ok(document(aka)), &Fake::ok(did()), &did()).await; assert_eq!(handle, None); } } }