use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::Deserialize;
use ureq::http::{HeaderValue, Uri, header};
use super::adapter::ServiceRefusal;
pub(super) const RENEW_WITHIN_SECS: u64 = 60;
const AUDIENCE: &str = "haematite";
const REQUEST: &[u8] = br#"{"audience":"haematite"}"#;
const GRANT_HEADER: &str = "lys-grant-token";
const SIGNATURE_HEADER: &str = "lys-agent-signature";
const RUN_PASS_HEADER: &str = "lys-agent-pass";
const RUN_PASS_NAME_BYTES: usize = 64;
const ANSWER_BYTES: u64 = 65_536;
const REFUSAL_NAME_BYTES: usize = 96;
const AGENT_ID_BYTES: usize = 128;
const RENEWABLE: &[&str] = &[
"pass_expired",
"signature_refused",
"unpublished_key",
"pass_encoding_refused",
];
pub(super) enum PassSource {
File(PathBuf),
Lys(LysAgent),
Held(String),
}
impl PassSource {
pub(super) fn present(
&mut self,
mut present: impl FnMut(&str) -> Result<(), ServiceRefusal>,
) -> Result<(), ServiceRefusal> {
match self {
Self::File(path) => present(read_pass_file(path)?.trim()),
Self::Held(pass) => present(pass),
Self::Lys(agent) => {
let first = present(agent.current()?);
match first {
Err(store) if RENEWABLE.contains(&store.code.as_str()) => {
let second = present(agent.renew()?);
if second.is_err() {
agent.held = None;
}
second
}
other => other,
}
}
}
}
}
fn read_pass_file(path: &Path) -> Result<String, ServiceRefusal> {
std::fs::read_to_string(path).map_err(|error| {
refusal(
"mcp_pass_unreadable",
format!("the pass file could not be read: {error}"),
)
})
}
struct Held {
pass: String,
expires_at: u64,
}
pub(super) enum Proof {
Grant(PathBuf),
Key(PathBuf),
Launch(PathBuf),
RunPass(String),
}
impl Proof {
fn file(&self) -> Option<&Path> {
match self {
Self::Grant(path) | Self::Key(path) | Self::Launch(path) => Some(path),
Self::RunPass(_) => None,
}
}
}
pub(super) struct LysAgent {
client: ureq::Agent,
route: String,
agent: String,
path: String,
proof: Proof,
held: Option<Held>,
clock: Box<dyn Fn() -> u64 + Send>,
environment: Environment,
}
type Environment = Box<dyn Fn(&str) -> Option<String> + Send>;
#[derive(Deserialize)]
struct Answer {
pass: String,
expires_at: u64,
audience: String,
}
#[derive(Deserialize)]
struct Refused {
refusal: String,
}
impl LysAgent {
pub(super) fn new(
issuer: &str,
agent: &str,
proof: Proof,
timeout: Duration,
) -> Result<Self, &'static str> {
let origin = origin(issuer).ok_or(
"lys agent requires an https issuer origin, or http on 127.0.0.1 or localhost",
)?;
if agent.is_empty()
|| agent.len() > AGENT_ID_BYTES
|| !agent
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
{
return Err("lys agent requires a plain agent id");
}
if proof.file().is_some_and(|file| !file.is_absolute()) {
if matches!(proof, Proof::Launch(_)) {
return Err("mcp_lys_launch_proof_invalid: proof_file_must_be_absolute");
}
return Err("credential or key file requires an absolute path");
}
if let Proof::RunPass(name) = &proof
&& (name.is_empty()
|| name.len() > RUN_PASS_NAME_BYTES
|| !name
.bytes()
.all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_'))
{
return Err("run pass requires an upper-case environment variable name");
}
let config = ureq::Agent::config_builder()
.proxy(None)
.http_status_as_error(false)
.max_redirects(0)
.max_redirects_will_error(false)
.max_idle_connections(0)
.timeout_global(Some(timeout))
.timeout_resolve(Some(timeout))
.build();
Ok(Self {
client: ureq::Agent::new_with_config(config),
route: format!("{origin}/api/agents/{agent}/pass"),
agent: agent.to_owned(),
path: format!("/api/agents/{agent}/pass"),
proof,
held: None,
clock: Box::new(system_now),
environment: Box::new(|name| std::env::var(name).ok()),
})
}
#[cfg(test)]
pub(super) fn route(&self) -> &str {
&self.route
}
#[cfg(test)]
pub(super) fn credential_file(&self) -> Option<&Path> {
self.proof.file()
}
pub(super) fn ask_now(&mut self) -> Result<&str, ServiceRefusal> {
self.renew()
}
fn current(&mut self) -> Result<&str, ServiceRefusal> {
self.current_with_margin(RENEW_WITHIN_SECS)
}
pub(super) fn current_until_expiry(&mut self) -> Result<&str, ServiceRefusal> {
self.current_with_margin(0)
}
fn current_with_margin(&mut self, margin: u64) -> Result<&str, ServiceRefusal> {
let renew_after = (self.clock)().saturating_add(margin);
let held = match self.held.take() {
Some(held) if held.expires_at > renew_after => held,
_ => self.ask()?,
};
Ok(&self.held.insert(held).pass)
}
fn renew(&mut self) -> Result<&str, ServiceRefusal> {
self.held = None;
let held = self.ask()?;
Ok(&self.held.insert(held).pass)
}
fn ask(&self) -> Result<Held, ServiceRefusal> {
let mut request = self.client.post(self.route.as_str());
if let Proof::Launch(path) = &self.proof {
let invalid =
|error: anyhow::Error| refusal("mcp_lys_launch_proof_invalid", error.to_string());
let proof = crate::assistant::launch_proof::read(path, 65_536).map_err(invalid)?;
for (name, value) in
&crate::assistant::launch_proof::headers(&proof).map_err(invalid)?
{
request = request.header(name, value);
}
} else {
let (name, proof) = match &self.proof {
Proof::Grant(_) => (GRANT_HEADER, self.credential()?),
Proof::RunPass(name) => (RUN_PASS_HEADER, self.run_pass(name)?),
Proof::Key(path) => {
let key = super::lys_sign::read_key(path)?;
let millis = (self.clock)().saturating_mul(1000);
let signed =
super::lys_sign::signature(&key, &self.agent, &self.path, REQUEST, millis)?;
(SIGNATURE_HEADER, signed)
}
Proof::Launch(_) => {
return Err(refusal(
"mcp_lys_launch_proof_invalid",
"launch proof requires its header file".into(),
));
}
};
let mut token = HeaderValue::from_str(&proof).map_err(|_| {
refusal(
"mcp_lys_credential_unreadable",
"the agent's proof is not a header value".into(),
)
})?;
token.set_sensitive(true);
request = request.header(name, token);
}
let mut response = request
.header(header::CONTENT_TYPE, "application/json")
.header(header::ACCEPT, "application/json")
.send(REQUEST)
.map_err(|error| self.could_not_ask(&error))?;
let status = response.status().as_u16();
let body = response
.body_mut()
.with_config()
.limit(ANSWER_BYTES)
.read_to_vec()
.map_err(|error| match error {
ureq::Error::BodyExceedsLimit(_) => refusal(
"mcp_lys_answer_invalid",
format!("Lys's pass answer exceeds {ANSWER_BYTES} bytes"),
),
other => self.could_not_ask(&other),
})?;
if status != 200 {
return Err(refused(status, &body));
}
let invalid = |why: &str| {
refusal(
"mcp_lys_answer_invalid",
format!("Lys answered HTTP 200 without a usable pass: {why}"),
)
};
let answer: Answer =
serde_json::from_slice(&body).map_err(|_| invalid("the answer is not a pass"))?;
if answer.audience != AUDIENCE {
return Err(invalid("the pass names another audience"));
}
let pass = answer.pass.trim();
if pass.is_empty() {
return Err(invalid("the pass is empty"));
}
if answer.expires_at <= (self.clock)() {
return Err(invalid("the pass has already expired"));
}
Ok(Held {
pass: pass.to_owned(),
expires_at: answer.expires_at,
})
}
fn credential(&self) -> Result<String, ServiceRefusal> {
let file = self.proof.file().ok_or_else(|| {
refusal(
"mcp_lys_credential_unreadable",
"no credential file is configured".into(),
)
})?;
let text = std::fs::read_to_string(file).map_err(|error| {
refusal(
"mcp_lys_credential_unreadable",
format!("the credential file could not be read: {}", error.kind()),
)
})?;
let trimmed = text.trim();
if trimmed.is_empty() {
return Err(refusal(
"mcp_lys_credential_unreadable",
"the credential file is empty".into(),
));
}
Ok(trimmed.to_owned())
}
fn could_not_ask(&self, error: &ureq::Error) -> ServiceRefusal {
refusal(
"mcp_lys_unreachable",
format!("Lys could not be asked at {}: {error}", self.route),
)
}
}
fn refused(status: u16, body: &[u8]) -> ServiceRefusal {
let name = serde_json::from_slice::<Refused>(body)
.ok()
.map(|refused| refused.refusal)
.filter(|name| {
!name.is_empty()
&& name.len() <= REFUSAL_NAME_BYTES
&& name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
});
let message = name.map_or_else(
|| format!("Lys refused the agent pass with HTTP {status} and no refusal name"),
|name| format!("Lys refused the agent pass: {name} (HTTP {status})"),
);
refusal("mcp_lys_pass_refused", message)
}
fn refusal(code: &str, message: String) -> ServiceRefusal {
ServiceRefusal {
code: code.into(),
message,
receipt: None,
}
}
fn origin(value: &str) -> Option<String> {
if value.contains('#') || !value.is_ascii() {
return None;
}
let uri: Uri = value.parse().ok()?;
let scheme = uri.scheme_str()?;
let authority = uri.authority()?;
let host = authority.host();
let local = matches!(host, "127.0.0.1" | "localhost");
let allowed = scheme == "https" || (scheme == "http" && local);
let bare = uri.query().is_none() && matches!(uri.path(), "" | "/");
(allowed && bare && !authority.as_str().contains('@'))
.then(|| format!("{scheme}://{}", authority.as_str().to_ascii_lowercase()))
}
fn system_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |elapsed| elapsed.as_secs())
}
#[cfg(test)]
#[path = "lys_pass_tests.rs"]
mod tests;
impl LysAgent {
fn run_pass(&self, name: &str) -> Result<String, ServiceRefusal> {
let text = (self.environment)(name).ok_or_else(|| {
refusal(
"mcp_lys_credential_unreadable",
format!("the run pass variable {name} is not set to text"),
)
})?;
let trimmed = text.trim();
if trimmed.is_empty() {
return Err(refusal(
"mcp_lys_credential_unreadable",
format!("the run pass variable {name} is empty"),
));
}
Ok(trimmed.to_owned())
}
}