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)
}
}