haematite-cli 0.21.8

The haematite command: serve a store, its door and Studio, answer MCP, query with jq, and host the store's assistant seat
Documentation
//! The `lys-agent-signature` proof: the agent's own ed25519 key signs the
//! exact request sent to Lys's agent pass route, in the frozen attestation
//! shape Lys verifies (the same bytes as Cambium's seat MCP sends).
//!
//! The key is read from its file only when a pass is asked for and never
//! enters a refusal, a diagnostic or a log; neither does the signature.

use std::io::Read;
use std::path::Path;

use base64::Engine;
use ed25519_dalek::{Signer, SigningKey};
use sha2::{Digest, Sha256};

use super::adapter::ServiceRefusal;

/// 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,
];
/// Random bytes in every request nonce.
const NONCE_BYTES: usize = 16;
/// The most bytes read from a key file.
const KEY_FILE_BYTES: u64 = 65_536;

/// The agent's signing key, read now from a PEM file or a seat file whose
/// `privateKeyPem` field holds the PEM.
pub(super) fn read_key(path: &Path) -> Result<SigningKey, ServiceRefusal> {
    let unreadable = |why: &str| {
        refusal(
            "mcp_lys_key_unreadable",
            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(KEY_FILE_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 `body`, signed at
/// `millis` since the epoch: `{agent} {millis} {nonce} {attestation hex}`.
pub(super) fn signature(
    key: &SigningKey,
    agent: &str,
    path: &str,
    body: &[u8],
    millis: u64,
) -> Result<String, ServiceRefusal> {
    let nonce = hex(&nonce()?);
    let payload = [
        REQUEST_DOMAIN,
        "POST",
        path,
        &hex(&Sha256::digest(body)),
        &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;
    match value {
        0..24 => out.push(base | u8::try_from(value).unwrap_or_default()),
        24..=0xff => {
            out.push(base | 0x18);
            out.push(u8::try_from(value).unwrap_or_default());
        }
        0x100..=0xffff => {
            out.push(base | 0x19);
            out.extend_from_slice(&u16::try_from(value).unwrap_or_default().to_be_bytes());
        }
        0x1_0000..=0xffff_ffff => {
            out.push(base | 0x1a);
            out.extend_from_slice(&u32::try_from(value).unwrap_or_default().to_be_bytes());
        }
        _ => {
            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 nonce() -> Result<[u8; NONCE_BYTES], ServiceRefusal> {
    let mut nonce = [0; NONCE_BYTES];
    std::fs::File::open("/dev/urandom")
        .and_then(|mut source| source.read_exact(&mut nonce))
        .map_err(|error| {
            refusal(
                "mcp_lys_nonce_unavailable",
                format!("no random nonce could be read: {}", error.kind()),
            )
        })?;
    Ok(nonce)
}

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()
}

fn refusal(code: &str, message: String) -> ServiceRefusal {
    ServiceRefusal {
        code: code.into(),
        message,
        receipt: None,
    }
}

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