Skip to main content

liminal_server/
link_pass.rs

1//! The agent's own pass, signed for the issuer's agent route at boot.
2//!
3//! Crossings read the verified rights offline. An open renews a due agent
4//! pass and judges the renewed rights and lifetime on that same open.
5use 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    // Only contract names enter logs and metric labels, never response words.
43    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    // Open refusals are named once by their connection or retry owner.
62    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}
106/// Collects errors for the two mutually exclusive agent pass sources.
107pub(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    /// The pass held and its rights; `None` after a boot whose ask Lys
151    /// refused, until the next link open asks (ACCESS-009 R1).
152    current: Mutex<Option<Current>>,
153    pass: MachinePass,
154    dial: Option<Box<dyn Renewal>>,
155    adapter: Arc<LysPassAdapter>,
156    /// The boot left its ask for after the bind: whichever ask comes first
157    /// is the boot's (SRV-041 R4).
158    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    /// Asks for the pass when none is held or it is due; `phase` names a
238    /// refusal as a link open's or as the boot's (SRV-041 R4).
239    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        // Serialize renewals so rights and the presented token change together.
254        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        // A first issue after a refused boot is not a renewal.
266        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    // The link authority wraps a session only to guard link opens; every prepared hold is the
316    // inner session's, or permissions and construction would fall to the trait's refusals.
317    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    /// The first pass issue, left for after the listener binds (SRV-041 R4).
356    pub issue: Option<StartIssue>,
357}
358/// Verifies the configured pass before creating any link host. With
359/// `after_bind`, a dialled pass that nothing built before the bind reads is
360/// not asked for here: [`PassInstall::issue`] asks once the listener binds.
361pub(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            // The host is built without a pass and the next link open asks
450            // again (ACCESS-009 R1); a machine with a peers block cannot
451            // serve without its pass, so its refusal stays a refusal.
452            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;