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