use std::io::Read;
use std::path::Path;
use base64::Engine;
use ed25519_dalek::{Signer, SigningKey};
use sha2::{Digest, Sha256};
use super::adapter::ServiceRefusal;
const REQUEST_DOMAIN: &str = "lys-identity/agent-request/v1";
const CONTENT_TYPE: &str = "application/vnd.lys.attestation.v2+cbor";
const PKCS8_ED25519_PREFIX: [u8; 16] = [
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
];
const NONCE_BYTES: usize = 16;
const KEY_FILE_BYTES: u64 = 65_536;
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))
}
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)))
}
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
}
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;