1use crate::ServerError;
6use crate::auth_lys::lys_pass_adapter::{LysPassAdapter, MachineLinkRights};
7use crate::auth_lys::session::{ConsumerAdmitter, LysAdmissionRefusal, LysAdmitter, LysSession};
8use crate::auth_lys::{
9 AccessAction, AccessDecisionError, AccessRefusal, AccessResource, DeliberateDecision,
10 GrantReceipt, HotAction, LysPassConsumer, RefusalCause, SubjectBinding,
11};
12use crate::config::types::{LysConfig, MachineConfig};
13use crate::link::{LinkAction, LinkHost, LinkRights};
14use crate::link_handshake::{LocalMachine, MachineIdentity};
15use lys_core::keys::Ed25519Identity;
16use std::net::IpAddr;
17use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
18use std::sync::{Arc, Mutex};
19use zeroize::Zeroizing;
20mod agent_dial;
21pub(crate) mod cose;
22pub(crate) mod debug;
23mod holder;
24mod peer_security;
25mod start_issue;
26use agent_dial::{Dial, Renewal};
27pub(crate) use holder::MachinePass;
28pub(crate) use start_issue::StartIssue;
29
30type GrantAnswer = Result<GrantReceipt, AccessDecisionError>;
31type LinkAnswer = Result<DeliberateDecision, AccessDecisionError>;
32static REFUSALS: AtomicU64 = AtomicU64::new(0);
33#[derive(Debug, thiserror::Error)]
34#[error("{0}")]
35pub(crate) struct PassRefusal(&'static str);
36impl PassRefusal {
37 pub(crate) fn source_absent(&self) -> bool {
38 self.0 == "machine_pass_not_configured"
39 }
40}
41fn named(name: &str) -> PassRefusal {
42 const NAMES: &str = "agent_dial_refused agent_signature_refused HolderRetired ProviderUnavailable \
44 ClientUnknown ScopeUnknown ScopeNotGranted PassRefused RequestMalformed signature_refused \
45 wrong_audience wrong_issuer pass_expired pass_not_yet_valid unpublished_key \
46 pass_encoding_refused contract_refused rights_outside_audience rights_truncated endpoint_refused";
47 let name = match name {
48 "AgentSignatureRefused" => "agent_signature_refused",
49 "agent_pass_unproven" | "agent_not_holder" | "audience_not_approved" => {
50 "agent_dial_refused"
51 }
52 "agent_retired" => "HolderRetired",
53 "agent_pass_unavailable" => "ProviderUnavailable",
54 name => name,
55 };
56 let known = NAMES.split_ascii_whitespace().find(|known| *known == name);
57 PassRefusal(known.unwrap_or("machine_pass_refused"))
58}
59fn record_refusal(error: &PassRefusal, phase: &'static str) {
60 let count = REFUSALS.fetch_add(1, Ordering::Relaxed) + 1;
61 if phase == "boot" {
63 tracing::warn!(
64 reason = error.0,
65 phase,
66 count,
67 "machine pass refused; asked again at the next link open"
68 );
69 }
70 if let Some(registry) = liminal::metrics::global_registry() {
71 match registry
72 .register_counter("liminal_machine_pass_refusals_total", [("reason", error.0)])
73 {
74 Ok(counter) => counter.increment(),
75 Err(error) => {
76 tracing::error!(%error, "machine pass refusal counter registration failed");
77 }
78 }
79 }
80}
81fn identifier(value: &str) -> bool {
82 !value.is_empty() && !value.chars().any(|c| c.is_whitespace() || c.is_control())
83}
84fn api_url(value: &str) -> Result<reqwest::Url, PassRefusal> {
85 let mut url = reqwest::Url::parse(value).map_err(|_| PassRefusal("endpoint_refused"))?;
86 let host = url.host_str().ok_or(PassRefusal("endpoint_refused"))?;
87 let loopback = host == "localhost"
88 || host
89 .trim_matches(['[', ']'])
90 .parse::<IpAddr>()
91 .is_ok_and(|ip| ip.is_loopback());
92 if !matches!(url.scheme(), "http" | "https")
93 || !url.username().is_empty()
94 || url.password().is_some()
95 || url.query().is_some()
96 || url.fragment().is_some()
97 || (url.scheme() == "http" && !loopback)
98 {
99 return Err(PassRefusal("endpoint_refused"));
100 }
101 if !url.path().ends_with('/') {
102 url.set_path(&format!("{}/", url.path()));
103 }
104 Ok(url)
105}
106pub(crate) fn validate_configuration(lys: &LysConfig, errors: &mut Vec<String>) {
108 let Some(machine) = &lys.machine else {
109 return;
110 };
111 let file = !machine.pass_path.as_os_str().is_empty();
112 let dial = !machine.agent_id.is_empty() || !machine.audience.is_empty();
113 if file && dial {
114 errors.push(
115 "auth.lys.machine: pass_path and agent_id/audience are mutually exclusive".into(),
116 );
117 } else if !file && !dial {
118 errors.push("auth.lys.machine: configure pass_path or agent_id and audience".into());
119 }
120 if dial {
121 if !identifier(&machine.agent_id)
122 || !machine
123 .agent_id
124 .bytes()
125 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
126 {
127 errors.push("auth.lys.machine.agent_id: expected one nonempty route segment".into());
128 }
129 if !identifier(&machine.audience) {
130 errors.push("auth.lys.machine.audience: expected a nonempty audience".into());
131 }
132 if lys
133 .api_url
134 .as_deref()
135 .is_none_or(|url| api_url(url).is_err())
136 {
137 errors.push("auth.lys.api_url: agent_id requires HTTPS or loopback HTTP".into());
138 }
139 }
140}
141
142#[derive(Debug)]
143struct Current {
144 rights: MachineLinkRights,
145 expiry: u64,
146 renew_at: u64,
147 renewed: bool,
148}
149struct Authority {
150 current: Mutex<Option<Current>>,
153 pass: MachinePass,
154 dial: Option<Box<dyn Renewal>>,
155 adapter: Arc<LysPassAdapter>,
156 boot_pending: AtomicBool,
159}
160fn verified(adapter: &LysPassAdapter, token: &[u8], now: u64) -> Result<Current, PassRefusal> {
161 let observed = std::time::SystemTime::now()
162 .duration_since(std::time::UNIX_EPOCH)
163 .map_err(|_| PassRefusal("machine_pass_clock_refused"))?
164 .as_secs();
165 let now = now.max(observed);
166 let pass = adapter
167 .verify_offline(token, crate::auth_lys::LIMINAL_PASS_AUDIENCE, now)
168 .map_err(|error| named(error.name()))?;
169 if pass.verified().claims().holder.kind != "agent" {
170 return Err(PassRefusal("agent_holder_refused"));
171 }
172 let expiry = pass.verified().claims().exp;
173 Ok(Current {
174 rights: MachineLinkRights::new(adapter, pass),
175 expiry,
176 renew_at: renewal_at(expiry, now),
177 renewed: false,
178 })
179}
180impl Authority {
181 fn refusal(
182 &self,
183 resource: &AccessResource,
184 action: AccessAction,
185 cause: RefusalCause,
186 ) -> AccessDecisionError {
187 AccessDecisionError {
188 cause,
189 refusal: AccessRefusal::new(resource.clone(), action, self.adapter.request_url()),
190 }
191 }
192 fn open(
193 &self,
194 link: &AccessResource,
195 end: LinkAction,
196 now: u64,
197 ) -> Result<(), AccessDecisionError> {
198 let action = end.other();
199 self.refresh_expired(now).map_err(|error| {
200 let cause = if error.0 == "pass_expired" {
201 RefusalCause::Expired
202 } else {
203 RefusalCause::DeliberateUnavailable
204 };
205 self.refusal(link, action.access_action(), cause)
206 })?;
207 let unavailable = || {
208 self.refusal(
209 link,
210 action.access_action(),
211 RefusalCause::DeliberateUnavailable,
212 )
213 };
214 self.current
215 .lock()
216 .map_err(|_| unavailable())?
217 .as_ref()
218 .map_or_else(
219 || Err(unavailable()),
220 |current| {
221 current
222 .rights
223 .holds_resource(action, link, now)
224 .map(|_| ())
225 .map_err(|mut error| {
226 if current.renewed && error.cause == RefusalCause::MissingGrant {
227 error.cause = RefusalCause::RenewedWithoutGrant;
228 }
229 error
230 })
231 },
232 )
233 }
234 fn refresh_expired(&self, now: u64) -> Result<(), PassRefusal> {
235 self.refresh(now, "link_open")
236 }
237 fn refresh(&self, now: u64, phase: &'static str) -> Result<(), PassRefusal> {
240 let mut current = self
241 .current
242 .lock()
243 .map_err(|_| PassRefusal("pass_holder_refused"))?;
244 let due = current
245 .as_ref()
246 .is_none_or(|held| held.expiry <= now || (self.dial.is_some() && held.renew_at <= now));
247 if !due {
248 return Ok(());
249 }
250 let dial = self.dial.as_ref().ok_or(PassRefusal("pass_expired"))?;
251 let boot = self.boot_pending.swap(false, Ordering::AcqRel);
252 let phase = if boot { "boot" } else { phase };
253 let (token, mut renewed) = dial.renew(&self.adapter, now).inspect_err(|error| {
255 record_refusal(error, phase);
256 })?;
257 if renewed.expiry <= now {
258 return Err(PassRefusal("pass_expired"));
259 }
260 let mut held = self
261 .pass
262 .0
263 .lock()
264 .map_err(|_| PassRefusal("pass_holder_refused"))?;
265 renewed.renewed = current.is_some();
267 held.token = token;
268 *current = Some(renewed);
269 drop(held);
270 drop(current);
271 Ok(())
272 }
273}
274#[derive(Debug)]
275struct Rights(Arc<Authority>);
276impl LinkRights for Rights {
277 fn holds(&self, action: LinkAction, subject: &SubjectBinding, now: u64) -> GrantAnswer {
278 let unavailable = || {
279 self.0.refusal(
280 subject.resource(),
281 action.access_action(),
282 RefusalCause::DeliberateUnavailable,
283 )
284 };
285 self.0
286 .current
287 .lock()
288 .map_err(|_| unavailable())?
289 .as_ref()
290 .map_or_else(
291 || Err(unavailable()),
292 |current| current.rights.holds(action, subject, now),
293 )
294 }
295}
296#[derive(Debug)]
297struct Admitter {
298 inner: Arc<dyn LysAdmitter>,
299 authority: Arc<Authority>,
300}
301impl LysAdmitter for Admitter {
302 fn admit(&self, pass: &[u8], now: u64) -> Result<Box<dyn LysSession>, LysAdmissionRefusal> {
303 Ok(Box::new(Session {
304 inner: self.inner.admit(pass, now)?,
305 authority: Arc::clone(&self.authority),
306 }))
307 }
308}
309#[derive(Debug)]
310struct Session {
311 inner: Box<dyn LysSession>,
312 authority: Arc<Authority>,
313}
314impl LysSession for Session {
315 fn hold_permission(
318 &self,
319 request: crate::auth_lys::prepared::PermissionRequest<'_>,
320 ) -> Result<serde_json::Value, lys_pass::Error> {
321 self.inner.hold_permission(request)
322 }
323 fn hold_site(
324 &self,
325 request: crate::auth_lys::prepared::SiteRequest<'_>,
326 ) -> Result<serde_json::Value, lys_pass::Error> {
327 self.inner.hold_site(request)
328 }
329 fn holder(&self) -> &str {
330 self.inner.holder()
331 }
332 fn holder_kind(&self) -> Option<&str> {
333 self.inner.holder_kind()
334 }
335 fn issued_at(&self) -> Option<u64> {
336 self.inner.issued_at()
337 }
338 fn decide(&self, subject: &SubjectBinding, action: HotAction, now: u64) -> GrantAnswer {
339 self.inner.decide(subject, action, now)
340 }
341 fn decide_link(&self, subject: &SubjectBinding, action: LinkAction, now: u64) -> GrantAnswer {
342 self.inner.decide_link(subject, action, now)
343 }
344 fn open_link(&self, link: &AccessResource, end: LinkAction, now: u64) -> LinkAnswer {
345 self.authority.open(link, end, now)?;
346 self.inner.open_link(link, end, now)
347 }
348 fn decide_live(&self, target: &AccessResource, action: AccessAction, now: u64) -> LinkAnswer {
349 self.inner.decide_live(target, action, now)
350 }
351}
352pub(crate) struct PassInstall {
353 pub host: Option<Arc<LinkHost>>,
354 pub admitter: Arc<dyn LysAdmitter>,
355 pub issue: Option<StartIssue>,
357}
358pub(crate) fn configured(
362 lys: &LysConfig,
363 adapter: Arc<LysPassAdapter>,
364 handle: Option<tokio::runtime::Handle>,
365 now: u64,
366 after_bind: bool,
367) -> Result<PassInstall, ServerError> {
368 let mut install = PassInstall {
369 host: None,
370 admitter: Arc::new(ConsumerAdmitter::new(Arc::clone(&adapter))),
371 issue: None,
372 };
373 let Some(machine) = &lys.machine else {
374 return Ok(install);
375 };
376 let mut errors = Vec::new();
377 validate_configuration(lys, &mut errors);
378 if !errors.is_empty() {
379 return Err(ServerError::ConfigValidation {
380 message: errors.join("; "),
381 });
382 }
383 let defer = after_bind && start_issue::defers(lys, machine);
384 match build(lys, machine, adapter, handle, now, defer) {
385 Ok((host, authority, refused)) => {
386 if let Some(error) = &refused {
387 record_refusal(error, "boot");
388 }
389 install.issue = defer.then(|| StartIssue::new(Arc::clone(&authority), now));
390 install.host = Some(Arc::new(host));
391 install.admitter = Arc::new(Admitter {
392 inner: install.admitter,
393 authority,
394 });
395 }
396 Err(error) if !machine.agent_id.is_empty() && machine.carrier_choice.peers.is_none() => {
397 record_refusal(&error, "boot");
398 }
399 Err(error) => {
400 return Err(ServerError::ConfigValidation {
401 message: format!("auth.lys.machine: {error}"),
402 });
403 }
404 }
405 Ok(install)
406}
407fn hex_key(digits: &str) -> Result<Zeroizing<[u8; 32]>, PassRefusal> {
408 let mut key = Zeroizing::new([0; 32]);
409 hex::decode_to_slice(digits.trim(), &mut *key)
410 .map_err(|_| PassRefusal("machine_key_refused"))?;
411 Ok(key)
412}
413fn build(
414 lys: &LysConfig,
415 machine: &MachineConfig,
416 adapter: Arc<LysPassAdapter>,
417 handle: Option<tokio::runtime::Handle>,
418 now: u64,
419 defer: bool,
420) -> Result<(LinkHost, Arc<Authority>, Option<PassRefusal>), PassRefusal> {
421 let digits = Zeroizing::new(
422 std::fs::read_to_string(&machine.identity_path)
423 .map_err(|_| PassRefusal("machine_identity_refused"))?,
424 );
425 let seed = hex_key(&digits)?;
426 let local = LocalMachine::new(Ed25519Identity::from_seed(&seed));
427 let peers = peer_security::prepare(lys, machine, &adapter, handle.as_ref())?;
428 let dial = if machine.agent_id.is_empty() {
429 None
430 } else {
431 Some(Dial::new(
432 lys.api_url
433 .as_deref()
434 .ok_or(PassRefusal("endpoint_refused"))?,
435 machine,
436 Ed25519Identity::from_seed(&seed),
437 handle.ok_or(PassRefusal("lys_ask_runtime_absent"))?,
438 )?)
439 };
440 let (token, current, refused) = if defer {
441 (None, None, None)
442 } else if let Some(dial) = &dial {
443 let asked = dial.ask().and_then(|issued| {
444 let current = dial.verified(&adapter, &issued, now)?;
445 Ok((issued.token, current))
446 });
447 match asked {
448 Ok((token, current)) => (Some(token), Some(current), None),
449 Err(error) if machine.carrier_choice.peers.is_none() => (None, None, Some(error)),
453 Err(error) => return Err(error),
454 }
455 } else {
456 let token = Zeroizing::new(
457 std::fs::read_to_string(&machine.pass_path)
458 .map_err(|_| PassRefusal("machine_pass_path_refused"))?,
459 );
460 let current = verified(&adapter, token.as_bytes(), now)?;
461 (Some(token), Some(current), None)
462 };
463 let pass = MachinePass::hold(machine, token)?;
464 let authority = Arc::new(Authority {
465 current: Mutex::new(current),
466 pass,
467 dial: dial.map(|dial| Box::new(dial) as Box<dyn Renewal>),
468 adapter,
469 boot_pending: AtomicBool::new(defer),
470 });
471 authority.pass.attach(&authority)?;
472 let recorded = machine
473 .machines
474 .iter()
475 .map(|(holder, key)| Ok((holder.clone(), MachineIdentity::new(*hex_key(key)?))))
476 .collect::<Result<_, PassRefusal>>()?;
477 let host = LinkHost::new(local, Box::new(Rights(Arc::clone(&authority))), recorded);
478 let host = if let Some(peers) = peers {
479 peers.install(host)?
480 } else {
481 host
482 };
483 Ok((host, authority, refused))
484}
485
486fn renewal_at(expiry: u64, now: u64) -> u64 {
487 expiry.saturating_sub((expiry.saturating_sub(now) / 10).clamp(1, 30))
488}
489
490#[cfg(test)]
491pub(crate) mod fixture;
492#[cfg(test)]
493mod open_tests;
494#[cfg(test)]
495mod renewal_tests;