Skip to main content

liminal_cli/
lys.rs

1//! The ticket's own Lys pass.
2//!
3//! With a `[lys]` table the ticket asks Lys's agent pass route for a `liminal`
4//! pass. A certified seat signs the exact request with its key in the
5//! attestation shape Lys verifies. A run uses `LYS_AGENT` and `LYS_AGENT_PASS`.
6//! Passes are held per audience: the signed route renews within
7//! [`RENEW_WITHIN_SECS`], and the run route at expiry. A station receives the
8//! seat's own pass for its store. The pass is checked when a call needs one;
9//! no timer runs, and no key, signature or pass enters an error or a log.
10
11use std::collections::BTreeMap;
12use std::io::Read as _;
13use std::path::{Path, PathBuf};
14use std::sync::Mutex;
15use std::time::{Duration, SystemTime, UNIX_EPOCH};
16
17use base64::Engine as _;
18use ed25519_dalek::{Signer as _, SigningKey};
19use serde::Deserialize;
20use sha2::{Digest as _, Sha256};
21
22/// A held pass expiring within this many seconds is renewed before use.
23pub const RENEW_WITHIN_SECS: u64 = 60;
24/// The audience of the ticket's own pass, the one its connections present.
25const AUDIENCE: &str = "liminal";
26/// The longest audience a call may name.
27const AUDIENCE_BYTES: usize = 64;
28/// The domain line every agent request payload opens with.
29const REQUEST_DOMAIN: &str = "lys-identity/agent-request/v1";
30/// The attestation content type in the protected header.
31const CONTENT_TYPE: &str = "application/vnd.lys.attestation.v2+cbor";
32/// PKCS#8 v1 DER for an ed25519 private key, before its 32-byte seed.
33const PKCS8_ED25519_PREFIX: [u8; 16] = [
34    0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
35];
36/// The most bytes read from a key file or a Lys answer.
37const READ_BYTES: u64 = 65_536;
38
39/// Why the ticket has no pass, in words that never carry a secret.
40#[derive(Debug, thiserror::Error, PartialEq, Eq)]
41#[error("{0}")]
42pub struct PassError(pub String);
43
44/// The `[lys]` table: where the agent asks for its pass and its optional key.
45#[derive(Debug, Clone, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct LysTable {
48    /// Lys's origin: https, or http on 127.0.0.1 or localhost.
49    pub issuer: String,
50    /// The agent id the pass is asked for.
51    #[serde(default, deserialize_with = "run_pass::table_agent")]
52    pub agent: String,
53    /// The agent's key; absent uses the run's `LYS_AGENT` and `LYS_AGENT_PASS`.
54    #[serde(default, deserialize_with = "run_pass::key_file")]
55    pub key_file: PathBuf,
56}
57
58/// A pass and the instant it stops being valid.
59struct Held {
60    pass: String,
61    expires_at: u64,
62}
63
64/// Asks Lys's agent pass route for this agent's liminal pass.
65pub struct LysAgent {
66    client: ureq::Agent,
67    route: String,
68    path: String,
69    agent: String,
70    proof: run_pass::Proof,
71    held: Mutex<BTreeMap<String, Held>>,
72}
73
74impl std::fmt::Debug for LysAgent {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        f.debug_struct("LysAgent")
77            .field("route", &self.route)
78            .field("agent", &self.agent)
79            .finish_non_exhaustive()
80    }
81}
82
83/// Lys's answer to an agent pass request.
84#[derive(Deserialize)]
85struct Answer {
86    pass: String,
87    expires_at: u64,
88    audience: String,
89}
90
91impl LysAgent {
92    /// A pass source for `table`, every request bounded by `timeout`.
93    ///
94    /// # Errors
95    /// Refuses, by a fixed sentence, an issuer that is not https (or http on
96    /// 127.0.0.1 or localhost), an agent id that is not plain path text, and a
97    /// key path that is not absolute; without a key, names a missing run variable
98    /// or an agent that differs from `LYS_AGENT`.
99    pub fn new(table: &LysTable, timeout: Duration) -> Result<Self, &'static str> {
100        Self::new_with_env(table, timeout, run_pass::environment)
101    }
102
103    fn configured(
104        table: &LysTable,
105        timeout: Duration,
106        agent: String,
107        proof: run_pass::Proof,
108    ) -> Result<Self, &'static str> {
109        let origin = table.issuer.trim_end_matches('/');
110        let local = ["http://127.0.0.1", "http://localhost"]
111            .iter()
112            .any(|base| origin == *base || origin.starts_with(&format!("{base}:")));
113        if !(local || origin.starts_with("https://")) || origin.contains(['?', '#']) {
114            return Err("lys.issuer must be https, or http on 127.0.0.1 or localhost");
115        }
116        if agent.is_empty()
117            || agent.len() > 128
118            || !agent
119                .bytes()
120                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
121        {
122            return Err("lys.agent must be a plain agent id");
123        }
124        if let run_pass::Proof::Signed(path) = &proof
125            && !path.is_absolute()
126        {
127            return Err("lys.key_file must be an absolute path");
128        }
129        let config = ureq::Agent::config_builder()
130            .proxy(None)
131            .http_status_as_error(false)
132            .max_redirects(0)
133            .timeout_global(Some(timeout))
134            .build();
135        Ok(Self {
136            client: ureq::Agent::new_with_config(config),
137            route: format!("{origin}/api/agents/{agent}/pass"),
138            path: format!("/api/agents/{agent}/pass"),
139            agent,
140            proof,
141            held: Mutex::new(BTreeMap::new()),
142        })
143    }
144
145    /// The ticket's own liminal pass: [`Self::pass_for`] the `liminal` audience.
146    ///
147    /// # Errors
148    /// As [`Self::pass_for`].
149    pub fn pass(&self) -> Result<String, PassError> {
150        self.pass_for(AUDIENCE)
151    }
152
153    /// This agent's pass for `audience`: the held one while it is good for
154    /// more than [`RENEW_WITHIN_SECS`] on the signed route, or until expiry on
155    /// the run-pass route; otherwise a new one from Lys.
156    ///
157    /// # Errors
158    /// Names why no pass could be had: an audience that is not plain text,
159    /// the key, Lys unreachable, or Lys's refusal by its own name.
160    pub fn pass_for(&self, audience: &str) -> Result<String, PassError> {
161        self.pass_for_at(audience, now_secs(), |route, request| {
162            self.send(route, request)
163        })
164    }
165
166    fn lock(&self) -> Result<std::sync::MutexGuard<'_, BTreeMap<String, Held>>, PassError> {
167        self.held
168            .lock()
169            .map_err(|_| PassError("the held pass's lock is poisoned".to_owned()))
170    }
171
172    fn send(
173        &self,
174        route: &str,
175        request: &run_pass::PassRequest,
176    ) -> Result<(u16, Vec<u8>), PassError> {
177        let mut response = self
178            .client
179            .post(route)
180            .header(request.header, request.proof_header()?)
181            .header("content-type", "application/json")
182            .header("accept", "application/json")
183            .send(&request.body[..])
184            .map_err(|error| {
185                PassError(format!(
186                    "Lys could not be asked at {route}: {}",
187                    run_pass::transport_reason(&error)
188                ))
189            })?;
190        let status = response.status().as_u16();
191        let body = response
192            .body_mut()
193            .with_config()
194            .limit(READ_BYTES)
195            .read_to_vec()
196            .map_err(|error| PassError(format!("Lys's answer could not be read: {error}")))?;
197        Ok((status, body))
198    }
199
200    fn read_answer(status: u16, body: &[u8], now: u64, audience: &str) -> Result<Held, PassError> {
201        if status != 200 {
202            let name = serde_json::from_slice::<serde_json::Value>(body)
203                .ok()
204                .and_then(|answer| {
205                    answer
206                        .get("refusal")
207                        .and_then(|n| n.as_str().map(str::to_owned))
208                })
209                .filter(|name| {
210                    name.len() <= 96 && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
211                })
212                .unwrap_or_else(|| "no refusal name".to_owned());
213            return Err(PassError(format!(
214                "Lys refused the agent pass: {name} (HTTP {status})"
215            )));
216        }
217        let answer: Answer = serde_json::from_slice(body)
218            .map_err(|_| PassError("Lys answered HTTP 200 without a pass".to_owned()))?;
219        if answer.audience != audience || answer.pass.trim().is_empty() || answer.expires_at <= now
220        {
221            return Err(PassError(
222                "Lys answered a pass for another audience, empty, or already expired".to_owned(),
223            ));
224        }
225        Ok(Held {
226            pass: answer.pass.trim().to_owned(),
227            expires_at: answer.expires_at,
228        })
229    }
230}
231
232fn now_secs() -> u64 {
233    SystemTime::now()
234        .duration_since(UNIX_EPOCH)
235        .map_or(0, |since| since.as_secs())
236}
237
238/// The agent's signing key, read now from a PEM file or a seat file whose
239/// `privateKeyPem` field holds the PEM.
240fn read_key(path: &Path) -> Result<SigningKey, PassError> {
241    let unreadable = |why: &str| PassError(format!("the agent key file could not be read: {why}"));
242    let mut text = String::new();
243    std::fs::File::open(path)
244        .and_then(|file| file.take(READ_BYTES).read_to_string(&mut text))
245        .map_err(|error| unreadable(&error.kind().to_string()))?;
246    let pem = if text.trim_start().starts_with('{') {
247        let seat: serde_json::Value =
248            serde_json::from_str(&text).map_err(|_| unreadable("the seat file is not JSON"))?;
249        seat.get("privateKeyPem")
250            .and_then(serde_json::Value::as_str)
251            .ok_or_else(|| unreadable("the seat file has no privateKeyPem"))?
252            .to_owned()
253    } else {
254        text
255    };
256    let body: String = pem
257        .lines()
258        .map(str::trim)
259        .filter(|line| !line.is_empty() && !line.starts_with("-----"))
260        .collect();
261    let der = base64::engine::general_purpose::STANDARD
262        .decode(body)
263        .map_err(|_| unreadable("the key is not PEM"))?;
264    let seed = der
265        .strip_prefix(&PKCS8_ED25519_PREFIX)
266        .and_then(|seed| <[u8; 32]>::try_from(seed).ok())
267        .ok_or_else(|| unreadable("the key is not a PKCS#8 ed25519 private key"))?;
268    Ok(SigningKey::from_bytes(&seed))
269}
270
271/// The `lys-agent-signature` header for `POST path` with `request`, the exact
272/// body sent, signed at `millis`: `{agent} {millis} {nonce} {attestation hex}`.
273fn signature(
274    key: &SigningKey,
275    agent: &str,
276    path: &str,
277    request: &[u8],
278    millis: u64,
279) -> Result<String, PassError> {
280    let mut nonce = [0_u8; 16];
281    std::fs::File::open("/dev/urandom")
282        .and_then(|mut source| source.read_exact(&mut nonce))
283        .map_err(|error| PassError(format!("no random nonce could be read: {}", error.kind())))?;
284    let nonce = hex(&nonce);
285    let payload = [
286        REQUEST_DOMAIN,
287        "POST",
288        path,
289        &hex(&Sha256::digest(request)),
290        &millis.to_string(),
291        &nonce,
292    ]
293    .join("\n");
294    let attestation = attest(key, payload.as_bytes(), millis);
295    Ok(format!("{agent} {millis} {nonce} {}", hex(&attestation)))
296}
297
298/// The `COSE_Sign1` attestation over `payload`, in the fixed map and tag
299/// shape Lys reads, never a serializer's default encoding.
300fn attest(key: &SigningKey, payload: &[u8], millis: u64) -> Vec<u8> {
301    let mut protected = vec![0xa3, 0x01, 0x27, 0x03];
302    text(&mut protected, CONTENT_TYPE);
303    protected.push(0x04);
304    bytes(&mut protected, key.verifying_key().as_bytes());
305    let mut claims = vec![0xa2, 0x01];
306    bytes(&mut claims, &Sha256::digest(payload));
307    claims.push(0x02);
308    head(&mut claims, 0, millis);
309    let mut signed = vec![0x84];
310    text(&mut signed, "Signature1");
311    bytes(&mut signed, &protected);
312    bytes(&mut signed, &[]);
313    bytes(&mut signed, &claims);
314    let signature = key.sign(&signed);
315    let mut out = vec![0xd2, 0x84];
316    bytes(&mut out, &protected);
317    out.push(0xa0);
318    bytes(&mut out, &claims);
319    bytes(&mut out, &signature.to_bytes());
320    out
321}
322
323/// A CBOR head for `major` carrying `value` in its shortest form.
324fn head(out: &mut Vec<u8>, major: u8, value: u64) {
325    let base = major << 5;
326    if let Ok(small) = u8::try_from(value) {
327        if small < 24 {
328            out.push(base | small);
329        } else {
330            out.extend_from_slice(&[base | 0x18, small]);
331        }
332    } else if let Ok(short) = u16::try_from(value) {
333        out.push(base | 0x19);
334        out.extend_from_slice(&short.to_be_bytes());
335    } else if let Ok(word) = u32::try_from(value) {
336        out.push(base | 0x1a);
337        out.extend_from_slice(&word.to_be_bytes());
338    } else {
339        out.push(base | 0x1b);
340        out.extend_from_slice(&value.to_be_bytes());
341    }
342}
343
344fn bytes(out: &mut Vec<u8>, value: &[u8]) {
345    head(out, 2, value.len() as u64);
346    out.extend_from_slice(value);
347}
348
349fn text(out: &mut Vec<u8>, value: &str) {
350    head(out, 3, value.len() as u64);
351    out.extend_from_slice(value.as_bytes());
352}
353
354fn hex(bytes: &[u8]) -> String {
355    const DIGITS: &[u8; 16] = b"0123456789abcdef";
356    bytes
357        .iter()
358        .flat_map(|byte| {
359            [
360                DIGITS[usize::from(byte >> 4)],
361                DIGITS[usize::from(byte & 15)],
362            ]
363        })
364        .map(char::from)
365        .collect()
366}
367
368#[cfg(test)]
369#[path = "lys_tests.rs"]
370mod tests;
371
372#[path = "lys_run_pass.rs"]
373mod run_pass;