liminal-cli 0.2.0

The liminal command: serve the network, maintain its stores, or serve its discovery MCP over stdio
Documentation
//! The ticket's own Lys pass.
//!
//! With a `[lys]` table the ticket asks Lys's agent pass route for a `liminal`
//! pass. A certified seat signs the exact request with its key in the
//! attestation shape Lys verifies. A run uses `LYS_AGENT` and `LYS_AGENT_PASS`.
//! Passes are held per audience: the signed route renews within
//! [`RENEW_WITHIN_SECS`], and the run route at expiry. A station receives the
//! seat's own pass for its store. The pass is checked when a call needs one;
//! no timer runs, and no key, signature or pass enters an error or a log.

use std::collections::BTreeMap;
use std::io::Read as _;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use base64::Engine as _;
use ed25519_dalek::{Signer as _, SigningKey};
use serde::Deserialize;
use sha2::{Digest as _, Sha256};

/// A held pass expiring within this many seconds is renewed before use.
pub const RENEW_WITHIN_SECS: u64 = 60;
/// The audience of the ticket's own pass, the one its connections present.
const AUDIENCE: &str = "liminal";
/// The longest audience a call may name.
const AUDIENCE_BYTES: usize = 64;
/// The domain line every agent request payload opens with.
const REQUEST_DOMAIN: &str = "lys-identity/agent-request/v1";
/// The attestation content type in the protected header.
const CONTENT_TYPE: &str = "application/vnd.lys.attestation.v2+cbor";
/// PKCS#8 v1 DER for an ed25519 private key, before its 32-byte seed.
const PKCS8_ED25519_PREFIX: [u8; 16] = [
    0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
];
/// The most bytes read from a key file or a Lys answer.
const READ_BYTES: u64 = 65_536;

/// Why the ticket has no pass, in words that never carry a secret.
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
#[error("{0}")]
pub struct PassError(pub String);

/// The `[lys]` table: where the agent asks for its pass and its optional key.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LysTable {
    /// Lys's origin: https, or http on 127.0.0.1 or localhost.
    pub issuer: String,
    /// The agent id the pass is asked for.
    #[serde(default, deserialize_with = "run_pass::table_agent")]
    pub agent: String,
    /// The agent's key; absent uses the run's `LYS_AGENT` and `LYS_AGENT_PASS`.
    #[serde(default, deserialize_with = "run_pass::key_file")]
    pub key_file: PathBuf,
}

/// A pass and the instant it stops being valid.
struct Held {
    pass: String,
    expires_at: u64,
}

/// Asks Lys's agent pass route for this agent's liminal pass.
pub struct LysAgent {
    client: ureq::Agent,
    route: String,
    path: String,
    agent: String,
    proof: run_pass::Proof,
    held: Mutex<BTreeMap<String, Held>>,
}

impl std::fmt::Debug for LysAgent {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("LysAgent")
            .field("route", &self.route)
            .field("agent", &self.agent)
            .finish_non_exhaustive()
    }
}

/// Lys's answer to an agent pass request.
#[derive(Deserialize)]
struct Answer {
    pass: String,
    expires_at: u64,
    audience: String,
}

impl LysAgent {
    /// A pass source for `table`, every request bounded by `timeout`.
    ///
    /// # Errors
    /// Refuses, by a fixed sentence, an issuer that is not https (or http on
    /// 127.0.0.1 or localhost), an agent id that is not plain path text, and a
    /// key path that is not absolute; without a key, names a missing run variable
    /// or an agent that differs from `LYS_AGENT`.
    pub fn new(table: &LysTable, timeout: Duration) -> Result<Self, &'static str> {
        Self::new_with_env(table, timeout, run_pass::environment)
    }

    fn configured(
        table: &LysTable,
        timeout: Duration,
        agent: String,
        proof: run_pass::Proof,
    ) -> Result<Self, &'static str> {
        let origin = table.issuer.trim_end_matches('/');
        let local = ["http://127.0.0.1", "http://localhost"]
            .iter()
            .any(|base| origin == *base || origin.starts_with(&format!("{base}:")));
        if !(local || origin.starts_with("https://")) || origin.contains(['?', '#']) {
            return Err("lys.issuer must be https, or http on 127.0.0.1 or localhost");
        }
        if agent.is_empty()
            || agent.len() > 128
            || !agent
                .bytes()
                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
        {
            return Err("lys.agent must be a plain agent id");
        }
        if let run_pass::Proof::Signed(path) = &proof
            && !path.is_absolute()
        {
            return Err("lys.key_file must be an absolute path");
        }
        let config = ureq::Agent::config_builder()
            .proxy(None)
            .http_status_as_error(false)
            .max_redirects(0)
            .timeout_global(Some(timeout))
            .build();
        Ok(Self {
            client: ureq::Agent::new_with_config(config),
            route: format!("{origin}/api/agents/{agent}/pass"),
            path: format!("/api/agents/{agent}/pass"),
            agent,
            proof,
            held: Mutex::new(BTreeMap::new()),
        })
    }

    /// The ticket's own liminal pass: [`Self::pass_for`] the `liminal` audience.
    ///
    /// # Errors
    /// As [`Self::pass_for`].
    pub fn pass(&self) -> Result<String, PassError> {
        self.pass_for(AUDIENCE)
    }

    /// This agent's pass for `audience`: the held one while it is good for
    /// more than [`RENEW_WITHIN_SECS`] on the signed route, or until expiry on
    /// the run-pass route; otherwise a new one from Lys.
    ///
    /// # Errors
    /// Names why no pass could be had: an audience that is not plain text,
    /// the key, Lys unreachable, or Lys's refusal by its own name.
    pub fn pass_for(&self, audience: &str) -> Result<String, PassError> {
        self.pass_for_at(audience, now_secs(), |route, request| {
            self.send(route, request)
        })
    }

    fn lock(&self) -> Result<std::sync::MutexGuard<'_, BTreeMap<String, Held>>, PassError> {
        self.held
            .lock()
            .map_err(|_| PassError("the held pass's lock is poisoned".to_owned()))
    }

    fn send(
        &self,
        route: &str,
        request: &run_pass::PassRequest,
    ) -> Result<(u16, Vec<u8>), PassError> {
        let mut response = self
            .client
            .post(route)
            .header(request.header, request.proof_header()?)
            .header("content-type", "application/json")
            .header("accept", "application/json")
            .send(&request.body[..])
            .map_err(|error| {
                PassError(format!(
                    "Lys could not be asked at {route}: {}",
                    run_pass::transport_reason(&error)
                ))
            })?;
        let status = response.status().as_u16();
        let body = response
            .body_mut()
            .with_config()
            .limit(READ_BYTES)
            .read_to_vec()
            .map_err(|error| PassError(format!("Lys's answer could not be read: {error}")))?;
        Ok((status, body))
    }

    fn read_answer(status: u16, body: &[u8], now: u64, audience: &str) -> Result<Held, PassError> {
        if status != 200 {
            let name = serde_json::from_slice::<serde_json::Value>(body)
                .ok()
                .and_then(|answer| {
                    answer
                        .get("refusal")
                        .and_then(|n| n.as_str().map(str::to_owned))
                })
                .filter(|name| {
                    name.len() <= 96 && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
                })
                .unwrap_or_else(|| "no refusal name".to_owned());
            return Err(PassError(format!(
                "Lys refused the agent pass: {name} (HTTP {status})"
            )));
        }
        let answer: Answer = serde_json::from_slice(body)
            .map_err(|_| PassError("Lys answered HTTP 200 without a pass".to_owned()))?;
        if answer.audience != audience || answer.pass.trim().is_empty() || answer.expires_at <= now
        {
            return Err(PassError(
                "Lys answered a pass for another audience, empty, or already expired".to_owned(),
            ));
        }
        Ok(Held {
            pass: answer.pass.trim().to_owned(),
            expires_at: answer.expires_at,
        })
    }
}

fn now_secs() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_or(0, |since| since.as_secs())
}

/// The agent's signing key, read now from a PEM file or a seat file whose
/// `privateKeyPem` field holds the PEM.
fn read_key(path: &Path) -> Result<SigningKey, PassError> {
    let unreadable = |why: &str| PassError(format!("the agent key file could not be read: {why}"));
    let mut text = String::new();
    std::fs::File::open(path)
        .and_then(|file| file.take(READ_BYTES).read_to_string(&mut text))
        .map_err(|error| unreadable(&error.kind().to_string()))?;
    let pem = if text.trim_start().starts_with('{') {
        let seat: serde_json::Value =
            serde_json::from_str(&text).map_err(|_| unreadable("the seat file is not JSON"))?;
        seat.get("privateKeyPem")
            .and_then(serde_json::Value::as_str)
            .ok_or_else(|| unreadable("the seat file has no privateKeyPem"))?
            .to_owned()
    } else {
        text
    };
    let body: String = pem
        .lines()
        .map(str::trim)
        .filter(|line| !line.is_empty() && !line.starts_with("-----"))
        .collect();
    let der = base64::engine::general_purpose::STANDARD
        .decode(body)
        .map_err(|_| unreadable("the key is not PEM"))?;
    let seed = der
        .strip_prefix(&PKCS8_ED25519_PREFIX)
        .and_then(|seed| <[u8; 32]>::try_from(seed).ok())
        .ok_or_else(|| unreadable("the key is not a PKCS#8 ed25519 private key"))?;
    Ok(SigningKey::from_bytes(&seed))
}

/// The `lys-agent-signature` header for `POST path` with `request`, the exact
/// body sent, signed at `millis`: `{agent} {millis} {nonce} {attestation hex}`.
fn signature(
    key: &SigningKey,
    agent: &str,
    path: &str,
    request: &[u8],
    millis: u64,
) -> Result<String, PassError> {
    let mut nonce = [0_u8; 16];
    std::fs::File::open("/dev/urandom")
        .and_then(|mut source| source.read_exact(&mut nonce))
        .map_err(|error| PassError(format!("no random nonce could be read: {}", error.kind())))?;
    let nonce = hex(&nonce);
    let payload = [
        REQUEST_DOMAIN,
        "POST",
        path,
        &hex(&Sha256::digest(request)),
        &millis.to_string(),
        &nonce,
    ]
    .join("\n");
    let attestation = attest(key, payload.as_bytes(), millis);
    Ok(format!("{agent} {millis} {nonce} {}", hex(&attestation)))
}

/// The `COSE_Sign1` attestation over `payload`, in the fixed map and tag
/// shape Lys reads, never a serializer's default encoding.
fn attest(key: &SigningKey, payload: &[u8], millis: u64) -> Vec<u8> {
    let mut protected = vec![0xa3, 0x01, 0x27, 0x03];
    text(&mut protected, CONTENT_TYPE);
    protected.push(0x04);
    bytes(&mut protected, key.verifying_key().as_bytes());
    let mut claims = vec![0xa2, 0x01];
    bytes(&mut claims, &Sha256::digest(payload));
    claims.push(0x02);
    head(&mut claims, 0, millis);
    let mut signed = vec![0x84];
    text(&mut signed, "Signature1");
    bytes(&mut signed, &protected);
    bytes(&mut signed, &[]);
    bytes(&mut signed, &claims);
    let signature = key.sign(&signed);
    let mut out = vec![0xd2, 0x84];
    bytes(&mut out, &protected);
    out.push(0xa0);
    bytes(&mut out, &claims);
    bytes(&mut out, &signature.to_bytes());
    out
}

/// A CBOR head for `major` carrying `value` in its shortest form.
fn head(out: &mut Vec<u8>, major: u8, value: u64) {
    let base = major << 5;
    if let Ok(small) = u8::try_from(value) {
        if small < 24 {
            out.push(base | small);
        } else {
            out.extend_from_slice(&[base | 0x18, small]);
        }
    } else if let Ok(short) = u16::try_from(value) {
        out.push(base | 0x19);
        out.extend_from_slice(&short.to_be_bytes());
    } else if let Ok(word) = u32::try_from(value) {
        out.push(base | 0x1a);
        out.extend_from_slice(&word.to_be_bytes());
    } else {
        out.push(base | 0x1b);
        out.extend_from_slice(&value.to_be_bytes());
    }
}

fn bytes(out: &mut Vec<u8>, value: &[u8]) {
    head(out, 2, value.len() as u64);
    out.extend_from_slice(value);
}

fn text(out: &mut Vec<u8>, value: &str) {
    head(out, 3, value.len() as u64);
    out.extend_from_slice(value.as_bytes());
}

fn hex(bytes: &[u8]) -> String {
    const DIGITS: &[u8; 16] = b"0123456789abcdef";
    bytes
        .iter()
        .flat_map(|byte| {
            [
                DIGITS[usize::from(byte >> 4)],
                DIGITS[usize::from(byte & 15)],
            ]
        })
        .map(char::from)
        .collect()
}

#[cfg(test)]
#[path = "lys_tests.rs"]
mod tests;

#[path = "lys_run_pass.rs"]
mod run_pass;