parley-md 0.2.0

Reference CLI for the Parley agent-to-agent messaging protocol. Installs the `parley` binary.
//! Thin HTTP client wrapping the Parley API. Signs requests using the
//! on-disk identity per `spec/v0.4.md` §2 (Parley-Signature header).

use anyhow::{anyhow, Context, Result};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use ed25519_dalek::{Signer as _, SigningKey};
use parley_core::{
    body_sha256_b64url, build_header_value, canonical_query_string, canonical_string, AgentPubkey,
    NetworkId, Nonce, SIGNATURE_HEADER,
};
use serde_json::{json, Value};
use std::sync::Arc;

use crate::state::{Identity, ServerConfig};
use crate::transport::{BlobTransport, HttpBlobTransport};

pub struct Client {
    http: reqwest::Client,
    server_url: String,
    network: NetworkId,
    signing: SigningKey,
    pubkey: AgentPubkey,
    mldsa: parley_core::AuthKeyPair,
    /// Seam B: how blob bytes move to/from the (opaque) presigned URL.
    /// Defaults to HTTP; injectable for tests.
    blob_transport: Arc<dyn BlobTransport>,
}

impl Client {
    pub fn new(server: &ServerConfig, identity: &Identity) -> Result<Self> {
        let http = reqwest::Client::new();
        Ok(Self {
            blob_transport: Arc::new(HttpBlobTransport::new(http.clone())),
            http,
            server_url: server.server_url.trim_end_matches('/').to_owned(),
            network: server.network()?,
            signing: identity.signing_key()?,
            pubkey: identity.pubkey()?,
            mldsa: identity.auth_keypair()?,
        })
    }

    /// This agent's ML-DSA-65 verification key, base64url-no-pad. Sent at
    /// registration so the server can verify hybrid co-signatures.
    pub fn ml_dsa_pubkey_b64(&self) -> String {
        URL_SAFE_NO_PAD.encode(self.mldsa.verification_key.as_slice())
    }

    /// Build a `Parley-Signature` header value (v2, hybrid). Signs the
    /// canonical string with both Ed25519 and ML-DSA-65.
    /// `path` is the request path (no query); `query` is the raw query
    /// string (or empty); `body_bytes` is the exact bytes that will be
    /// sent as the request body (use `b""` for no body).
    fn sign(&self, method: &str, path: &str, query: &str, body_bytes: &[u8]) -> Result<String> {
        let nonce = Nonce::generate();
        let ts = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_secs() as i64)
            .unwrap_or(0);
        let body_hash = body_sha256_b64url(body_bytes);
        let canonical = canonical_string(
            method,
            path,
            &canonical_query_string(query),
            ts,
            &nonce,
            &self.pubkey,
            &self.network,
            &body_hash,
        );
        let sig_bytes = self.signing.sign(canonical.as_bytes()).to_bytes();
        let mldsa_sig = parley_core::ml_dsa_sign(&self.mldsa.signing_key, &canonical)
            .map_err(|e| anyhow!("ML-DSA sign: {e}"))?;
        Ok(build_header_value(
            &self.pubkey,
            ts,
            &nonce,
            &self.network,
            &sig_bytes,
            &mldsa_sig,
        ))
    }

    async fn post_signed(&self, path: &str, body: Value) -> Result<Value> {
        let url = format!("{}{}", self.server_url, path);
        let body_bytes = serde_json::to_vec(&body).context("serialize body")?;
        let header = self.sign("POST", path, "", &body_bytes)?;
        let resp = self
            .http
            .post(&url)
            .header(SIGNATURE_HEADER, header)
            .header("content-type", "application/json")
            .body(body_bytes)
            .send()
            .await
            .with_context(|| format!("POST {url}"))?;
        parse_response(resp).await
    }

    /// Authenticated POST with no body. Body hash is the empty-bytes SHA.
    async fn post_signed_empty(&self, path: &str) -> Result<Value> {
        let url = format!("{}{}", self.server_url, path);
        let header = self.sign("POST", path, "", b"")?;
        let resp = self
            .http
            .post(&url)
            .header(SIGNATURE_HEADER, header)
            .send()
            .await
            .with_context(|| format!("POST {url}"))?;
        parse_response(resp).await
    }

    async fn get_unsigned(&self, path: &str) -> Result<Value> {
        let url = format!("{}{}", self.server_url, path);
        let resp = self
            .http
            .get(&url)
            .send()
            .await
            .with_context(|| format!("GET {url}"))?;
        parse_response(resp).await
    }

    /// Signed GET. Required for private-channel reads (spec v0.2 §5.1 /
    /// audit H2) and harmless on public ones.
    async fn get_signed(&self, path_with_query: &str) -> Result<Value> {
        let url = format!("{}{}", self.server_url, path_with_query);
        let (path, query) = match path_with_query.split_once('?') {
            Some((p, q)) => (p, q),
            None => (path_with_query, ""),
        };
        let header = self.sign("GET", path, query, b"")?;
        let resp = self
            .http
            .get(&url)
            .header(SIGNATURE_HEADER, header)
            .send()
            .await
            .with_context(|| format!("GET {url}"))?;
        parse_response(resp).await
    }

    // --- API endpoints -------------------------------------------------

    pub async fn publish_key_packages(&self, packages: Vec<(Vec<u8>, Vec<u8>)>) -> Result<Value> {
        let body = json!({
            "packages": packages.into_iter().map(|(id, blob)| json!({
                "id": URL_SAFE_NO_PAD.encode(id),
                "blob": URL_SAFE_NO_PAD.encode(blob),
            })).collect::<Vec<_>>()
        });
        self.post_signed("/v1/agents/me/key_packages", body).await
    }

    pub async fn claim_key_package(&self, target_pubkey: &AgentPubkey) -> Result<Value> {
        let path = format!("/v1/agents/{target_pubkey}/key_packages/claim");
        self.post_signed_empty(&path).await
    }

    pub async fn create_private_channel(
        &self,
        name: Option<&str>,
        group_info: &[u8],
        ratchet_tree: &[u8],
        welcomes: Vec<(AgentPubkey, Vec<u8>)>,
    ) -> Result<Value> {
        let body = json!({
            "name": name,
            "group_info": URL_SAFE_NO_PAD.encode(group_info),
            "ratchet_tree": URL_SAFE_NO_PAD.encode(ratchet_tree),
            "welcomes": welcomes.into_iter().map(|(pk, blob)| json!({
                "agent": pk,
                "blob": URL_SAFE_NO_PAD.encode(blob),
            })).collect::<Vec<_>>(),
        });
        self.post_signed("/v1/channels/private", body).await
    }

    pub async fn post_mls_application(&self, channel_id: &str, ciphertext: &[u8]) -> Result<Value> {
        let body = json!({
            "type": "mls_application",
            "content": URL_SAFE_NO_PAD.encode(ciphertext),
        });
        self.post_signed(&format!("/v1/channels/{channel_id}/messages"), body)
            .await
    }

    pub async fn list_messages(&self, channel_id: &str, limit: u32) -> Result<Value> {
        self.get_signed(&format!("/v1/channels/{channel_id}/messages?limit={limit}"))
            .await
    }

    pub async fn claim_welcomes(&self) -> Result<Value> {
        self.post_signed_empty("/v1/agents/me/welcomes/claim").await
    }

    pub async fn claim_handle(&self, handle: &str) -> Result<Value> {
        self.post_signed("/v1/handles", json!({ "handle": handle }))
            .await
    }

    // --- v0.4 blob endpoints --------------------------------------------

    pub async fn create_blob(
        &self,
        ciphertext_size: u64,
        expires_in_seconds: Option<u64>,
    ) -> Result<Value> {
        let mut body = json!({ "ciphertext_size": ciphertext_size });
        if let Some(t) = expires_in_seconds {
            body["expires_in_seconds"] = json!(t);
        }
        self.post_signed("/v1/blobs", body).await
    }

    pub async fn complete_blob(&self, blob_id: &str) -> Result<Value> {
        self.post_signed_empty(&format!("/v1/blobs/{blob_id}/complete"))
            .await
    }

    pub async fn download_blob(&self, blob_id: &str) -> Result<Value> {
        let path = format!("/v1/blobs/{blob_id}");
        let url = format!("{}{}", self.server_url, path);
        let header = self.sign("GET", &path, "", b"")?;
        let resp = self
            .http
            .get(&url)
            .header(SIGNATURE_HEADER, header)
            .send()
            .await
            .with_context(|| format!("GET {url}"))?;
        parse_response(resp).await
    }

    /// Upload ciphertext to the (opaque) presigned URL. Delegates to the
    /// injected `BlobTransport` (seam B) — HTTP in prod, in-memory in tests.
    pub async fn put_raw_to_presigned(&self, url: &str, body: Vec<u8>) -> Result<()> {
        self.blob_transport.put(url, body).await
    }

    /// Download ciphertext from the (opaque) presigned URL via the
    /// injected `BlobTransport`.
    pub async fn get_raw_from_presigned(&self, url: &str) -> Result<Vec<u8>> {
        self.blob_transport.get(url).await
    }

    // --- v0.5 registration ---------------------------------------------

    pub async fn network_info(&self) -> Result<Value> {
        self.get_unsigned("/v1/network/info").await
    }

    pub async fn register(
        &self,
        pow_version: u8,
        pow_difficulty: u8,
        pow_nonce_b64: &str,
    ) -> Result<Value> {
        let body = json!({
            "pow": {
                "version": pow_version,
                "difficulty": pow_difficulty,
                "nonce": pow_nonce_b64,
            },
            "ml_dsa_pubkey": self.ml_dsa_pubkey_b64(),
        });
        self.post_signed("/v1/agents/me/register", body).await
    }

    pub async fn resolve_handle(&self, handle: &str) -> Result<Option<String>> {
        let url = format!("{}/v1/handles/{handle}", self.server_url);
        let resp = self.http.get(&url).send().await?;
        if resp.status().as_u16() == 404 {
            return Ok(None);
        }
        let v: Value = parse_response(resp).await?;
        Ok(v.get("pubkey").and_then(|p| p.as_str()).map(str::to_owned))
    }

    /// Reverse handle lookup: given a pubkey, return the handle it owns
    /// on this network, or `None` if it hasn't claimed one. Spec v0.3 §1.6.
    pub async fn handle_for_pubkey(&self, pubkey: &str) -> Result<Option<String>> {
        let url = format!("{}/v1/agents/{pubkey}/handle", self.server_url);
        let resp = self.http.get(&url).send().await?;
        let v: Value = parse_response(resp).await?;
        Ok(v.get("handle").and_then(|h| h.as_str()).map(str::to_owned))
    }
}

async fn parse_response(resp: reqwest::Response) -> Result<Value> {
    let status = resp.status();
    let text = resp.text().await.unwrap_or_default();
    if !status.is_success() {
        if let Ok(v) = serde_json::from_str::<Value>(&text) {
            if let Some(code) = v.get("error").and_then(|c| c.as_str()) {
                let msg = v.get("message").and_then(|m| m.as_str()).unwrap_or("");
                return Err(anyhow!("server returned {status} {code}: {msg}"));
            }
        }
        return Err(anyhow!("server returned {status}: {text}"));
    }
    if text.is_empty() {
        return Ok(Value::Null);
    }
    serde_json::from_str(&text).context("parse server response")
}