liminal-server 0.25.4

Standalone server for the liminal messaging bus
Documentation
//! The agent pass request signs its request path, including the `api_url` prefix.

use super::{MachineConfig, PassRefusal, api_url, cose, named};
use lys_core::keys::Ed25519Identity;
use std::io::Read;
use std::time::{SystemTime, UNIX_EPOCH};
use zeroize::Zeroizing;

const MAX_BODY: usize = 131_072;

#[derive(serde::Deserialize)]
struct Answer {
    pass: String,
    expires_at: u64,
    audience: String,
}

pub(super) struct Issued {
    pub(super) token: Zeroizing<String>,
    pub(super) expires_at: u64,
}

pub(super) trait Renewal: Send + Sync + std::fmt::Debug {
    fn renew(
        &self,
        adapter: &super::LysPassAdapter,
        now: u64,
    ) -> Result<(Zeroizing<String>, super::Current), PassRefusal>;
}

impl Renewal for Dial {
    fn renew(
        &self,
        adapter: &super::LysPassAdapter,
        now: u64,
    ) -> Result<(Zeroizing<String>, super::Current), PassRefusal> {
        let issued = self.ask()?;
        let current = self.verified(adapter, &issued, now)?;
        Ok((issued.token, current))
    }
}

pub(super) struct Dial {
    http: reqwest::Client,
    endpoint: reqwest::Url,
    agent: String,
    audience: String,
    body: Vec<u8>,
    key: Ed25519Identity,
    handle: tokio::runtime::Handle,
}

impl std::fmt::Debug for Dial {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter.debug_struct("Dial").finish_non_exhaustive()
    }
}

impl Dial {
    pub(super) fn new(
        base: &str,
        config: &MachineConfig,
        key: Ed25519Identity,
        handle: tokio::runtime::Handle,
    ) -> Result<Self, PassRefusal> {
        let endpoint = api_url(base)?
            .join(&format!("agents/{}/pass", config.agent_id))
            .map_err(|_| PassRefusal("endpoint_refused"))?;
        Ok(Self {
            http: reqwest::Client::builder()
                .redirect(reqwest::redirect::Policy::none())
                .timeout(std::time::Duration::from_secs(10))
                .build()
                .map_err(|_| PassRefusal("lys_could_not_be_asked"))?,
            endpoint,
            agent: config.agent_id.clone(),
            audience: config.audience.clone(),
            body: serde_json::to_vec(&serde_json::json!({"audience":config.audience}))
                .map_err(|_| PassRefusal("contract_refused"))?,
            key,
            handle,
        })
    }

    pub(super) fn ask(&self) -> Result<Issued, PassRefusal> {
        std::thread::scope(|scope| scope.spawn(|| self.handle.block_on(self.request())).join())
            .map_err(|_| PassRefusal("lys_could_not_be_asked"))?
    }

    async fn request(&self) -> Result<Issued, PassRefusal> {
        let at = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map_err(|_| PassRefusal("machine_pass_clock_refused"))?;
        let at =
            u64::try_from(at.as_millis()).map_err(|_| PassRefusal("machine_pass_clock_refused"))?;
        let mut random = [0; 16];
        std::fs::File::open("/dev/urandom")
            .and_then(|mut file| file.read_exact(&mut random))
            .map_err(|_| PassRefusal("agent_nonce_unavailable"))?;
        let signature = cose::header(
            &self.key,
            &self.agent,
            self.endpoint.path(),
            &self.body,
            at,
            &hex::encode(random),
        );
        let response = self
            .http
            .post(self.endpoint.clone())
            .header("content-type", "application/json")
            .header("lys-agent-signature", signature)
            .body(self.body.clone())
            .send()
            .await
            .map_err(|_| PassRefusal("lys_could_not_be_asked"))?;
        let (status, bytes) = Self::read(response).await?;
        if !status.is_success() {
            return Err(Self::refused(&bytes));
        }
        let answer: Answer =
            serde_json::from_slice(&bytes).map_err(|_| PassRefusal("contract_refused"))?;
        if answer.pass.is_empty()
            || answer.audience != self.audience
            || answer.expires_at <= at / 1000
        {
            return Err(PassRefusal("contract_refused"));
        }
        Ok(Issued {
            token: Zeroizing::new(answer.pass),
            expires_at: answer.expires_at,
        })
    }

    async fn read(
        mut response: reqwest::Response,
    ) -> Result<(reqwest::StatusCode, Zeroizing<Vec<u8>>), PassRefusal> {
        let status = response.status();
        let mut bytes = Zeroizing::new(Vec::new());
        while let Some(chunk) = response
            .chunk()
            .await
            .map_err(|_| PassRefusal("lys_could_not_be_asked"))?
        {
            if bytes.len().saturating_add(chunk.len()) > MAX_BODY {
                return Err(PassRefusal("contract_refused"));
            }
            bytes.extend_from_slice(&chunk);
        }
        Ok((status, bytes))
    }

    fn refused(bytes: &[u8]) -> PassRefusal {
        let Ok(body) = serde_json::from_slice::<serde_json::Value>(bytes) else {
            return PassRefusal("contract_refused");
        };
        named(
            body.get("refusal")
                .or_else(|| body.get("error"))
                .and_then(serde_json::Value::as_str)
                .unwrap_or("agent_dial_refused"),
        )
    }

    pub(super) fn verified(
        &self,
        adapter: &super::LysPassAdapter,
        issued: &Issued,
        now: u64,
    ) -> Result<super::Current, PassRefusal> {
        let current = super::verified(adapter, issued.token.as_bytes(), now)?;
        let pass = super::LysPassConsumer::verify_offline(
            adapter,
            issued.token.as_bytes(),
            &self.audience,
            now,
        )
        .map_err(|error| named(error.name()))?;
        if pass.verified().claims().holder.id != self.agent || current.expiry != issued.expires_at {
            return Err(PassRefusal("contract_refused"));
        }
        Ok(current)
    }
}