liminal_server/link_certificate/
standing.rs1use super::{CertificateRefusal, MachineIdentity};
5use crate::auth_lys::{LysPassConsumer, lys_pass_adapter::LysPassAdapter};
6use crate::config::peers::PeersConfig;
7use lys_core::attestation::verify_attestation_bytes_by_signer;
8use std::sync::Arc;
9use std::time::Duration;
10use zeroize::Zeroizing;
11
12pub struct Standing {
14 base: reqwest::Url,
15 http: reqwest::Client,
16 handle: tokio::runtime::Handle,
17 adapter: Arc<LysPassAdapter>,
18 limits: PeersConfig,
19}
20impl std::fmt::Debug for Standing {
21 fn fmt(&self, out: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22 out.debug_struct("CertificateStanding")
23 .finish_non_exhaustive()
24 }
25}
26#[derive(serde::Deserialize)]
27#[serde(deny_unknown_fields)]
28struct Answer {
29 pass: String,
30 expires_at: u64,
31 audience: String,
32}
33
34pub struct StandingPass {
36 token: Zeroizing<String>,
37 expires_at: u64,
38}
39impl std::fmt::Debug for StandingPass {
40 fn fmt(&self, out: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 out.debug_struct("StandingPass")
42 .field("bytes", &self.token.len())
43 .field("expires_at", &self.expires_at)
44 .finish_non_exhaustive()
45 }
46}
47impl StandingPass {
48 #[must_use]
50 pub fn as_bytes(&self) -> &[u8] {
51 self.token.as_bytes()
52 }
53 #[must_use]
55 pub const fn expires_at(&self) -> u64 {
56 self.expires_at
57 }
58}
59
60impl Standing {
61 pub fn new(
67 base: &str,
68 adapter: Arc<LysPassAdapter>,
69 handle: tokio::runtime::Handle,
70 limits: PeersConfig,
71 deadline: Duration,
72 ) -> Result<Arc<Self>, CertificateRefusal> {
73 limits.validate().map_err(|_| CertificateRefusal::Bound)?;
74 if deadline.is_zero() {
75 return Err(CertificateRefusal::StatusUnavailable);
76 }
77 let mut base = super::service_key::endpoint(base)?;
78 let path = base
79 .path()
80 .strip_suffix("service-key")
81 .ok_or(CertificateRefusal::ServiceKey)?
82 .to_owned();
83 base.set_path(&path);
84 let http = reqwest::Client::builder()
85 .redirect(reqwest::redirect::Policy::none())
86 .timeout(deadline)
87 .build()
88 .map_err(|_| CertificateRefusal::StatusUnavailable)?;
89 Ok(Arc::new(Self {
90 base,
91 http,
92 handle,
93 adapter,
94 limits,
95 }))
96 }
97 pub fn endpoint(&self, holder: &str) -> Result<reqwest::Url, CertificateRefusal> {
102 if holder.is_empty()
103 || !holder
104 .bytes()
105 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
106 {
107 return Err(CertificateRefusal::HolderMismatch);
108 }
109 self.base
110 .join(&format!("agents/{holder}/pass"))
111 .map_err(|_| CertificateRefusal::ServiceKey)
112 }
113 pub fn status_endpoint(&self) -> Result<reqwest::Url, CertificateRefusal> {
118 self.base
119 .join("mcp")
120 .map_err(|_| CertificateRefusal::ServiceKey)
121 }
122 #[must_use]
124 pub fn status_signature_path(endpoint: &reqwest::Url) -> &str {
125 endpoint
126 .path()
127 .strip_prefix("/api")
128 .unwrap_or_else(|| endpoint.path())
129 }
130 #[must_use]
132 pub const fn body() -> &'static [u8] {
133 br#"{"audience":"liminal"}"#
134 }
135
136 pub fn prove(
143 &self,
144 holder: &str,
145 machine: MachineIdentity,
146 der: &[u8],
147 header: &str,
148 now: u64,
149 ) -> Result<StandingPass, CertificateRefusal> {
150 if header.len() > self.limits.max_proof_bytes.get()
151 || der.len() > self.limits.max_certificate_bytes.get()
152 {
153 return Err(CertificateRefusal::Bound);
154 }
155 if der.is_empty() {
156 return Err(CertificateRefusal::Encoding);
157 }
158 let (pass_header, status_header) =
159 header.split_once('\n').ok_or(CertificateRefusal::Proof)?;
160 if status_header.contains(['\n', '\r']) {
161 return Err(CertificateRefusal::Proof);
162 }
163 let endpoint = self.endpoint(holder)?;
164 verify_proof(
165 holder,
166 machine,
167 pass_header,
168 endpoint.path(),
169 now,
170 self.limits.max_proof_bytes.get(),
171 self.limits.proof_window_ms.get(),
172 )?;
173 let status_endpoint = self.status_endpoint()?;
174 let status_body = super::status::request(holder)?;
175 verify_request(ProofRequest {
176 holder,
177 machine,
178 header: status_header,
179 path: Self::status_signature_path(&status_endpoint),
180 body: &status_body,
181 now,
182 bound: self.limits.max_proof_bytes.get(),
183 window_ms: self.limits.proof_window_ms.get(),
184 })?;
185 let token = std::thread::scope(|scope| {
186 scope
187 .spawn(|| {
188 self.handle.block_on(async {
189 self.ask_status(status_endpoint, status_header, status_body, holder, der)
190 .await?;
191 self.ask(endpoint, pass_header).await
192 })
193 })
194 .join()
195 })
196 .map_err(|_| CertificateRefusal::StatusUnavailable)??;
197 let observed = std::time::SystemTime::now()
200 .duration_since(std::time::UNIX_EPOCH)
201 .map_err(|_| CertificateRefusal::StatusUnavailable)?
202 .as_secs();
203 let now = now.max(observed);
204 let pass = self
205 .adapter
206 .verify_offline(token.as_bytes(), "liminal", now)
207 .map_err(|_| CertificateRefusal::Proof)?;
208 let claims = pass.verified().claims();
209 if claims.holder.id != holder
210 || claims.holder.kind != "agent"
211 || claims.exp <= now
212 || claims.iat > now
213 || claims.exp != token.expires_at
214 || claims.exp.saturating_sub(claims.iat)
215 > self.limits.max_certificate_pass_lifetime_secs.get()
216 {
217 return Err(CertificateRefusal::Proof);
218 }
219 Ok(token)
220 }
221 async fn ask_status(
222 &self,
223 endpoint: reqwest::Url,
224 header: &str,
225 body: Vec<u8>,
226 holder: &str,
227 der: &[u8],
228 ) -> Result<(), CertificateRefusal> {
229 let mut response = self
230 .http
231 .post(endpoint)
232 .header("lys-agent-signature", header)
233 .header("accept", "application/json, text/event-stream")
234 .header("content-type", "application/json")
235 .body(body)
236 .send()
237 .await
238 .map_err(|_| CertificateRefusal::StatusUnavailable)?;
239 let status = response.status();
240 let mut bytes = Zeroizing::new(Vec::new());
241 while let Some(chunk) = response
242 .chunk()
243 .await
244 .map_err(|_| CertificateRefusal::StatusUnavailable)?
245 {
246 if bytes
247 .len()
248 .checked_add(chunk.len())
249 .is_none_or(|length| length > self.limits.max_certificate_status_bytes.get())
250 {
251 return Err(CertificateRefusal::Bound);
252 }
253 bytes.extend_from_slice(&chunk);
254 }
255 if !status.is_success() {
256 return Err(issuer_refusal(&bytes)?);
257 }
258 super::status::response(&bytes, holder, der)
259 }
260 async fn ask(
261 &self,
262 endpoint: reqwest::Url,
263 header: &str,
264 ) -> Result<StandingPass, CertificateRefusal> {
265 let mut response = self
266 .http
267 .post(endpoint)
268 .header("lys-agent-signature", header)
269 .header("content-type", "application/json")
270 .body(Self::body())
271 .send()
272 .await
273 .map_err(|_| CertificateRefusal::StatusUnavailable)?;
274 let status = response.status();
275 let mut bytes = Zeroizing::new(Vec::new());
276 while let Some(chunk) = response
277 .chunk()
278 .await
279 .map_err(|_| CertificateRefusal::StatusUnavailable)?
280 {
281 if bytes
282 .len()
283 .checked_add(chunk.len())
284 .is_none_or(|length| length > self.limits.max_pass_response_bytes.get())
285 {
286 return Err(CertificateRefusal::Bound);
287 }
288 bytes.extend_from_slice(&chunk);
289 }
290 if !status.is_success() {
291 return Err(issuer_refusal(&bytes)?);
292 }
293 let answer: Answer =
294 serde_json::from_slice(&bytes).map_err(|_| CertificateRefusal::StatusUnavailable)?;
295 let token = Zeroizing::new(answer.pass);
296 if answer.audience != "liminal" || answer.expires_at == 0 || token.is_empty() {
297 return Err(CertificateRefusal::Proof);
298 }
299 Ok(StandingPass {
300 token,
301 expires_at: answer.expires_at,
302 })
303 }
304}
305
306fn issuer_refusal(bytes: &[u8]) -> Result<CertificateRefusal, CertificateRefusal> {
307 let reason: serde_json::Value =
308 serde_json::from_slice(bytes).map_err(|_| CertificateRefusal::StatusUnavailable)?;
309 Ok(
310 if reason["refusal"] == "AgentSignatureRefused"
311 && reason["reason"] == "AgentSignatureRefused: the signature does not verify"
312 {
313 CertificateRefusal::Withdrawn
314 } else {
315 CertificateRefusal::StatusUnavailable
316 },
317 )
318}
319
320pub fn verify_proof(
325 holder: &str,
326 machine: MachineIdentity,
327 header: &str,
328 path: &str,
329 now: u64,
330 bound: usize,
331 window_ms: u64,
332) -> Result<(), CertificateRefusal> {
333 verify_request(ProofRequest {
334 holder,
335 machine,
336 header,
337 path,
338 body: Standing::body(),
339 now,
340 bound,
341 window_ms,
342 })
343}
344
345#[derive(Clone, Copy)]
346struct ProofRequest<'a> {
347 holder: &'a str,
348 machine: MachineIdentity,
349 header: &'a str,
350 path: &'a str,
351 body: &'a [u8],
352 now: u64,
353 bound: usize,
354 window_ms: u64,
355}
356
357fn verify_request(proof: ProofRequest<'_>) -> Result<(), CertificateRefusal> {
358 let ProofRequest {
359 holder,
360 machine,
361 header,
362 path,
363 body,
364 now,
365 bound,
366 window_ms,
367 } = proof;
368 if bound == 0 || header.len() > bound {
369 return Err(CertificateRefusal::Bound);
370 }
371 let mut words = header.split_ascii_whitespace();
372 let agent = words.next().ok_or(CertificateRefusal::Proof)?;
373 let at: u64 = words
374 .next()
375 .ok_or(CertificateRefusal::Proof)?
376 .parse()
377 .map_err(|_| CertificateRefusal::Proof)?;
378 let nonce = words.next().ok_or(CertificateRefusal::Proof)?;
379 let signature = words.next().ok_or(CertificateRefusal::Proof)?;
380 let observed = now.checked_mul(1000).ok_or(CertificateRefusal::Proof)?;
381 if agent != holder
382 || words.next().is_some()
383 || window_ms == 0
384 || observed.abs_diff(at) > window_ms
385 || !hex::decode(nonce).is_ok_and(|bytes| bytes.len() == 16)
386 {
387 return Err(CertificateRefusal::Proof);
388 }
389 let cose = hex::decode(signature).map_err(|_| CertificateRefusal::Proof)?;
390 let payload = crate::link_pass::cose::payload(path, body, at, nonce);
391 verify_attestation_bytes_by_signer(&cose, &payload, machine.ed25519_public())
392 .map(|_| ())
393 .map_err(|_| CertificateRefusal::Proof)
394}