use std::fmt;
use std::time::Duration;
use zeroize::Zeroizing;
pub(super) const fn session_end(resumable: bool) -> &'static str {
if resumable { "dormant" } else { "ended" }
}
pub(super) struct Revoke {
route: String,
pass: Zeroizing<String>,
bound: usize,
client: ureq::Agent,
}
impl fmt::Debug for Revoke {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("RunPassRevoke")
}
}
impl Revoke {
pub fn new(
issuer: &str,
agent: &str,
pass: String,
bound: usize,
timeout_ms: u64,
) -> anyhow::Result<Self> {
let pass = Zeroizing::new(pass);
let issuer = validate_issuer(issuer)?;
anyhow::ensure!(
!agent.is_empty()
&& agent
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')),
"revoke agent must be one segment"
);
anyhow::ensure!(
bound > 0 && timeout_ms > 0,
"revoke response and request bounds must be positive"
);
anyhow::ensure!(
!pass.is_empty()
&& pass.is_ascii()
&& !pass.bytes().any(|byte| byte.is_ascii_control()),
"LYS_AGENT_PASS is not a valid header value"
);
let client = ureq::Agent::new_with_config(
ureq::Agent::config_builder()
.proxy(None)
.http_status_as_error(false)
.max_redirects(0)
.timeout_global(Some(Duration::from_millis(timeout_ms)))
.build(),
);
Ok(Self {
route: format!("{issuer}/api/agents/{agent}/pass"),
pass,
bound,
client,
})
}
pub fn send(self) -> anyhow::Result<()> {
revoke_run_audience(
&self.client,
&self.route,
&self.pass,
u64::try_from(self.bound)?,
)?;
Ok(())
}
}
pub(super) fn validate_issuer(issuer: &str) -> anyhow::Result<&str> {
let issuer = issuer.trim_end_matches('/');
let uri: ureq::http::Uri = issuer
.parse()
.map_err(|_| anyhow::anyhow!("lys.issuer is not an origin"))?;
let authority = uri
.authority()
.ok_or_else(|| anyhow::anyhow!("lys.issuer has no authority"))?;
anyhow::ensure!(
!authority.as_str().contains('@')
&& uri.query().is_none()
&& matches!(uri.path(), "" | "/")
&& !issuer.contains('#'),
"lys.issuer must be an origin without credentials, path, query or fragment"
);
anyhow::ensure!(
uri.scheme_str() == Some("https")
|| (uri.scheme_str() == Some("http")
&& ["localhost", "127.0.0.1", "[::1]"].contains(&authority.host())),
"lys.issuer must be https or loopback http"
);
Ok(issuer)
}
pub fn revoke_run_audience(
client: &ureq::Agent,
pass_route: &str,
pass: &str,
response_bytes: u64,
) -> Result<(), crate::lys::PassError> {
send_revoke(client, pass_route, pass, response_bytes)
.map_err(|error| crate::lys::PassError(error.to_string()))
}
fn send_revoke(
client: &ureq::Agent,
pass_route: &str,
pass: &str,
response_bytes: u64,
) -> anyhow::Result<()> {
anyhow::ensure!(
!pass.is_empty() && pass.is_ascii() && !pass.bytes().any(|byte| byte.is_ascii_control()),
"revoke proof is not a valid header value"
);
let config = client.config();
anyhow::ensure!(
config.max_redirects() == 0
&& config.proxy().is_none()
&& config
.timeouts()
.global
.is_some_and(|duration| !duration.is_zero())
&& response_bytes > 0,
"revoke needs a bounded client without redirects or a proxy"
);
let uri: ureq::http::Uri = pass_route
.parse()
.map_err(|_| anyhow::anyhow!("revoke route refused"))?;
let authority = uri
.authority()
.ok_or_else(|| anyhow::anyhow!("revoke route has no authority"))?;
let segments: Vec<_> = uri
.path()
.split('/')
.filter(|segment| !segment.is_empty())
.collect();
anyhow::ensure!(
!authority.as_str().contains('@')
&& uri.query().is_none()
&& segments.len() == 4
&& segments[0] == "api"
&& segments[1] == "agents"
&& segments[3] == "pass"
&& segments[2]
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')),
"revoke route is not the agent pass endpoint"
);
anyhow::ensure!(
uri.scheme_str() == Some("https")
|| (uri.scheme_str() == Some("http")
&& ["localhost", "127.0.0.1", "[::1]"].contains(&authority.host())),
"revoke route must be https or loopback http"
);
let mut proof = ureq::http::HeaderValue::from_bytes(pass.as_bytes())
.map_err(|_| anyhow::anyhow!("revoke proof is not a header value"))?;
proof.set_sensitive(true);
let mut response = client
.post(format!("{pass_route}/revoke"))
.header("lys-agent-pass", proof)
.header("content-type", "application/json")
.send(b"{\"audience\":\"liminal\"}".as_slice())
.map_err(|_| anyhow::anyhow!("run pass revoke transport failed"))?;
let status = response.status().as_u16();
let body = Zeroizing::new(
response
.body_mut()
.with_config()
.limit(response_bytes)
.read_to_vec()
.map_err(|_| {
anyhow::anyhow!(
"run pass revoke response could not be read within issuer_response_bytes"
)
})?,
);
if matches!(status, 200 | 204) {
return Ok(());
}
let name = serde_json::from_slice::<serde_json::Value>(&body)
.ok()
.and_then(|value| {
value
.get("refusal")
.and_then(serde_json::Value::as_str)
.map(str::to_owned)
})
.filter(|name| {
!name.is_empty()
&& !name.contains(pass)
&& name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
})
.unwrap_or_else(|| "unnamed_refusal".to_owned());
anyhow::bail!("run pass revoke refused: {name} (HTTP {status})")
}
pub fn revoke_run_pass(
issuer: &str,
agent: &str,
pass: String,
response_bytes: usize,
timeout_ms: u64,
) -> anyhow::Result<()> {
Revoke::new(issuer, agent, pass, response_bytes, timeout_ms)?.send()
}