1use crate::SyncError;
35use sioul_core::i18n::Translator;
36use sioul_core::pgp;
37#[cfg(target_os = "android")]
38use sioul_core::securitykey::NoReaders;
39use sioul_core::securitykey::{self, CardInfo, CertCheck, Certificate, Mismatch, Readers};
40use std::path::{Path, PathBuf};
41use std::process::Stdio;
42use std::time::Duration;
43
44#[cfg(not(target_os = "android"))]
45pub mod smartcard;
46
47pub fn readers() -> &'static dyn Readers {
50 #[cfg(not(target_os = "android"))]
51 {
52 static PCSC: smartcard::PcscReaders = smartcard::PcscReaders;
53 &PCSC
54 }
55 #[cfg(target_os = "android")]
56 {
57 static NONE: NoReaders = NoReaders;
58 &NONE
59 }
60}
61
62pub fn cap_logging() {
69 log::set_max_level(log::max_level().min(log::LevelFilter::Info));
70}
71
72pub enum Looked {
74 Found {
76 cert: Certificate,
78 check: CertCheck,
80 source: String,
82 },
83 Mismatch(Mismatch),
85 NotFound,
87}
88
89pub fn places(card: &CardInfo, addresses: &[String]) -> Vec<String> {
93 let mut urls = Vec::new();
94 let url = card.url.trim();
95 if url.get(..8).is_some_and(|s| s.eq_ignore_ascii_case("https://")) {
96 urls.push(url.to_string());
97 }
98 for address in addresses {
99 urls.extend(pgp::wkd_urls(address).into_iter().flatten());
100 }
101 if let Some(slot) = card.sign.as_ref().or(card.decrypt.as_ref()) {
102 urls.push(format!("https://keys.openpgp.org/vks/v1/by-fingerprint/{}", slot.fingerprint.to_ascii_uppercase()));
104 }
105 let mut seen = std::collections::BTreeSet::new();
106 urls.retain(|u| seen.insert(u.to_ascii_lowercase()));
107 urls
108}
109
110pub fn find_certificate(card: &CardInfo, addresses: &[String]) -> Result<Looked, SyncError> {
113 let agent: ureq::Agent = ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(15))).https_only(true).user_agent("Sioul").build().into();
115 find_at(&agent, &places(card, addresses), card)
116}
117
118fn find_at(agent: &ureq::Agent, urls: &[String], card: &CardInfo) -> Result<Looked, SyncError> {
119 let mut mismatch = None;
120 let mut last_error = None;
121 for url in urls {
122 match agent.get(url).call() {
123 Ok(mut response) => {
124 let bytes = response.body_mut().read_to_vec().unwrap_or_default();
125 match securitykey::certificate_in(&bytes, card) {
126 Ok((cert, check)) => return Ok(Looked::Found { cert, check, source: url.clone() }),
127 Err(Mismatch::Missing) => {}
129 Err(why) => mismatch = Some(why),
130 }
131 }
132 Err(ureq::Error::StatusCode(_)) => {}
133 Err(e) => last_error = Some(e.to_string()),
134 }
135 }
136 match (mismatch, last_error) {
137 (Some(why), _) => Ok(Looked::Mismatch(why)),
138 (None, Some(e)) => Err(SyncError::Network(e)),
139 (None, None) => Ok(Looked::NotFound),
140 }
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
145pub enum Released {
146 Done,
148 NoGnupg,
150 Sandboxed,
152 Failed(String),
154}
155
156impl Released {
157 pub fn sentence(&self, tr: &Translator) -> String {
159 match self {
160 Released::Done => tr.text("seckey-released", None),
161 Released::NoGnupg => tr.text("seckey-release-no-gnupg", None),
162 Released::Sandboxed => tr.text("seckey-release-sandboxed", None),
163 Released::Failed(detail) => {
164 let mut args = sioul_core::i18n::args();
165 args.set("detail", detail.replace('`', "'"));
167 tr.text("seckey-release-failed", Some(&args))
168 }
169 }
170 }
171}
172
173pub fn release_gnupg() -> Released {
176 if Path::new("/.flatpak-info").exists() {
177 return Released::Sandboxed;
178 }
179 for program in gpgconf_places() {
180 match release_with(&program) {
181 Released::NoGnupg => continue,
182 done => return done,
183 }
184 }
185 Released::NoGnupg
186}
187
188fn gpgconf_places() -> Vec<PathBuf> {
191 let mut places = vec![PathBuf::from(if cfg!(windows) { "gpgconf.exe" } else { "gpgconf" })];
192 if cfg!(target_os = "macos") {
193 places.extend(["/opt/homebrew/bin/gpgconf", "/usr/local/bin/gpgconf", "/usr/local/MacGPG2/bin/gpgconf"].map(PathBuf::from));
194 }
195 if cfg!(windows) {
196 places.extend([r"C:\Program Files (x86)\GnuPG\bin\gpgconf.exe", r"C:\Program Files\GnuPG\bin\gpgconf.exe"].map(PathBuf::from));
197 }
198 places
199}
200
201fn release_with(program: &Path) -> Released {
202 release_at(program, None)
203}
204
205fn release_at(program: &Path, home: Option<&Path>) -> Released {
207 let mut command = std::process::Command::new(program);
208 if let Some(home) = home {
209 command.arg("--homedir").arg(home);
210 }
211 command.args(["--kill", "scdaemon"]).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::piped());
212 #[cfg(windows)]
214 {
215 use std::os::windows::process::CommandExt;
216 command.creation_flags(0x0800_0000);
217 }
218 match command.output() {
219 Ok(out) if out.status.success() => Released::Done,
220 Ok(out) => Released::Failed(String::from_utf8_lossy(&out.stderr).lines().map(str::trim).rfind(|l| !l.is_empty()).unwrap_or("").to_string()),
221 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Released::NoGnupg,
222 Err(e) => Released::Failed(e.to_string()),
223 }
224}
225
226#[derive(Debug, Clone)]
229pub struct Gnupg {
230 program: PathBuf,
231 home: Option<PathBuf>,
232}
233
234#[derive(Debug, Clone, PartialEq, Eq)]
236pub enum GnupgProblem {
237 Missing,
239 Sandboxed,
241 NoSuchKey,
243 TouchMissed,
246 Failed(String),
248}
249
250const GNUPG_WAITS: Duration = Duration::from_secs(5 * 60);
253
254const GPG_ERR_TIMEOUT: u32 = 62;
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259pub enum GnupgStep {
260 Pin,
262 Card,
264}
265
266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
268pub enum Status {
269 Pinentry(u32),
271 Failed(u32),
274}
275
276pub fn status_of(line: &str) -> Option<Status> {
278 let mut words = line.trim().strip_prefix("[GNUPG:] ")?.split_whitespace();
279 match words.next()? {
280 "PINENTRY_LAUNCHED" => words.next()?.parse().ok().map(Status::Pinentry),
281 "FAILURE" | "ERROR" => words.last()?.parse::<u32>().ok().map(|code| Status::Failed(code & 0xFFFF)),
282 _ => None,
283 }
284}
285
286fn running(pid: u32) -> bool {
289 if cfg!(target_os = "linux") { Path::new(&format!("/proc/{pid}")).exists() } else { true }
290}
291
292static GNUPG_ON_CARD: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
296
297pub struct OnCard;
299
300impl OnCard {
301 pub fn start() -> OnCard {
303 GNUPG_ON_CARD.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
304 OnCard
305 }
306}
307
308impl Drop for OnCard {
309 fn drop(&mut self) {
310 GNUPG_ON_CARD.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
311 }
312}
313
314pub fn may_look() -> bool {
316 GNUPG_ON_CARD.load(std::sync::atomic::Ordering::SeqCst) == 0
317}
318
319impl Gnupg {
320 pub fn find() -> Result<Gnupg, GnupgProblem> {
323 if cfg!(target_os = "android") {
324 return Err(GnupgProblem::Missing);
325 }
326 if Path::new("/.flatpak-info").exists() {
327 return Err(GnupgProblem::Sandboxed);
328 }
329 let name = if cfg!(windows) { "gpg.exe" } else { "gpg" };
330 let on_path = std::env::var_os("PATH").into_iter().flat_map(|path| std::env::split_paths(&path).collect::<Vec<_>>()).map(|dir| dir.join(name));
331 let known: Vec<PathBuf> = if cfg!(target_os = "macos") {
332 ["/opt/homebrew/bin/gpg", "/usr/local/bin/gpg", "/usr/local/MacGPG2/bin/gpg"].map(PathBuf::from).to_vec()
333 } else if cfg!(windows) {
334 [r"C:\Program Files (x86)\GnuPG\bin\gpg.exe", r"C:\Program Files\GnuPG\bin\gpg.exe"].map(PathBuf::from).to_vec()
335 } else {
336 vec![PathBuf::from("/usr/bin/gpg")]
337 };
338 on_path.chain(known).find(|p| p.is_file()).map(|program| Gnupg { program, home: None }).ok_or(GnupgProblem::Missing)
339 }
340
341 pub fn with_home(program: &Path, home: &Path) -> Gnupg {
343 Gnupg { program: program.to_path_buf(), home: Some(home.to_path_buf()) }
344 }
345
346 fn run(&self, args: &[&str]) -> Result<Vec<u8>, GnupgProblem> {
349 self.run_watching(args, None)
350 }
351
352 fn run_watching(&self, args: &[&str], watch: Option<&(dyn Fn(GnupgStep) + Sync)>) -> Result<Vec<u8>, GnupgProblem> {
356 let mut command = std::process::Command::new(&self.program);
357 if let Some(home) = &self.home {
358 command.arg("--homedir").arg(home);
359 }
360 command.args(["--batch", "--no-tty"]);
361 if watch.is_some() {
362 command.args(["--status-fd", "2"]);
363 }
364 command.args(args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
365 #[cfg(windows)]
367 {
368 use std::os::windows::process::CommandExt;
369 command.creation_flags(0x0800_0000);
370 }
371 let mut child = command.spawn().map_err(|e| if e.kind() == std::io::ErrorKind::NotFound { GnupgProblem::Missing } else { GnupgProblem::Failed(e.to_string()) })?;
372 let mut out = child.stdout.take();
374 let mut err = child.stderr.take();
375 let reading = std::thread::spawn(move || {
376 use std::io::Read;
377 let mut bytes = Vec::new();
378 if let Some(out) = out.as_mut() {
379 let _ = out.read_to_end(&mut bytes);
380 }
381 bytes
382 });
383 let (tell, heard) = std::sync::mpsc::channel::<Status>();
385 let said = std::thread::spawn(move || {
386 use std::io::BufRead;
387 let mut text = String::new();
388 if let Some(err) = err.take() {
389 for line in std::io::BufReader::new(err).lines().map_while(Result::ok) {
390 match status_of(&line) {
391 Some(status) => {
392 let _ = tell.send(status);
393 }
394 None if !line.starts_with("[GNUPG:]") => {
395 text.push_str(&line);
396 text.push('\n');
397 }
398 None => {}
399 }
400 }
401 }
402 text
403 });
404 let started = std::time::Instant::now();
405 let mut shown: Option<GnupgStep> = None;
406 let mut pinentry: Option<u32> = None;
407 let mut failed: Option<u32> = None;
408 let status = loop {
409 if let Some(watch) = watch {
410 for status in heard.try_iter() {
411 match status {
412 Status::Pinentry(pid) => pinentry = Some(pid),
413 Status::Failed(code) => failed = Some(code),
414 }
415 }
416 if pinentry.is_some_and(|pid| !running(pid)) {
417 pinentry = None;
418 }
419 let step = if pinentry.is_some() { GnupgStep::Pin } else { GnupgStep::Card };
420 if shown != Some(step) {
421 shown = Some(step);
422 watch(step);
423 }
424 }
425 match child.try_wait() {
426 Ok(Some(status)) => break status,
427 Ok(None) if started.elapsed() > GNUPG_WAITS => {
428 let _ = child.kill();
429 let _ = child.wait();
430 return Err(GnupgProblem::Failed("gpg did not answer".into()));
431 }
432 Ok(None) => std::thread::sleep(Duration::from_millis(100)),
433 Err(e) => return Err(GnupgProblem::Failed(e.to_string())),
434 }
435 };
436 let output = reading.join().unwrap_or_default();
437 let errors = said.join().unwrap_or_default();
438 failed = heard.try_iter().fold(failed, |failed, status| if let Status::Failed(code) = status { Some(code) } else { failed });
439 if status.success() {
440 Ok(output)
441 } else if failed == Some(GPG_ERR_TIMEOUT) {
442 Err(GnupgProblem::TouchMissed)
443 } else {
444 Err(GnupgProblem::Failed(errors.lines().map(str::trim).rfind(|l| !l.is_empty()).unwrap_or("").to_string()))
445 }
446 }
447
448 pub fn export(&self, fingerprint: &str) -> Result<Vec<u8>, GnupgProblem> {
451 let fingerprint = fingerprint.trim();
452 if fingerprint.is_empty() || !fingerprint.chars().all(|c| c.is_ascii_hexdigit()) {
453 return Err(GnupgProblem::NoSuchKey);
454 }
455 let out = self.run(&["--armor", "--export", fingerprint])?;
456 if out.iter().all(u8::is_ascii_whitespace) { Err(GnupgProblem::NoSuchKey) } else { Ok(out) }
457 }
458
459 pub fn holds_secret(&self, fingerprint: &str) -> bool {
462 let fingerprint = fingerprint.trim();
463 !fingerprint.is_empty()
464 && fingerprint.chars().all(|c| c.is_ascii_hexdigit())
465 && self.run(&["--with-colons", "--list-secret-keys", fingerprint]).is_ok_and(|out| String::from_utf8_lossy(&out).lines().any(|l| l.starts_with("sec:")))
466 }
467
468 pub fn renew(&self, fingerprint: &str, period: &str, watch: &(dyn Fn(GnupgStep) + Sync)) -> Result<(), GnupgProblem> {
478 if !self.holds_secret(fingerprint) {
479 return Err(GnupgProblem::NoSuchKey);
480 }
481 let fingerprint = fingerprint.trim();
482 let subkeys = self.subkeys(fingerprint);
483 let renewed = {
484 let _on_card = OnCard::start();
485 self.run_watching(&["--quick-set-expire", fingerprint, period], Some(watch)).and_then(|_| {
486 if subkeys.is_empty() {
487 return Ok(Vec::new());
488 }
489 let mut args = vec!["--quick-set-expire", fingerprint, period];
490 args.extend(subkeys.iter().map(String::as_str));
491 self.run_watching(&args, Some(watch))
492 })
493 };
494 let _ = self.release();
496 renewed.map(|_| ())
497 }
498
499 pub fn subkeys(&self, fingerprint: &str) -> Vec<String> {
502 let Ok(listed) = self.run(&["--with-colons", "--list-keys", fingerprint.trim()]) else { return Vec::new() };
503 let mut out = Vec::new();
504 let mut next_is_sub = false;
505 for line in String::from_utf8_lossy(&listed).lines() {
506 let fields: Vec<&str> = line.split(':').collect();
507 match fields.first().copied() {
508 Some("sub") => next_is_sub = fields.get(1).is_some_and(|v| *v != "r"),
510 Some("fpr") if next_is_sub => {
511 out.extend(fields.get(9).filter(|f| !f.is_empty()).map(|f| f.to_string()));
512 next_is_sub = false;
513 }
514 Some("fpr") => {}
515 _ => next_is_sub = false,
516 }
517 }
518 out
519 }
520
521 pub fn release(&self) -> Released {
526 let gpgconf = self.program.with_file_name(if cfg!(windows) { "gpgconf.exe" } else { "gpgconf" });
527 let program = if gpgconf.is_file() { gpgconf } else { PathBuf::from(if cfg!(windows) { "gpgconf.exe" } else { "gpgconf" }) };
528 release_at(&program, self.home.as_deref())
529 }
530}
531
532pub fn renew_commands(fingerprint: &str, subkeys: &[String]) -> Vec<String> {
535 let mut out = vec![format!("gpg --quick-set-expire {fingerprint} 2y")];
536 if !subkeys.is_empty() {
537 out.push(format!("gpg --quick-set-expire {fingerprint} 2y {}", subkeys.join(" ")));
538 }
539 out
540}
541
542pub fn export_command(fingerprint: &str) -> String {
544 format!("gpg --export --armor {fingerprint} > key.asc")
545}
546
547#[derive(Debug, Clone, Default, PartialEq, Eq)]
549pub struct Published {
550 pub fingerprint: String,
552 pub mailed: Vec<String>,
554 pub published: Vec<String>,
556}
557
558pub const KEYS_OPENPGP: &str = "https://keys.openpgp.org";
560
561pub fn send_to_keys_openpgp(armored: &[u8], locale: &str) -> Result<Published, SyncError> {
567 let agent: ureq::Agent = ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(30))).https_only(true).http_status_as_error(false).user_agent("Sioul").build().into();
568 send_at(&agent, KEYS_OPENPGP, armored, locale)
569}
570
571fn send_at(agent: &ureq::Agent, base: &str, armored: &[u8], locale: &str) -> Result<Published, SyncError> {
572 let call = |path: &str, body: serde_json::Value| -> Result<serde_json::Value, SyncError> {
573 let mut response = agent.post(&format!("{base}{path}")).header("Content-Type", "application/json").send(body.to_string()).map_err(|e| SyncError::Network(e.to_string()))?;
574 let status = response.status().as_u16();
575 let answer: serde_json::Value = serde_json::from_str(&response.body_mut().read_to_string().unwrap_or_default()).unwrap_or_default();
576 if status == 200 {
577 Ok(answer)
578 } else {
579 Err(SyncError::Server(format!("keys.openpgp.org: {status} {}", answer["error"].as_str().unwrap_or("")).trim().to_string()))
580 }
581 };
582 let uploaded = call("/vks/v1/upload", serde_json::json!({ "keytext": String::from_utf8_lossy(armored) }))?;
583 let addresses = |answer: &serde_json::Value, states: &[&str]| -> Vec<String> {
584 answer["status"].as_object().map(|status| status.iter().filter(|(_, state)| states.contains(&state.as_str().unwrap_or(""))).map(|(address, _)| address.clone()).collect()).unwrap_or_default()
585 };
586 let mut published = Published { fingerprint: uploaded["key_fpr"].as_str().unwrap_or("").to_string(), mailed: Vec::new(), published: addresses(&uploaded, &["published"]) };
587 let waiting = addresses(&uploaded, &["unpublished", "pending"]);
588 let token = uploaded["token"].as_str().unwrap_or("");
589 if !waiting.is_empty() && !token.is_empty() {
590 let locale: Vec<&str> = Some(locale).filter(|l| !l.is_empty()).into_iter().collect();
591 let asked = call("/vks/v1/request-verify", serde_json::json!({ "token": token, "addresses": waiting, "locale": locale }))?;
592 published.mailed = addresses(&asked, &["pending"]);
593 published.published = addresses(&asked, &["published"]);
594 }
595 Ok(published)
596}
597
598#[cfg(test)]
599mod tests {
600 use super::*;
601 use sioul_core::securitykey::SoftCard;
602 use std::io::{BufRead, BufReader, Write};
603 use std::net::TcpListener;
604
605 fn stand_in(answers: Vec<Vec<u8>>) -> (String, std::sync::mpsc::Receiver<String>) {
608 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
609 let address = format!("http://{}", listener.local_addr().unwrap());
610 let (tell, heard) = std::sync::mpsc::channel();
611 std::thread::spawn(move || {
612 for answer in answers {
613 let Ok((stream, _)) = listener.accept() else { return };
614 let mut reader = BufReader::new(stream.try_clone().unwrap());
615 let mut line = String::new();
616 reader.read_line(&mut line).unwrap();
617 loop {
618 let mut header = String::new();
619 reader.read_line(&mut header).unwrap();
620 if header.trim_end().is_empty() {
621 break;
622 }
623 }
624 let _ = tell.send(line.split_whitespace().nth(1).unwrap_or("").to_string());
625 let mut stream = stream;
626 let _ = stream.write_all(&answer);
627 }
628 });
629 (address, heard)
630 }
631
632 fn ok(body: &[u8]) -> Vec<u8> {
633 let mut answer = format!("HTTP/1.1 200 OK\r\nContent-Type: application/pgp-keys\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()).into_bytes();
634 answer.extend_from_slice(body);
635 answer
636 }
637
638 const NOT_FOUND: &[u8] = b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
639
640 fn agent() -> ureq::Agent {
641 ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(5))).build().into()
642 }
643
644 #[test]
645 fn the_places_come_in_order_and_https_only() {
646 let (mut card, _) = SoftCard::generate("0006:12345678", &["Card Holder <me@example.org>"], "123456").unwrap();
647 card.info.url = "https://keys.example.org/me.asc".into();
648 let urls = places(&card.info, &["me@example.org".to_string()]);
649 assert_eq!(urls[0], "https://keys.example.org/me.asc");
650 assert!(urls[1].starts_with("https://openpgpkey.example.org/.well-known/openpgpkey/example.org/hu/"), "{urls:?}");
651 assert!(urls[2].starts_with("https://example.org/.well-known/openpgpkey/hu/"), "{urls:?}");
652 let fingerprint = card.info.sign.as_ref().unwrap().fingerprint.clone();
653 assert_eq!(urls[3], format!("https://keys.openpgp.org/vks/v1/by-fingerprint/{fingerprint}"));
654 assert_eq!(fingerprint, fingerprint.to_ascii_uppercase());
655 card.info.url = "http://keys.example.org/me.asc".into();
657 assert!(!places(&card.info, &[]).iter().any(|u| u.starts_with("http:")));
658 assert_eq!(places(&card.info, &[]).len(), 1);
659 }
660
661 #[test]
662 fn the_first_certificate_that_is_the_keys_is_kept() {
663 let (card, public) = SoftCard::generate("0006:12345678", &["Card Holder <me@example.org>"], "123456").unwrap();
664 let (_, stranger) = SoftCard::generate("0006:87654321", &["Someone Else <someone@example.net>"], "123456").unwrap();
665 let (base, heard) = stand_in(vec![NOT_FOUND.to_vec(), ok(&stranger), ok(&public)]);
666 let urls = vec![format!("{base}/card"), format!("{base}/wkd"), format!("{base}/vks")];
667 match find_at(&agent(), &urls, &card.info).unwrap() {
668 Looked::Found { check, source, .. } => {
669 assert_eq!(source, format!("{base}/vks"));
670 assert_eq!(check.addresses, vec!["me@example.org".to_string()]);
671 assert!(check.signs && check.decrypts && !check.expired);
672 }
673 _ => panic!("not found"),
674 }
675 let asked: Vec<String> = heard.try_iter().collect();
676 assert_eq!(asked, vec!["/card", "/wkd", "/vks"]);
677 let (base, _heard) = stand_in(vec![NOT_FOUND.to_vec(), ok(&stranger)]);
679 assert!(matches!(find_at(&agent(), &[format!("{base}/a"), format!("{base}/b")], &card.info).unwrap(), Looked::NotFound));
680 }
681
682 #[test]
683 fn no_network_is_said_as_such() {
684 let (card, _) = SoftCard::generate("0006:12345678", &[], "123456").unwrap();
685 let closed = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap();
687 assert!(matches!(find_at(&agent(), &[format!("http://{closed}/key")], &card.info), Err(SyncError::Network(_))));
688 }
689
690 #[test]
691 fn logs_never_carry_a_pin() {
692 log::set_max_level(log::LevelFilter::Trace);
694 cap_logging();
695 assert_eq!(log::max_level(), log::LevelFilter::Info);
696 assert!(!log::log_enabled!(target: "openpgp_card::ocard::apdu", log::Level::Trace));
697 log::set_max_level(log::LevelFilter::Off);
699 cap_logging();
700 assert_eq!(log::max_level(), log::LevelFilter::Off);
701 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
703 let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap();
704 for logger in ["env_logger", "simple_logger", "simplelog", "fern", "log4rs", "android_logger", "oslog", "syslog", "systemd-journal-logger", "tracing-log", "pretty_env_logger", "flexi_logger", "slog-stdlog", "stderrlog", "tracing-subscriber"] {
705 assert!(!lock.contains(&format!("name = \"{logger}\"")), "{logger} would write what crates log");
706 }
707 let mut sources = vec![root.join("crates")];
708 while let Some(path) = sources.pop() {
709 if path.is_dir() {
710 sources.extend(std::fs::read_dir(&path).unwrap().filter_map(Result::ok).map(|e| e.path()));
711 } else if path.extension().is_some_and(|x| x == "rs") {
712 let text = std::fs::read_to_string(&path).unwrap();
713 for (call, here) in [(concat!("set_", "logger("), false), (concat!("set_boxed", "_logger("), false), (concat!("set_max", "_level("), true)] {
715 let calls = text.matches(call).count();
716 let allowed = if here && path.ends_with("sioul-sync/src/securitykey.rs") { calls } else { 0 };
717 assert_eq!(calls, allowed, "{} calls {call}", path.display());
718 }
719 }
720 }
721 }
722
723 #[cfg(unix)]
724 #[test]
725 fn gnupg_is_asked_to_let_go_with_a_stand_in() {
726 use std::os::unix::fs::PermissionsExt;
727 let dir = std::env::temp_dir().join(format!("sioul-gpgconf-{}", std::process::id()));
728 std::fs::create_dir_all(&dir).unwrap();
729 let program = dir.join("gpgconf");
731 std::fs::write(&program, format!("#!/bin/sh\necho \"$@\" > '{}'\n", dir.join("asked").display())).unwrap();
732 std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap();
733 assert_eq!(release_with(&program), Released::Done);
734 assert_eq!(std::fs::read_to_string(dir.join("asked")).unwrap().trim(), "--kill scdaemon");
735 let refusing = dir.join("refusing");
736 std::fs::write(&refusing, "#!/bin/sh\necho 'gpgconf: no such component' >&2\nexit 2\n").unwrap();
737 std::fs::set_permissions(&refusing, std::fs::Permissions::from_mode(0o755)).unwrap();
738 assert_eq!(release_with(&refusing), Released::Failed("gpgconf: no such component".into()));
739 assert_eq!(release_with(&dir.join("nowhere")), Released::NoGnupg);
740 let tr = Translator::new("en");
741 for said in [Released::Done, Released::NoGnupg, Released::Sandboxed, Released::Failed("x".into())] {
742 assert!(!said.sentence(&tr).starts_with("seckey-"), "{said:?}");
743 }
744 std::fs::remove_dir_all(&dir).unwrap();
745 }
746
747 struct ThrowawayHome {
751 dir: PathBuf,
752 gpg: PathBuf,
753 }
754
755 impl ThrowawayHome {
756 fn new(name: &str) -> Option<ThrowawayHome> {
757 let gpg = Gnupg::find().ok()?.program;
758 if cfg!(windows) && gpg.components().any(|c| c.as_os_str().eq_ignore_ascii_case("usr")) {
761 return None;
762 }
763 let dir = std::env::temp_dir().join(format!("sg-{name}-{}", std::process::id()));
765 let _ = std::fs::remove_dir_all(&dir);
766 std::fs::create_dir_all(&dir).unwrap();
767 #[cfg(unix)]
768 {
769 use std::os::unix::fs::PermissionsExt;
770 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
771 }
772 std::fs::write(dir.join("gpg-agent.conf"), "disable-scdaemon\n").unwrap();
773 std::fs::write(dir.join("gpg.conf"), "no-auto-key-locate\nno-auto-key-retrieve\n").unwrap();
774 Some(ThrowawayHome { dir, gpg })
775 }
776
777 fn gpg(&self, args: &[&str]) -> String {
779 self.gpg_said(args).0
780 }
781
782 fn gpg_said(&self, args: &[&str]) -> (String, String) {
784 let out = std::process::Command::new(&self.gpg).arg("--homedir").arg(&self.dir).args(["--batch", "--no-tty", "--pinentry-mode", "loopback", "--passphrase", ""]).args(args).stdin(Stdio::null()).output().unwrap();
785 assert!(out.status.success(), "gpg {args:?}: {}", String::from_utf8_lossy(&out.stderr));
786 (String::from_utf8_lossy(&out.stdout).to_string(), String::from_utf8_lossy(&out.stderr).to_string())
787 }
788
789 fn gnupg(&self) -> Gnupg {
790 Gnupg::with_home(&self.gpg, &self.dir)
791 }
792 }
793
794 impl Drop for ThrowawayHome {
795 fn drop(&mut self) {
796 let gpgconf = self.gpg.with_file_name(if cfg!(windows) { "gpgconf.exe" } else { "gpgconf" });
797 let _ = std::process::Command::new(gpgconf).arg("--homedir").arg(&self.dir).args(["--kill", "all"]).stdin(Stdio::null()).output();
798 let _ = std::fs::remove_dir_all(&self.dir);
799 }
800 }
801
802 fn expiries(home: &ThrowawayHome, fingerprint: &str) -> Vec<i64> {
804 home.gpg(&["--with-colons", "--list-keys", fingerprint]).lines().filter(|l| l.starts_with("pub:") || l.starts_with("sub:")).map(|l| l.split(':').nth(6).unwrap_or("").parse().unwrap_or(0)).collect()
805 }
806
807 #[test]
811 fn gnupg_exports_and_renews_in_a_throwaway_home() {
812 let _alone = ON_CARD_TESTS.lock().unwrap_or_else(|e| e.into_inner());
813 let Some(home) = ThrowawayHome::new("gpg") else { return };
814 let (card, public) = SoftCard::generate("0006:12345678", &["Card Holder <me@example.org>"], "123456").unwrap();
816 let asc = home.dir.join("card.asc");
817 std::fs::write(&asc, &public).unwrap();
818 let (_, imported) = home.gpg_said(&["--import", asc.to_str().unwrap()]);
819 let fingerprint = card.info.sign.as_ref().unwrap().fingerprint.clone();
820 let gnupg = home.gnupg();
821 let exported = gnupg.export(&fingerprint).unwrap();
823 assert!(String::from_utf8_lossy(&exported).starts_with("-----BEGIN PGP PUBLIC KEY BLOCK-----"));
824 let (_, check) = securitykey::certificate_in(&exported, &card.info).unwrap_or_else(|e| panic!("{e:?}; the import said: {imported}; GnuPG holds: {}", home.gpg(&["--with-colons", "--list-keys"])));
826 assert_eq!(check.addresses, vec!["me@example.org".to_string()]);
827 assert_eq!(gnupg.export("0123456789ABCDEF0123456789ABCDEF01234567"), Err(GnupgProblem::NoSuchKey));
829 assert_eq!(gnupg.export("me; rm -rf ~"), Err(GnupgProblem::NoSuchKey));
830 assert!(!gnupg.holds_secret(&fingerprint));
832 assert_eq!(gnupg.renew(&fingerprint, "2y", &|_| {}), Err(GnupgProblem::NoSuchKey));
833 home.gpg(&["--quick-gen-key", "Test Key <test@example.org>", "ed25519", "cert,sign", "1y"]);
835 let own = home.gpg(&["--with-colons", "--list-secret-keys", "test@example.org"]).lines().find(|l| l.starts_with("fpr:")).unwrap().split(':').nth(9).unwrap().to_string();
836 home.gpg(&["--quick-add-key", &own, "cv25519", "encr", "1y"]);
837 assert!(gnupg.holds_secret(&own));
838 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
839 assert!(expiries(&home, &own).iter().all(|at| *at < now + 400 * 86_400), "a year first");
840 let steps = std::sync::Mutex::new(Vec::new());
843 let looks = std::sync::Mutex::new(Vec::new());
844 gnupg
845 .renew(&own, "2y", &|step| {
846 steps.lock().unwrap().push(step);
847 looks.lock().unwrap().push(may_look());
848 })
849 .unwrap();
850 assert_eq!(*steps.lock().unwrap(), [GnupgStep::Card, GnupgStep::Card], "one for the key, one for its subkeys");
851 assert!(looks.lock().unwrap().iter().all(|may| !may), "no look at the keys while GnuPG works with one");
852 assert!(may_look(), "and again once it is done");
853 let renewed = expiries(&home, &own);
854 assert_eq!(renewed.len(), 2);
855 assert!(renewed.iter().all(|at| *at > now + 700 * 86_400 && *at < now + 740 * 86_400), "{renewed:?}");
856 assert_eq!(Gnupg::with_home(&home.dir.join("no-gpg"), &home.dir).export(&own), Err(GnupgProblem::Missing));
858 assert_eq!(renew_commands("ABCD", &["EF01".to_string(), "2345".to_string()]), ["gpg --quick-set-expire ABCD 2y".to_string(), "gpg --quick-set-expire ABCD 2y EF01 2345".to_string()]);
860 assert_eq!(renew_commands("ABCD", &[]), ["gpg --quick-set-expire ABCD 2y".to_string()]);
861 assert_eq!(export_command("ABCD"), "gpg --export --armor ABCD > key.asc");
862 }
863
864 #[test]
869 fn subkeys_expired_are_said_and_renewed_one_by_one() {
870 let _alone = ON_CARD_TESTS.lock().unwrap_or_else(|e| e.into_inner());
871 let Some(home) = ThrowawayHome::new("sub") else { return };
872 let past = "20220101T000000";
873 home.gpg(&["--faked-system-time", past, "--quick-gen-key", "Old Key <old@example.org>", "ed25519", "cert,sign", "1y"]);
874 let primary = home.gpg(&["--with-colons", "--list-secret-keys", "old@example.org"]).lines().find(|l| l.starts_with("fpr:")).unwrap().split(':').nth(9).unwrap().to_string();
875 home.gpg(&["--faked-system-time", past, "--quick-add-key", &primary, "cv25519", "encr", "1y"]);
876 home.gpg(&["--faked-system-time", past, "--quick-add-key", &primary, "ed25519", "auth", "1y"]);
877 home.gpg(&["--quick-set-expire", &primary, "2y"]);
879 home.gpg(&["--quick-set-expire", &primary, "2y", "*"]);
880 let gnupg = home.gnupg();
881 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
882 let parts = securitykey::parts_in(&gnupg.export(&primary).unwrap());
883 let roles: Vec<(securitykey::Role, bool)> = parts.iter().map(|p| (p.role, p.expires.is_some_and(|at| at <= now))).collect();
884 assert_eq!(roles, [(securitykey::Role::Primary, false), (securitykey::Role::Encryption, true), (securitykey::Role::Authentication, true)]);
885 let tr = Translator::new("en");
886 let said = securitykey::expired_parts(&tr, &parts, now);
887 assert_eq!(said.len(), 2, "{said:?}");
888 let encryption = securitykey::short_fingerprint(&parts[1].fingerprint);
889 assert!(said[0].starts_with(&format!("Its encryption subkey ({encryption}) expired on ")) && said[0].ends_with("2023: nobody can encrypt to you with it."), "{}", said[0]);
890 assert!(said[1].starts_with("Its authentication subkey ("), "{}", said[1]);
891 let mut listed = gnupg.subkeys(&primary);
893 listed.sort();
894 let mut expected = vec![parts[1].fingerprint.clone(), parts[2].fingerprint.clone()];
895 expected.sort();
896 assert_eq!(listed, expected);
897 gnupg.renew(&primary, "2y", &|_| {}).unwrap();
898 let renewed = securitykey::parts_in(&gnupg.export(&primary).unwrap());
899 assert!(renewed.iter().all(|p| p.expires.is_some_and(|at| at > now + 700 * 86_400)), "{renewed:?}");
900 assert!(securitykey::expired_parts(&tr, &renewed, now).is_empty());
901 let until = securitykey::parts_until(&tr, &renewed);
902 assert!(until.starts_with("primary key until ") && until.contains("encryption subkey until ") && until.contains("authentication subkey until "), "{until}");
903 }
904
905 #[cfg(unix)]
909 #[test]
910 fn renewing_ends_with_the_key_let_go_once() {
911 use std::os::unix::fs::PermissionsExt;
912 let _alone = ON_CARD_TESTS.lock().unwrap_or_else(|e| e.into_inner());
913 let dir = std::env::temp_dir().join(format!("sioul-gpg-standin-{}", std::process::id()));
914 let _ = std::fs::remove_dir_all(&dir);
915 std::fs::create_dir_all(&dir).unwrap();
916 let primary = "0123456789ABCDEF0123456789ABCDEF01234567";
917 let sub = "89ABCDEF0123456789ABCDEF0123456789ABCDEF";
918 let asked = dir.join("asked");
919 let script = |name: &str, body: String| {
920 let path = dir.join(name);
921 std::fs::write(&path, body).unwrap();
922 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
923 path
924 };
925 let fail = dir.join("fail");
926 let gpg = script(
927 "gpg",
928 format!(
929 "#!/bin/sh\ncase \"$*\" in\n*--list-secret-keys*) echo 'sec:u:255:22:0123456789ABCDEF:1:::::::scESC:::#:::23::0:' ;;\n*--list-keys*) printf 'pub:u:255:22:0123456789ABCDEF:1:::::::scESC:\\nfpr:::::::::{primary}:\\nsub:e:255:18:89ABCDEF0123:1:::::::e:\\nfpr:::::::::{sub}:\\n' ;;\n*--quick-set-expire*) echo \"gpg $*\" >> '{asked}'; if [ -e '{fail}' ]; then echo '[GNUPG:] FAILURE sign 100663358' >&2; exit 2; fi ;;\nesac\nexit 0\n",
930 asked = asked.display(),
931 fail = fail.display()
932 ),
933 );
934 script("gpgconf", format!("#!/bin/sh\necho \"gpgconf $*\" >> '{}'\n", asked.display()));
935 let home = dir.join("home");
936 let gnupg = Gnupg::with_home(&gpg, &home);
937 gnupg.renew(primary, "2y", &|_| {}).unwrap();
938 let lines: Vec<String> = std::fs::read_to_string(&asked).unwrap().lines().map(str::to_string).collect();
939 let homedir = format!("--homedir {}", home.display());
940 assert_eq!(
941 lines,
942 [
943 format!("gpg {homedir} --batch --no-tty --status-fd 2 --quick-set-expire {primary} 2y"),
944 format!("gpg {homedir} --batch --no-tty --status-fd 2 --quick-set-expire {primary} 2y {sub}"),
945 format!("gpgconf {homedir} --kill scdaemon"),
946 ],
947 "the key and its expired subkey, each named; then the key let go, once"
948 );
949 std::fs::write(&asked, "").unwrap();
951 std::fs::write(&fail, "").unwrap();
952 assert_eq!(gnupg.renew(primary, "2y", &|_| {}), Err(GnupgProblem::TouchMissed));
953 let lines = std::fs::read_to_string(&asked).unwrap();
954 assert_eq!(lines.lines().filter(|l| l.starts_with("gpgconf")).count(), 1, "{lines}");
955 std::fs::remove_dir_all(&dir).unwrap();
956 }
957
958 static ON_CARD_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(());
964
965 #[test]
966 fn gnupg_says_where_it_is_and_a_touch_missed() {
967 let _alone = ON_CARD_TESTS.lock().unwrap_or_else(|e| e.into_inner());
968 assert_eq!(status_of("[GNUPG:] PINENTRY_LAUNCHED 41235 gnome3 1.3.1 - - - - 1000/1000 0"), Some(Status::Pinentry(41235)));
969 assert_eq!(status_of("[GNUPG:] FAILURE sign 100663358"), Some(Status::Failed(GPG_ERR_TIMEOUT)), "6 << 24 | 62: the smart card daemon's timeout");
970 assert_eq!(status_of("[GNUPG:] ERROR keyedit.setexpire 62"), Some(Status::Failed(GPG_ERR_TIMEOUT)));
971 assert_eq!(status_of("[GNUPG:] FAILURE sign 83918950"), Some(Status::Failed(83918950 & 0xFFFF)));
972 assert_eq!(status_of("[GNUPG:] KEY_CONSIDERED ABCD 0"), None);
973 assert_eq!(status_of("gpg: signing failed: Délai d'attente dépassé"), None);
974 assert!(may_look());
976 let first = OnCard::start();
977 let second = OnCard::start();
978 assert!(!may_look());
979 drop(first);
980 assert!(!may_look());
981 drop(second);
982 assert!(may_look());
983 }
984
985 fn stand_in_posts(answers: Vec<(u16, &'static str)>) -> (String, std::sync::mpsc::Receiver<(String, String)>) {
988 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
989 let address = format!("http://{}", listener.local_addr().unwrap());
990 let (tell, heard) = std::sync::mpsc::channel();
991 std::thread::spawn(move || {
992 for (status, body) in answers {
993 let Ok((stream, _)) = listener.accept() else { return };
994 let mut reader = BufReader::new(stream.try_clone().unwrap());
995 let mut line = String::new();
996 reader.read_line(&mut line).unwrap();
997 let mut length = 0;
998 loop {
999 let mut header = String::new();
1000 reader.read_line(&mut header).unwrap();
1001 if header.trim_end().is_empty() {
1002 break;
1003 }
1004 if let Some((name, value)) = header.split_once(':')
1005 && name.eq_ignore_ascii_case("content-length")
1006 {
1007 length = value.trim().parse().unwrap_or(0);
1008 }
1009 }
1010 let mut sent = vec![0u8; length];
1011 std::io::Read::read_exact(&mut reader, &mut sent).unwrap();
1012 let _ = tell.send((line.split_whitespace().nth(1).unwrap_or("").to_string(), String::from_utf8_lossy(&sent).to_string()));
1013 let mut stream = stream;
1014 let _ = stream.write_all(format!("HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes());
1015 }
1016 });
1017 (address, heard)
1018 }
1019
1020 #[test]
1024 fn keys_openpgp_gets_the_certificate_and_mails_each_address() {
1025 let (_, public) = SoftCard::generate("0006:12345678", &["Card Holder <me@example.org>", "Card Holder <old@example.org>"], "123456").unwrap();
1026 let agent: ureq::Agent = ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(5))).http_status_as_error(false).build().into();
1027 let (base, heard) = stand_in_posts(vec![
1028 (200, r#"{"key_fpr":"ABCD","status":{"me@example.org":"unpublished","old@example.org":"published"},"token":"t0k3n"}"#),
1029 (200, r#"{"key_fpr":"ABCD","status":{"me@example.org":"pending","old@example.org":"published"},"token":"t0k3n"}"#),
1030 ]);
1031 let published = send_at(&agent, &base, &public, "fr").unwrap();
1032 assert_eq!(published, Published { fingerprint: "ABCD".into(), mailed: vec!["me@example.org".into()], published: vec!["old@example.org".into()] });
1033 let (path, body) = heard.recv().unwrap();
1034 assert_eq!(path, "/vks/v1/upload");
1035 let sent: serde_json::Value = serde_json::from_str(&body).unwrap();
1036 assert_eq!(sent["keytext"].as_str().unwrap().as_bytes(), &public[..], "the public certificate, as it is");
1037 let (path, body) = heard.recv().unwrap();
1038 assert_eq!(path, "/vks/v1/request-verify");
1039 assert_eq!(serde_json::from_str::<serde_json::Value>(&body).unwrap(), serde_json::json!({ "token": "t0k3n", "addresses": ["me@example.org"], "locale": ["fr"] }));
1040 let (base, heard) = stand_in_posts(vec![(200, r#"{"key_fpr":"ABCD","status":{"me@example.org":"published"},"token":"t"}"#)]);
1042 assert_eq!(send_at(&agent, &base, &public, "en").unwrap().mailed, Vec::<String>::new());
1043 assert_eq!(heard.try_iter().count(), 1);
1044 let (base, _heard) = stand_in_posts(vec![(400, r#"{"error":"Rate limit exceeded"}"#)]);
1046 assert!(matches!(send_at(&agent, &base, &public, "en"), Err(SyncError::Server(said)) if said.contains("Rate limit exceeded")));
1047 let https: ureq::Agent = ureq::Agent::config_builder().https_only(true).timeout_global(Some(Duration::from_secs(5))).build().into();
1049 let (base, _heard) = stand_in_posts(vec![]);
1050 assert!(send_at(&https, &base, &public, "en").is_err());
1051 }
1052}