atproto-devtool 0.1.1

A multitool for the atproto developer ecosystem
Documentation
//! `SelfMintSigner`: owns a random key, an ephemeral `did:web` identity
//! server, and a reference curve. Exposes a single method for signing
//! atproto service-auth JWTs with that identity.

use std::net::SocketAddr;
use std::time::Duration;

use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use serde_json::json;
use url::Url;

// Reach `rand_core` through `k256`'s re-export so we pin the same version
// that `k256::ecdsa::SigningKey::random` expects.
use k256::elliptic_curve::rand_core;

use crate::commands::test::labeler::create_report::did_doc_server::DidDocServer;
use crate::common::identity::{AnySigningKey, Did, encode_multikey};
use crate::common::jwt::{self, JwtClaims, JwtHeader};

// Local RNG shim: `k256::ecdsa::SigningKey::random` and
// `p256::ecdsa::SigningKey::random` take `CryptoRngCore`. We build a
// thin adapter around `getrandom` (carried as a direct dependency)
// since `elliptic_curve::rand_core::OsRng` is not re-exported through
// the current dep graph.
struct GetrandomRng;
impl rand_core::RngCore for GetrandomRng {
    fn next_u32(&mut self) -> u32 {
        let mut b = [0u8; 4];
        getrandom::getrandom(&mut b).expect("OS CSPRNG");
        u32::from_le_bytes(b)
    }
    fn next_u64(&mut self) -> u64 {
        let mut b = [0u8; 8];
        getrandom::getrandom(&mut b).expect("OS CSPRNG");
        u64::from_le_bytes(b)
    }
    fn fill_bytes(&mut self, dest: &mut [u8]) {
        getrandom::getrandom(dest).expect("OS CSPRNG");
    }
    fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), rand_core::Error> {
        getrandom::getrandom(dest).map_err(|_| rand_core::Error::new("getrandom failed"))
    }
}
impl rand_core::CryptoRng for GetrandomRng {}

/// Curve selector for self-mint keys, mirrors clap's `--self-mint-curve` flag.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
pub enum SelfMintCurve {
    /// secp256k1 (JWT `alg = "ES256K"`). Default.
    #[default]
    Es256k,
    /// NIST P-256 (JWT `alg = "ES256"`).
    Es256,
}

/// A self-mint JWT signer. Owns the keypair, the DID, and the backing DID
/// document server (which is shut down on drop).
pub struct SelfMintSigner {
    signing_key: AnySigningKey,
    issuer_did: Did,
    /// Held for its Drop side effect (the server stays up while this
    /// field is alive). Also read by `did_doc_url()` to expose the
    /// listening port.
    did_doc_server: DidDocServer,
}

impl SelfMintSigner {
    /// Create a new self-mint signer with a freshly-generated key of the
    /// requested curve. Binds `127.0.0.1:0` for the DID document server.
    ///
    /// Port-stable by construction: the server binds first, then the
    /// body-builder closure embeds the bound port into the DID document
    /// before the first request is served. There is no probe phase and
    /// no window in which the port can drift.
    pub async fn spawn(curve: SelfMintCurve) -> std::io::Result<Self> {
        let signing_key = match curve {
            SelfMintCurve::Es256k => {
                AnySigningKey::K256(k256::ecdsa::SigningKey::random(&mut GetrandomRng))
            }
            SelfMintCurve::Es256 => {
                AnySigningKey::P256(p256::ecdsa::SigningKey::random(&mut GetrandomRng))
            }
        };
        let verifying_key = signing_key.verifying_key();
        let multikey = encode_multikey(&verifying_key);

        // Capture the DID that the builder computes so we can store it.
        // `Arc<Mutex<Option<Did>>>` lets the one-shot closure publish the
        // DID out through the body-builder boundary.
        let issuer_capture: std::sync::Arc<std::sync::Mutex<Option<Did>>> =
            std::sync::Arc::new(std::sync::Mutex::new(None));
        let issuer_capture_clone = issuer_capture.clone();
        let multikey_for_builder = multikey.clone();

        let server = DidDocServer::spawn(move |addr| {
            let did = did_for(addr);
            let did_doc = json!({
                "@context": ["https://www.w3.org/ns/did/v1"],
                "id": did.0,
                "alsoKnownAs": [],
                "verificationMethod": [{
                    "id": format!("{}#atproto", did.0),
                    "type": "Multikey",
                    "controller": did.0,
                    "publicKeyMultibase": multikey_for_builder,
                }],
                "service": [],
            });
            let bytes = serde_json::to_vec(&did_doc).expect("static JSON serializes");
            *issuer_capture_clone.lock().unwrap() = Some(did);
            bytes
        })
        .await?;

        let issuer_did = issuer_capture
            .lock()
            .unwrap()
            .take()
            .expect("body-builder runs synchronously before spawn returns");

        Ok(Self {
            signing_key,
            issuer_did,
            did_doc_server: server,
        })
    }

    /// The issuer DID bound to this signer (`did:web:127.0.0.1%3A{port}`).
    pub fn issuer_did(&self) -> &Did {
        &self.issuer_did
    }

    /// URL the labeler will fetch to resolve the DID document.
    pub fn did_doc_url(&self) -> url::Url {
        let mut u = base_url(self.did_doc_server.local_addr());
        u.set_path("/.well-known/did.json");
        u
    }

    /// Sign a JWT with these claims. The `iss` field in `claims` is
    /// overridden with this signer's DID so callers never forget.
    pub fn sign_jwt(&self, mut claims: JwtClaims) -> String {
        claims.iss = self.issuer_did.0.clone();
        let header = JwtHeader::for_signing_key(&self.signing_key);
        jwt::encode_compact(&header, &claims, &self.signing_key)
            .expect("encode_compact is infallible for well-formed structs")
    }

    /// Build a valid-claims template for the given labeler DID and the
    /// createReport NSID. Callers mutate specific fields for negative
    /// tests. `now_unix_secs` is the current wall-clock time in UNIX
    /// seconds; `exp_after` is the lifetime.
    pub fn valid_claims_template(
        &self,
        labeler_did: &Did,
        lxm: &str,
        now_unix_secs: i64,
        exp_after: Duration,
    ) -> JwtClaims {
        JwtClaims {
            iss: self.issuer_did.0.clone(),
            aud: labeler_did.0.clone(),
            exp: now_unix_secs + exp_after.as_secs() as i64,
            iat: now_unix_secs,
            lxm: lxm.to_string(),
            jti: crate::commands::test::labeler::create_report::sentinel::new_run_id(),
        }
    }
}

/// Construct `did:web:127.0.0.1%3A{port}` for a self-mint identity bound
/// to the given local `SocketAddr`. The `:` between the IP and the port is
/// percent-encoded per atproto did:web rules.
///
/// Uses the `SocketAddr` IP literally (typically `127.0.0.1`). IPv6
/// loopback would produce `did:web:::1%3A{port}` which the atproto did
/// syntax regex rejects; for v1 the self-mint server is IPv4-only.
pub(crate) fn did_for(addr: SocketAddr) -> Did {
    assert!(addr.is_ipv4(), "self-mint DidDocServer is IPv4-only");
    let host = addr.ip().to_string();
    let port = addr.port();
    // Percent-encode the `:` (and, defensively, any other non-alphanumeric)
    // with the standard set. For the `127.0.0.1:{port}` case this yields
    // exactly `127.0.0.1%3A{port}`.
    let encoded_hostport = format!("{host}{}{port}", utf8_percent_encode(":", NON_ALPHANUMERIC));
    Did(format!("did:web:{encoded_hostport}"))
}

/// Base URL the labeler uses to fetch the self-mint DID document:
/// `http://127.0.0.1:{port}`.
pub(crate) fn base_url(addr: SocketAddr) -> Url {
    Url::parse(&format!("http://{addr}")).expect("SocketAddr Display is always a valid authority")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::common::identity::{AnyVerifyingKey, parse_multikey};
    use crate::common::jwt::verify_compact;

    #[test]
    fn self_mint_did_encodes_colon() {
        let addr: SocketAddr = "127.0.0.1:5000".parse().unwrap();
        let did = did_for(addr);
        assert_eq!(did.0, "did:web:127.0.0.1%3A5000");
    }

    #[test]
    fn self_mint_base_url_uses_http() {
        let addr: SocketAddr = "127.0.0.1:5000".parse().unwrap();
        let url = base_url(addr);
        assert_eq!(url.as_str(), "http://127.0.0.1:5000/");
    }

    async fn round_trip(curve: SelfMintCurve, expected_alg: &str) {
        let signer = SelfMintSigner::spawn(curve).await.expect("spawn");

        // Fetch the DID document as the labeler would.
        let url = signer.did_doc_url();
        let client = reqwest::Client::new();
        let resp = client.get(url).send().await.expect("http");
        assert_eq!(resp.status(), 200);
        let doc: serde_json::Value = resp.json().await.expect("json");
        assert_eq!(
            doc["id"],
            serde_json::Value::String(signer.issuer_did().0.clone())
        );
        let vm = doc["verificationMethod"][0].clone();
        let multikey = vm["publicKeyMultibase"]
            .as_str()
            .expect("multikey")
            .to_string();

        // Decode the key and verify a signature from the signer.
        let parsed = parse_multikey(&multikey).expect("parse multikey");
        let vkey: AnyVerifyingKey = parsed.verifying_key;

        let claims = signer.valid_claims_template(
            &Did("did:plc:aaa22222222222222222bbbbbb".to_string()),
            "com.atproto.moderation.createReport",
            1_776_000_000,
            Duration::from_secs(60),
        );
        let token = signer.sign_jwt(claims.clone());
        let (header, decoded_claims) = verify_compact(&token, &vkey).expect("verify");
        assert_eq!(header.alg, expected_alg);
        assert_eq!(decoded_claims.iss, signer.issuer_did().0);
        assert_eq!(decoded_claims.aud, claims.aud);
        assert_eq!(decoded_claims.lxm, "com.atproto.moderation.createReport");
    }

    #[tokio::test]
    async fn self_mint_signer_es256k_round_trips() {
        round_trip(SelfMintCurve::Es256k, "ES256K").await;
    }

    #[tokio::test]
    async fn self_mint_signer_es256_round_trips() {
        round_trip(SelfMintCurve::Es256, "ES256").await;
    }
}