techne-sdk 0.3.3

TechneSDK's logic written once: version ranking (eventState) and offline record verification over the published check, shipped to TypeScript as WebAssembly; with opt-in atproto plumbing (service auth, DID resolution, signed labels, proven record fetches, PDS password sessions) behind the `crypto` and `net` features.
Documentation
//! DIDs: documents, the in-network endpoint overrides, and (with `net`) the resolver that turns a DID
//! into its `#atproto` key and its PDS.
//!
//! Ported from regenOS `regenos_appview::auth::did_resolver` (P20.7 G4). The trait, the in-memory
//! double and the error are unchanged. [`CommonDidKeyResolver`] keeps its behaviour (PLC + `did:web`,
//! bounded by a timeout, the same "could not answer" vs "no such document" split, bidirectional
//! handle verification, `DID_ENDPOINT_OVERRIDES`) but is written over `reqwest` directly instead of
//! `atrium-identity`, so this crate pulls no atrium.
//!
//! Pure (`crypto`): [`DidDocument`] and its lookups, [`is_did`], [`overrides`]. With `net`: the
//! [`DidKeyResolver`] trait, [`InMemoryDidKeyResolver`], [`CommonDidKeyResolver`].

pub mod overrides;

pub use overrides::{overrides_or_fail, EndpointOverrides, OverrideError};

use serde::{Deserialize, Serialize};

/// A DID document, the fields atproto reads.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct DidDocument {
    #[serde(skip_serializing_if = "Option::is_none", rename = "@context")]
    pub context: Option<Vec<String>>,
    pub id: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub also_known_as: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub verification_method: Option<Vec<VerificationMethod>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub service: Option<Vec<Service>>,
}

/// One `verificationMethod` entry.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct VerificationMethod {
    pub id: String,
    pub r#type: String,
    pub controller: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub public_key_multibase: Option<String>,
}

/// One `service` entry.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct Service {
    pub id: String,
    pub r#type: String,
    pub service_endpoint: String,
}

impl DidDocument {
    /// The `#atproto_pds` endpoint (type `AtprotoPersonalDataServer`), when it is an http(s) URL with a host.
    pub fn get_pds_endpoint(&self) -> Option<String> {
        self.get_service_endpoint("#atproto_pds", "AtprotoPersonalDataServer")
    }

    /// The endpoint of service `id` (`#fragment`, bare or with the DID before it) of type `type`.
    pub fn get_service_endpoint(&self, id: &str, r#type: &str) -> Option<String> {
        let full_id = format!("{}{id}", self.id);
        self.service
            .as_ref()?
            .iter()
            .find(|s| (s.id == id || s.id == full_id) && s.r#type == r#type)
            .map(|s| s.service_endpoint.clone())
            .filter(|s| is_http_url(s))
    }

    /// The `#atproto` verification method (the repo signing key).
    pub fn get_signing_key(&self) -> Option<&VerificationMethod> {
        let full_id = format!("{}#atproto", self.id);
        self.verification_method.as_ref()?.iter().find(|m| m.id == "#atproto" || m.id == full_id)
    }
}

/// `http://` or `https://` followed by a non-empty host.
fn is_http_url(s: &str) -> bool {
    let Some(rest) = s.strip_prefix("https://").or_else(|| s.strip_prefix("http://")) else { return false };
    let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
    let host = authority.rsplit('@').next().unwrap_or_default();
    let host = host.split(':').next().unwrap_or_default();
    !host.is_empty()
}

/// Whether `s` is a syntactic atproto DID: `did:<lowercase method>:<id>`, the id of
/// `[A-Za-z0-9._:%-]` not ending in `:` or `%`, at most 2048 characters (atproto's DID syntax).
pub fn is_did(s: &str) -> bool {
    if s.len() > 2048 {
        return false;
    }
    let Some(rest) = s.strip_prefix("did:") else { return false };
    let Some((method, id)) = rest.split_once(':') else { return false };
    !method.is_empty()
        && method.bytes().all(|b| b.is_ascii_lowercase())
        && !id.is_empty()
        && id.bytes().all(|b| b.is_ascii_alphanumeric() || b"._:%-".contains(&b))
        && !id.ends_with([':', '%'])
}

/// Why a DID could not be resolved to a usable `#atproto` key / PDS endpoint. A verifier treats ANY
/// variant as a verification failure (fail-closed).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DidResolveError {
    /// The DID doc does not exist, or the DID is malformed or of an unsupported method.
    NotFound,
    /// The DID doc could not be fetched right now: a transport failure, a timeout, or a 5xx or 429
    /// from the directory. A caller that can tell its client "try again" (a 503) uses the distinction.
    Unavailable,
    /// The DID doc had no usable `#atproto` verification method.
    NoAtprotoKey,
    /// The `#atproto` key string was present but undecodable / the wrong curve.
    BadKey,
}

impl std::fmt::Display for DidResolveError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(match self {
            DidResolveError::NotFound => "no such DID document",
            DidResolveError::Unavailable => "the DID directory did not answer",
            DidResolveError::NoAtprotoKey => "the DID document has no #atproto key",
            DidResolveError::BadKey => "the DID document's #atproto key is unusable",
        })
    }
}

impl std::error::Error for DidResolveError {}

impl From<crate::service_jwt::ServiceJwtError> for DidResolveError {
    fn from(e: crate::service_jwt::ServiceJwtError) -> Self {
        match e {
            crate::service_jwt::ServiceJwtError::BadKey => DidResolveError::BadKey,
            _ => DidResolveError::NoAtprotoKey,
        }
    }
}

/// Extract the CLAIMED handle alias (the bare host after `at://`) from a DID doc's `alsoKnownAs`.
/// Only a CLAIM: it must be verified to resolve back to the DID before it is trusted.
pub fn handle_from_aka(aka: Option<&[String]>) -> Option<String> {
    aka?.iter().find_map(|a| a.strip_prefix("at://")).map(str::to_owned)
}

/// The bidirectional-verification decision: the CLAIMED handle only when it resolves back to `did`.
pub fn verified_handle(did: &str, claimed: &str, resolved: Option<&str>) -> Option<String> {
    match resolved {
        Some(r) if r == did => Some(claimed.to_owned()),
        #[cfg(feature = "net")]
        Some(r) => {
            tracing::warn!(did, claimed_handle = %claimed, resolved_did = %r, "alsoKnownAs handle failed bidirectional verification");
            None
        }
        _ => None,
    }
}

#[cfg(feature = "net")]
mod resolver;
#[cfg(feature = "net")]
pub use resolver::{CommonDidKeyResolver, DidKeyResolver, InMemoryDidKeyResolver};

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn dids_are_checked_for_syntax() {
        for ok in ["did:plc:ewvi7nxzyoun6zhxrhs64oiz", "did:web:example.com", "did:web:localhost%3A3000", "did:x:a:b"] {
            assert!(is_did(ok), "{ok}");
        }
        for bad in ["", "did:", "did:plc", "did:plc:", "did:PLC:abc", "plc:abc", "did:plc:abc:", "did:plc:a b", "did:plc:abc%"] {
            assert!(!is_did(bad), "{bad}");
        }
        assert!(!is_did(&format!("did:plc:{}", "a".repeat(2048))));
    }

    #[test]
    fn a_document_names_its_key_and_pds() {
        let doc: DidDocument = serde_json::from_value(serde_json::json!({
            "id": "did:plc:x",
            "verificationMethod": [{"id": "did:plc:x#atproto", "type": "Multikey", "controller": "did:plc:x", "publicKeyMultibase": "zQ3s"}],
            "service": [
                {"id": "#atproto_pds", "type": "AtprotoPersonalDataServer", "serviceEndpoint": "https://pds.example"},
                {"id": "#other", "type": "AtprotoPersonalDataServer", "serviceEndpoint": "https://wrong.example"},
            ],
        }))
        .unwrap();
        assert_eq!(doc.get_signing_key().and_then(|m| m.public_key_multibase.as_deref()), Some("zQ3s"));
        assert_eq!(doc.get_pds_endpoint().as_deref(), Some("https://pds.example"));
        let mut bad = doc.clone();
        bad.service.as_mut().unwrap()[0].service_endpoint = "ftp://pds.example".into();
        assert_eq!(bad.get_pds_endpoint(), None, "only an http(s) URL with a host");
        bad.service.as_mut().unwrap()[0].service_endpoint = "https://".into();
        assert_eq!(bad.get_pds_endpoint(), None);
    }

    #[test]
    fn handle_from_aka_takes_the_first_at_uri_host() {
        let aka = vec!["https://web.example/profile".to_string(), "at://alice.example".to_string()];
        assert_eq!(handle_from_aka(Some(&aka)), Some("alice.example".to_string()));
        assert_eq!(handle_from_aka(Some(&["https://web.example".to_string()])), None);
        assert_eq!(handle_from_aka(None), None);
        assert_eq!(handle_from_aka(Some(&[])), None);
    }

    #[test]
    fn verified_handle_requires_a_bidirectional_did_match() {
        assert_eq!(verified_handle("did:plc:alice", "alice.example", Some("did:plc:alice")), Some("alice.example".to_string()));
        assert_eq!(verified_handle("did:plc:attacker", "lucian.scenius.makewith.you", Some("did:plc:lucian")), None);
        assert_eq!(verified_handle("did:plc:alice", "alice.example", None), None);
    }
}