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