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