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};
pub const RENEW_WITHIN_SECS: u64 = 60;
const AUDIENCE: &str = "liminal";
const AUDIENCE_BYTES: usize = 64;
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 READ_BYTES: u64 = 65_536;
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
#[error("{0}")]
pub struct PassError(pub String);
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LysTable {
pub issuer: String,
#[serde(default, deserialize_with = "run_pass::table_agent")]
pub agent: String,
#[serde(default, deserialize_with = "run_pass::key_file")]
pub key_file: PathBuf,
}
struct Held {
pass: String,
expires_at: u64,
}
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()
}
}
#[derive(Deserialize)]
struct Answer {
pass: String,
expires_at: u64,
audience: String,
}
impl LysAgent {
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()),
})
}
pub fn pass(&self) -> Result<String, PassError> {
self.pass_for(AUDIENCE)
}
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())
}
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))
}
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)))
}
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;
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;