1use crate::SyncError;
36use sioul_core::i18n::Translator;
37use sioul_core::pgp;
38#[cfg(target_os = "android")]
39use sioul_core::securitykey::NoReaders;
40use sioul_core::securitykey::{self, CardInfo, CertCheck, Certificate, Mismatch, Readers};
41use std::path::{Path, PathBuf};
42use std::process::Stdio;
43use std::time::Duration;
44
45pub mod ocard;
46pub mod phone;
47#[cfg(not(target_os = "android"))]
48pub mod smartcard;
49
50pub fn readers() -> &'static dyn Readers {
54 #[cfg(not(target_os = "android"))]
55 {
56 static PCSC: smartcard::PcscReaders = smartcard::PcscReaders;
57 &PCSC
58 }
59 #[cfg(target_os = "android")]
60 {
61 static NONE: NoReaders = NoReaders;
62 static PHONE: std::sync::OnceLock<phone::PhoneReaders> = std::sync::OnceLock::new();
63 match phone::link() {
64 Some(link) => PHONE.get_or_init(|| phone::PhoneReaders::new(link)),
65 None => &NONE,
66 }
67 }
68}
69
70pub fn usable_here() -> bool {
76 #[cfg(not(target_os = "android"))]
77 {
78 true
79 }
80 #[cfg(target_os = "android")]
81 {
82 phone::link().is_some_and(|link| link.state() != phone::LinkState::NoNfc || link.usb_host())
83 }
84}
85
86pub fn cap_logging() {
93 log::set_max_level(log::max_level().min(log::LevelFilter::Info));
94}
95
96pub enum Looked {
98 Found {
100 cert: Certificate,
102 check: CertCheck,
104 source: String,
106 },
107 Mismatch(Mismatch),
109 NotFound,
111}
112
113pub fn places(card: &CardInfo, addresses: &[String]) -> Vec<String> {
117 let mut urls = Vec::new();
118 let url = card.url.trim();
119 if url.get(..8).is_some_and(|s| s.eq_ignore_ascii_case("https://")) {
120 urls.push(url.to_string());
121 }
122 for address in addresses {
123 urls.extend(pgp::wkd_urls(address).into_iter().flatten());
124 }
125 if let Some(slot) = card.sign.as_ref().or(card.decrypt.as_ref()) {
126 urls.push(format!("https://keys.openpgp.org/vks/v1/by-fingerprint/{}", slot.fingerprint.to_ascii_uppercase()));
128 }
129 let mut seen = std::collections::BTreeSet::new();
130 urls.retain(|u| seen.insert(u.to_ascii_lowercase()));
131 urls
132}
133
134pub fn find_certificate(card: &CardInfo, addresses: &[String]) -> Result<Looked, SyncError> {
137 let agent: ureq::Agent = ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(15))).https_only(true).user_agent("Sioul").build().into();
139 find_at(&agent, &places(card, addresses), card)
140}
141
142fn find_at(agent: &ureq::Agent, urls: &[String], card: &CardInfo) -> Result<Looked, SyncError> {
143 let mut mismatch = None;
144 let mut last_error = None;
145 for url in urls {
146 match agent.get(url).call() {
147 Ok(mut response) => {
148 let bytes = response.body_mut().read_to_vec().unwrap_or_default();
149 match securitykey::certificate_in(&bytes, card) {
150 Ok((cert, check)) => return Ok(Looked::Found { cert, check, source: url.clone() }),
151 Err(Mismatch::Missing) => {}
153 Err(why) => mismatch = Some(why),
154 }
155 }
156 Err(ureq::Error::StatusCode(_)) => {}
157 Err(e) => last_error = Some(e.to_string()),
158 }
159 }
160 match (mismatch, last_error) {
161 (Some(why), _) => Ok(Looked::Mismatch(why)),
162 (None, Some(e)) => Err(SyncError::Network(e)),
163 (None, None) => Ok(Looked::NotFound),
164 }
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
169pub enum Released {
170 Done,
172 NoGnupg,
174 Sandboxed,
176 Failed(String),
178}
179
180impl Released {
181 pub fn sentence(&self, tr: &Translator) -> String {
183 match self {
184 Released::Done => tr.text("seckey-released", None),
185 Released::NoGnupg => tr.text("seckey-release-no-gnupg", None),
186 Released::Sandboxed => tr.text("seckey-release-sandboxed", None),
187 Released::Failed(detail) => {
188 let mut args = sioul_core::i18n::args();
189 args.set("detail", detail.replace('`', "'"));
191 tr.text("seckey-release-failed", Some(&args))
192 }
193 }
194 }
195}
196
197pub fn release_gnupg() -> Released {
200 if Path::new("/.flatpak-info").exists() {
201 return Released::Sandboxed;
202 }
203 for program in gpgconf_places() {
204 match release_with(&program) {
205 Released::NoGnupg => continue,
206 done => return done,
207 }
208 }
209 Released::NoGnupg
210}
211
212fn gpgconf_places() -> Vec<PathBuf> {
215 let mut places = vec![PathBuf::from(if cfg!(windows) { "gpgconf.exe" } else { "gpgconf" })];
216 if cfg!(target_os = "macos") {
217 places.extend(["/opt/homebrew/bin/gpgconf", "/usr/local/bin/gpgconf", "/usr/local/MacGPG2/bin/gpgconf"].map(PathBuf::from));
218 }
219 if cfg!(windows) {
220 places.extend([r"C:\Program Files (x86)\GnuPG\bin\gpgconf.exe", r"C:\Program Files\GnuPG\bin\gpgconf.exe"].map(PathBuf::from));
221 }
222 places
223}
224
225fn release_with(program: &Path) -> Released {
226 release_at(program, None)
227}
228
229fn release_at(program: &Path, home: Option<&Path>) -> Released {
231 let mut command = std::process::Command::new(program);
232 if let Some(home) = home {
233 command.arg("--homedir").arg(home);
234 }
235 command.args(["--kill", "scdaemon"]).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::piped());
236 #[cfg(windows)]
238 {
239 use std::os::windows::process::CommandExt;
240 command.creation_flags(0x0800_0000);
241 }
242 match command.output() {
243 Ok(out) if out.status.success() => Released::Done,
244 Ok(out) => Released::Failed(String::from_utf8_lossy(&out.stderr).lines().map(str::trim).rfind(|l| !l.is_empty()).unwrap_or("").to_string()),
245 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Released::NoGnupg,
246 Err(e) => Released::Failed(e.to_string()),
247 }
248}
249
250#[derive(Debug, Clone)]
253pub struct Gnupg {
254 program: PathBuf,
255 home: Option<PathBuf>,
256}
257
258#[derive(Debug, Clone, PartialEq, Eq)]
260pub enum GnupgProblem {
261 Missing,
263 Sandboxed,
265 NoSuchKey,
267 TouchMissed,
270 Failed(String),
272}
273
274const GNUPG_WAITS: Duration = Duration::from_secs(5 * 60);
277
278const GPG_ERR_TIMEOUT: u32 = 62;
280
281#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub enum GnupgStep {
284 Pin,
286 Card,
288}
289
290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292pub enum Status {
293 Pinentry(u32),
295 Failed(u32),
298}
299
300pub fn status_of(line: &str) -> Option<Status> {
302 let mut words = line.trim().strip_prefix("[GNUPG:] ")?.split_whitespace();
303 match words.next()? {
304 "PINENTRY_LAUNCHED" => words.next()?.parse().ok().map(Status::Pinentry),
305 "FAILURE" | "ERROR" => words.last()?.parse::<u32>().ok().map(|code| Status::Failed(code & 0xFFFF)),
306 _ => None,
307 }
308}
309
310fn running(pid: u32) -> bool {
313 if cfg!(target_os = "linux") { Path::new(&format!("/proc/{pid}")).exists() } else { true }
314}
315
316static GNUPG_ON_CARD: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
320
321pub struct OnCard;
323
324impl OnCard {
325 pub fn start() -> OnCard {
327 GNUPG_ON_CARD.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
328 OnCard
329 }
330}
331
332impl Drop for OnCard {
333 fn drop(&mut self) {
334 GNUPG_ON_CARD.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
335 }
336}
337
338pub fn may_look() -> bool {
340 GNUPG_ON_CARD.load(std::sync::atomic::Ordering::SeqCst) == 0
341}
342
343impl Gnupg {
344 pub fn find() -> Result<Gnupg, GnupgProblem> {
347 if cfg!(target_os = "android") {
348 return Err(GnupgProblem::Missing);
349 }
350 if Path::new("/.flatpak-info").exists() {
351 return Err(GnupgProblem::Sandboxed);
352 }
353 let name = if cfg!(windows) { "gpg.exe" } else { "gpg" };
354 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));
355 let known: Vec<PathBuf> = if cfg!(target_os = "macos") {
356 ["/opt/homebrew/bin/gpg", "/usr/local/bin/gpg", "/usr/local/MacGPG2/bin/gpg"].map(PathBuf::from).to_vec()
357 } else if cfg!(windows) {
358 [r"C:\Program Files (x86)\GnuPG\bin\gpg.exe", r"C:\Program Files\GnuPG\bin\gpg.exe"].map(PathBuf::from).to_vec()
359 } else {
360 vec![PathBuf::from("/usr/bin/gpg")]
361 };
362 on_path.chain(known).find(|p| p.is_file()).map(|program| Gnupg { program, home: None }).ok_or(GnupgProblem::Missing)
363 }
364
365 pub fn with_home(program: &Path, home: &Path) -> Gnupg {
367 Gnupg { program: program.to_path_buf(), home: Some(home.to_path_buf()) }
368 }
369
370 fn run(&self, args: &[&str]) -> Result<Vec<u8>, GnupgProblem> {
373 self.run_watching(args, None)
374 }
375
376 fn run_watching(&self, args: &[&str], watch: Option<&(dyn Fn(GnupgStep) + Sync)>) -> Result<Vec<u8>, GnupgProblem> {
380 let mut command = std::process::Command::new(&self.program);
381 if let Some(home) = &self.home {
382 command.arg("--homedir").arg(home);
383 }
384 command.args(["--batch", "--no-tty"]);
385 if watch.is_some() {
386 command.args(["--status-fd", "2"]);
387 }
388 command.args(args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
389 #[cfg(windows)]
391 {
392 use std::os::windows::process::CommandExt;
393 command.creation_flags(0x0800_0000);
394 }
395 let mut child = command.spawn().map_err(|e| if e.kind() == std::io::ErrorKind::NotFound { GnupgProblem::Missing } else { GnupgProblem::Failed(e.to_string()) })?;
396 let mut out = child.stdout.take();
398 let mut err = child.stderr.take();
399 let reading = std::thread::spawn(move || {
400 use std::io::Read;
401 let mut bytes = Vec::new();
402 if let Some(out) = out.as_mut() {
403 let _ = out.read_to_end(&mut bytes);
404 }
405 bytes
406 });
407 let (tell, heard) = std::sync::mpsc::channel::<Status>();
409 let said = std::thread::spawn(move || {
410 use std::io::BufRead;
411 let mut text = String::new();
412 if let Some(err) = err.take() {
413 for line in std::io::BufReader::new(err).lines().map_while(Result::ok) {
414 match status_of(&line) {
415 Some(status) => {
416 let _ = tell.send(status);
417 }
418 None if !line.starts_with("[GNUPG:]") => {
419 text.push_str(&line);
420 text.push('\n');
421 }
422 None => {}
423 }
424 }
425 }
426 text
427 });
428 let started = std::time::Instant::now();
429 let mut shown: Option<GnupgStep> = None;
430 let mut pinentry: Option<u32> = None;
431 let mut failed: Option<u32> = None;
432 let status = loop {
433 if let Some(watch) = watch {
434 for status in heard.try_iter() {
435 match status {
436 Status::Pinentry(pid) => pinentry = Some(pid),
437 Status::Failed(code) => failed = Some(code),
438 }
439 }
440 if pinentry.is_some_and(|pid| !running(pid)) {
441 pinentry = None;
442 }
443 let step = if pinentry.is_some() { GnupgStep::Pin } else { GnupgStep::Card };
444 if shown != Some(step) {
445 shown = Some(step);
446 watch(step);
447 }
448 }
449 match child.try_wait() {
450 Ok(Some(status)) => break status,
451 Ok(None) if started.elapsed() > GNUPG_WAITS => {
452 let _ = child.kill();
453 let _ = child.wait();
454 return Err(GnupgProblem::Failed("gpg did not answer".into()));
455 }
456 Ok(None) => std::thread::sleep(Duration::from_millis(100)),
457 Err(e) => return Err(GnupgProblem::Failed(e.to_string())),
458 }
459 };
460 let output = reading.join().unwrap_or_default();
461 let errors = said.join().unwrap_or_default();
462 failed = heard.try_iter().fold(failed, |failed, status| if let Status::Failed(code) = status { Some(code) } else { failed });
463 if status.success() {
464 Ok(output)
465 } else if failed == Some(GPG_ERR_TIMEOUT) {
466 Err(GnupgProblem::TouchMissed)
467 } else {
468 Err(GnupgProblem::Failed(errors.lines().map(str::trim).rfind(|l| !l.is_empty()).unwrap_or("").to_string()))
469 }
470 }
471
472 pub fn export(&self, fingerprint: &str) -> Result<Vec<u8>, GnupgProblem> {
475 let fingerprint = fingerprint.trim();
476 if fingerprint.is_empty() || !fingerprint.chars().all(|c| c.is_ascii_hexdigit()) {
477 return Err(GnupgProblem::NoSuchKey);
478 }
479 let out = self.run(&["--armor", "--export", fingerprint])?;
480 if out.iter().all(u8::is_ascii_whitespace) { Err(GnupgProblem::NoSuchKey) } else { Ok(out) }
481 }
482
483 pub fn holds_secret(&self, fingerprint: &str) -> bool {
486 let fingerprint = fingerprint.trim();
487 !fingerprint.is_empty()
488 && fingerprint.chars().all(|c| c.is_ascii_hexdigit())
489 && self.run(&["--with-colons", "--list-secret-keys", fingerprint]).is_ok_and(|out| String::from_utf8_lossy(&out).lines().any(|l| l.starts_with("sec:")))
490 }
491
492 pub fn renew(&self, fingerprint: &str, period: &str, watch: &(dyn Fn(GnupgStep) + Sync)) -> Result<(), GnupgProblem> {
502 if !self.holds_secret(fingerprint) {
503 return Err(GnupgProblem::NoSuchKey);
504 }
505 let fingerprint = fingerprint.trim();
506 let subkeys = self.subkeys(fingerprint);
507 let renewed = {
508 let _on_card = OnCard::start();
509 self.run_watching(&["--quick-set-expire", fingerprint, period], Some(watch)).and_then(|_| {
510 if subkeys.is_empty() {
511 return Ok(Vec::new());
512 }
513 let mut args = vec!["--quick-set-expire", fingerprint, period];
514 args.extend(subkeys.iter().map(String::as_str));
515 self.run_watching(&args, Some(watch))
516 })
517 };
518 let _ = self.release();
520 renewed.map(|_| ())
521 }
522
523 pub fn subkeys(&self, fingerprint: &str) -> Vec<String> {
526 let Ok(listed) = self.run(&["--with-colons", "--list-keys", fingerprint.trim()]) else { return Vec::new() };
527 let mut out = Vec::new();
528 let mut next_is_sub = false;
529 for line in String::from_utf8_lossy(&listed).lines() {
530 let fields: Vec<&str> = line.split(':').collect();
531 match fields.first().copied() {
532 Some("sub") => next_is_sub = fields.get(1).is_some_and(|v| *v != "r"),
534 Some("fpr") if next_is_sub => {
535 out.extend(fields.get(9).filter(|f| !f.is_empty()).map(|f| f.to_string()));
536 next_is_sub = false;
537 }
538 Some("fpr") => {}
539 _ => next_is_sub = false,
540 }
541 }
542 out
543 }
544
545 pub fn release(&self) -> Released {
550 let gpgconf = self.program.with_file_name(if cfg!(windows) { "gpgconf.exe" } else { "gpgconf" });
551 let program = if gpgconf.is_file() { gpgconf } else { PathBuf::from(if cfg!(windows) { "gpgconf.exe" } else { "gpgconf" }) };
552 release_at(&program, self.home.as_deref())
553 }
554}
555
556pub fn renew_commands(fingerprint: &str, subkeys: &[String]) -> Vec<String> {
559 let mut out = vec![format!("gpg --quick-set-expire {fingerprint} 2y")];
560 if !subkeys.is_empty() {
561 out.push(format!("gpg --quick-set-expire {fingerprint} 2y {}", subkeys.join(" ")));
562 }
563 out
564}
565
566pub fn export_command(fingerprint: &str) -> String {
568 format!("gpg --export --armor {fingerprint} > key.asc")
569}
570
571#[derive(Debug, Clone, Default, PartialEq, Eq)]
573pub struct Published {
574 pub fingerprint: String,
576 pub mailed: Vec<String>,
578 pub published: Vec<String>,
580}
581
582pub const KEYS_OPENPGP: &str = "https://keys.openpgp.org";
584
585pub fn send_to_keys_openpgp(armored: &[u8], locale: &str) -> Result<Published, SyncError> {
591 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();
592 send_at(&agent, KEYS_OPENPGP, armored, locale)
593}
594
595fn send_at(agent: &ureq::Agent, base: &str, armored: &[u8], locale: &str) -> Result<Published, SyncError> {
596 let call = |path: &str, body: serde_json::Value| -> Result<serde_json::Value, SyncError> {
597 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()))?;
598 let status = response.status().as_u16();
599 let answer: serde_json::Value = serde_json::from_str(&response.body_mut().read_to_string().unwrap_or_default()).unwrap_or_default();
600 if status == 200 {
601 Ok(answer)
602 } else {
603 Err(SyncError::Server(format!("keys.openpgp.org: {status} {}", answer["error"].as_str().unwrap_or("")).trim().to_string()))
604 }
605 };
606 let uploaded = call("/vks/v1/upload", serde_json::json!({ "keytext": String::from_utf8_lossy(armored) }))?;
607 let addresses = |answer: &serde_json::Value, states: &[&str]| -> Vec<String> {
608 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()
609 };
610 let mut published = Published { fingerprint: uploaded["key_fpr"].as_str().unwrap_or("").to_string(), mailed: Vec::new(), published: addresses(&uploaded, &["published"]) };
611 let waiting = addresses(&uploaded, &["unpublished", "pending"]);
612 let token = uploaded["token"].as_str().unwrap_or("");
613 if !waiting.is_empty() && !token.is_empty() {
614 let locale: Vec<&str> = Some(locale).filter(|l| !l.is_empty()).into_iter().collect();
615 let asked = call("/vks/v1/request-verify", serde_json::json!({ "token": token, "addresses": waiting, "locale": locale }))?;
616 published.mailed = addresses(&asked, &["pending"]);
617 published.published = addresses(&asked, &["published"]);
618 }
619 Ok(published)
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625 use sioul_core::securitykey::SoftCard;
626 use std::io::{BufRead, BufReader, Write};
627 use std::net::TcpListener;
628
629 fn stand_in(answers: Vec<Vec<u8>>) -> (String, std::sync::mpsc::Receiver<String>) {
632 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
633 let address = format!("http://{}", listener.local_addr().unwrap());
634 let (tell, heard) = std::sync::mpsc::channel();
635 std::thread::spawn(move || {
636 for answer in answers {
637 let Ok((stream, _)) = listener.accept() else { return };
638 let mut reader = BufReader::new(stream.try_clone().unwrap());
639 let mut line = String::new();
640 reader.read_line(&mut line).unwrap();
641 loop {
642 let mut header = String::new();
643 reader.read_line(&mut header).unwrap();
644 if header.trim_end().is_empty() {
645 break;
646 }
647 }
648 let _ = tell.send(line.split_whitespace().nth(1).unwrap_or("").to_string());
649 let mut stream = stream;
650 let _ = stream.write_all(&answer);
651 }
652 });
653 (address, heard)
654 }
655
656 fn ok(body: &[u8]) -> Vec<u8> {
657 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();
658 answer.extend_from_slice(body);
659 answer
660 }
661
662 const NOT_FOUND: &[u8] = b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
663
664 fn agent() -> ureq::Agent {
665 ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(5))).build().into()
666 }
667
668 #[test]
669 fn the_places_come_in_order_and_https_only() {
670 let (mut card, _) = SoftCard::generate("0006:12345678", &["Card Holder <me@example.org>"], "123456").unwrap();
671 card.info.url = "https://keys.example.org/me.asc".into();
672 let urls = places(&card.info, &["me@example.org".to_string()]);
673 assert_eq!(urls[0], "https://keys.example.org/me.asc");
674 assert!(urls[1].starts_with("https://openpgpkey.example.org/.well-known/openpgpkey/example.org/hu/"), "{urls:?}");
675 assert!(urls[2].starts_with("https://example.org/.well-known/openpgpkey/hu/"), "{urls:?}");
676 let fingerprint = card.info.sign.as_ref().unwrap().fingerprint.clone();
677 assert_eq!(urls[3], format!("https://keys.openpgp.org/vks/v1/by-fingerprint/{fingerprint}"));
678 assert_eq!(fingerprint, fingerprint.to_ascii_uppercase());
679 card.info.url = "http://keys.example.org/me.asc".into();
681 assert!(!places(&card.info, &[]).iter().any(|u| u.starts_with("http:")));
682 assert_eq!(places(&card.info, &[]).len(), 1);
683 }
684
685 #[test]
686 fn the_first_certificate_that_is_the_keys_is_kept() {
687 let (card, public) = SoftCard::generate("0006:12345678", &["Card Holder <me@example.org>"], "123456").unwrap();
688 let (_, stranger) = SoftCard::generate("0006:87654321", &["Someone Else <someone@example.net>"], "123456").unwrap();
689 let (base, heard) = stand_in(vec![NOT_FOUND.to_vec(), ok(&stranger), ok(&public)]);
690 let urls = vec![format!("{base}/card"), format!("{base}/wkd"), format!("{base}/vks")];
691 match find_at(&agent(), &urls, &card.info).unwrap() {
692 Looked::Found { check, source, .. } => {
693 assert_eq!(source, format!("{base}/vks"));
694 assert_eq!(check.addresses, vec!["me@example.org".to_string()]);
695 assert!(check.signs && check.decrypts && !check.expired);
696 }
697 _ => panic!("not found"),
698 }
699 let asked: Vec<String> = heard.try_iter().collect();
700 assert_eq!(asked, vec!["/card", "/wkd", "/vks"]);
701 let (base, _heard) = stand_in(vec![NOT_FOUND.to_vec(), ok(&stranger)]);
703 assert!(matches!(find_at(&agent(), &[format!("{base}/a"), format!("{base}/b")], &card.info).unwrap(), Looked::NotFound));
704 }
705
706 #[test]
707 fn no_network_is_said_as_such() {
708 let (card, _) = SoftCard::generate("0006:12345678", &[], "123456").unwrap();
709 let closed = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap();
711 assert!(matches!(find_at(&agent(), &[format!("http://{closed}/key")], &card.info), Err(SyncError::Network(_))));
712 }
713
714 #[test]
715 fn logs_never_carry_a_pin() {
716 log::set_max_level(log::LevelFilter::Trace);
718 cap_logging();
719 assert_eq!(log::max_level(), log::LevelFilter::Info);
720 assert!(!log::log_enabled!(target: "openpgp_card::ocard::apdu", log::Level::Trace));
721 log::set_max_level(log::LevelFilter::Off);
723 cap_logging();
724 assert_eq!(log::max_level(), log::LevelFilter::Off);
725 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
727 let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap();
728 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"] {
729 assert!(!lock.contains(&format!("name = \"{logger}\"")), "{logger} would write what crates log");
730 }
731 let mut sources = vec![root.join("crates")];
732 while let Some(path) = sources.pop() {
733 if path.is_dir() {
734 sources.extend(std::fs::read_dir(&path).unwrap().filter_map(Result::ok).map(|e| e.path()));
735 } else if path.extension().is_some_and(|x| x == "rs") {
736 let text = std::fs::read_to_string(&path).unwrap();
737 for (call, here) in [(concat!("set_", "logger("), false), (concat!("set_boxed", "_logger("), false), (concat!("set_max", "_level("), true)] {
739 let calls = text.matches(call).count();
740 let allowed = if here && path.ends_with("sioul-sync/src/securitykey.rs") { calls } else { 0 };
741 assert_eq!(calls, allowed, "{} calls {call}", path.display());
742 }
743 }
744 }
745 }
746
747 #[cfg(unix)]
754 pub(super) fn executable(path: &std::path::Path, text: &str) {
755 let aside = path.with_extension("text");
756 std::fs::write(&aside, text).unwrap();
757 let status = std::process::Command::new("install").arg("-m").arg("755").arg(&aside).arg(path).status().unwrap();
758 assert!(status.success(), "install {}", path.display());
759 let _ = std::fs::remove_file(&aside);
760 }
761
762 #[cfg(unix)]
763 #[test]
764 fn gnupg_is_asked_to_let_go_with_a_stand_in() {
765 let dir = std::env::temp_dir().join(format!("sioul-gpgconf-{}", std::process::id()));
766 std::fs::create_dir_all(&dir).unwrap();
767 let program = dir.join("gpgconf");
769 executable(&program, &format!("#!/bin/sh\necho \"$@\" > '{}'\n", dir.join("asked").display()));
770 assert_eq!(release_with(&program), Released::Done);
771 assert_eq!(std::fs::read_to_string(dir.join("asked")).unwrap().trim(), "--kill scdaemon");
772 let refusing = dir.join("refusing");
773 executable(&refusing, "#!/bin/sh\necho 'gpgconf: no such component' >&2\nexit 2\n");
774 assert_eq!(release_with(&refusing), Released::Failed("gpgconf: no such component".into()));
775 assert_eq!(release_with(&dir.join("nowhere")), Released::NoGnupg);
776 let tr = Translator::new("en");
777 for said in [Released::Done, Released::NoGnupg, Released::Sandboxed, Released::Failed("x".into())] {
778 assert!(!said.sentence(&tr).starts_with("seckey-"), "{said:?}");
779 }
780 std::fs::remove_dir_all(&dir).unwrap();
781 }
782
783 struct ThrowawayHome {
787 dir: PathBuf,
788 gpg: PathBuf,
789 }
790
791 impl ThrowawayHome {
792 fn new(name: &str) -> Option<ThrowawayHome> {
793 let gpg = Gnupg::find().ok()?.program;
794 if cfg!(windows) && gpg.components().any(|c| c.as_os_str().eq_ignore_ascii_case("usr")) {
797 return None;
798 }
799 let dir = std::env::temp_dir().join(format!("sg-{name}-{}", std::process::id()));
801 let _ = std::fs::remove_dir_all(&dir);
802 std::fs::create_dir_all(&dir).unwrap();
803 #[cfg(unix)]
804 {
805 use std::os::unix::fs::PermissionsExt;
806 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
807 }
808 std::fs::write(dir.join("gpg-agent.conf"), "disable-scdaemon\n").unwrap();
809 std::fs::write(dir.join("gpg.conf"), "no-auto-key-locate\nno-auto-key-retrieve\n").unwrap();
810 Some(ThrowawayHome { dir, gpg })
811 }
812
813 fn gpg(&self, args: &[&str]) -> String {
815 self.gpg_said(args).0
816 }
817
818 fn gpg_said(&self, args: &[&str]) -> (String, String) {
820 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();
821 assert!(out.status.success(), "gpg {args:?}: {}", String::from_utf8_lossy(&out.stderr));
822 (String::from_utf8_lossy(&out.stdout).to_string(), String::from_utf8_lossy(&out.stderr).to_string())
823 }
824
825 fn gnupg(&self) -> Gnupg {
826 Gnupg::with_home(&self.gpg, &self.dir)
827 }
828 }
829
830 impl Drop for ThrowawayHome {
831 fn drop(&mut self) {
832 let gpgconf = self.gpg.with_file_name(if cfg!(windows) { "gpgconf.exe" } else { "gpgconf" });
833 let _ = std::process::Command::new(gpgconf).arg("--homedir").arg(&self.dir).args(["--kill", "all"]).stdin(Stdio::null()).output();
834 let _ = std::fs::remove_dir_all(&self.dir);
835 }
836 }
837
838 fn expiries(home: &ThrowawayHome, fingerprint: &str) -> Vec<i64> {
840 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()
841 }
842
843 #[test]
847 fn gnupg_exports_and_renews_in_a_throwaway_home() {
848 let _alone = ON_CARD_TESTS.lock().unwrap_or_else(|e| e.into_inner());
849 let Some(home) = ThrowawayHome::new("gpg") else { return };
850 let (card, public) = SoftCard::generate("0006:12345678", &["Card Holder <me@example.org>"], "123456").unwrap();
852 let asc = home.dir.join("card.asc");
853 std::fs::write(&asc, &public).unwrap();
854 let (_, imported) = home.gpg_said(&["--import", asc.to_str().unwrap()]);
855 let fingerprint = card.info.sign.as_ref().unwrap().fingerprint.clone();
856 let gnupg = home.gnupg();
857 let exported = gnupg.export(&fingerprint).unwrap();
859 assert!(String::from_utf8_lossy(&exported).starts_with("-----BEGIN PGP PUBLIC KEY BLOCK-----"));
860 let (_, check) = securitykey::certificate_in(&exported, &card.info).unwrap_or_else(|e| panic!("{e:?}; the import said: {imported}; GnuPG holds: {}; exported: {}", home.gpg(&["--with-colons", "--list-keys"]), String::from_utf8_lossy(&exported)));
865 assert_eq!(check.addresses, vec!["me@example.org".to_string()]);
866 assert_eq!(gnupg.export("0123456789ABCDEF0123456789ABCDEF01234567"), Err(GnupgProblem::NoSuchKey));
868 assert_eq!(gnupg.export("me; rm -rf ~"), Err(GnupgProblem::NoSuchKey));
869 assert!(!gnupg.holds_secret(&fingerprint));
871 assert_eq!(gnupg.renew(&fingerprint, "2y", &|_| {}), Err(GnupgProblem::NoSuchKey));
872 home.gpg(&["--quick-gen-key", "Test Key <test@example.org>", "ed25519", "cert,sign", "1y"]);
874 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();
875 home.gpg(&["--quick-add-key", &own, "cv25519", "encr", "1y"]);
876 assert!(gnupg.holds_secret(&own));
877 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
878 assert!(expiries(&home, &own).iter().all(|at| *at < now + 400 * 86_400), "a year first");
879 let steps = std::sync::Mutex::new(Vec::new());
882 let looks = std::sync::Mutex::new(Vec::new());
883 gnupg
884 .renew(&own, "2y", &|step| {
885 steps.lock().unwrap().push(step);
886 looks.lock().unwrap().push(may_look());
887 })
888 .unwrap();
889 assert_eq!(*steps.lock().unwrap(), [GnupgStep::Card, GnupgStep::Card], "one for the key, one for its subkeys");
890 assert!(looks.lock().unwrap().iter().all(|may| !may), "no look at the keys while GnuPG works with one");
891 assert!(may_look(), "and again once it is done");
892 let renewed = expiries(&home, &own);
893 assert_eq!(renewed.len(), 2);
894 assert!(renewed.iter().all(|at| *at > now + 700 * 86_400 && *at < now + 740 * 86_400), "{renewed:?}");
895 assert_eq!(Gnupg::with_home(&home.dir.join("no-gpg"), &home.dir).export(&own), Err(GnupgProblem::Missing));
897 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()]);
899 assert_eq!(renew_commands("ABCD", &[]), ["gpg --quick-set-expire ABCD 2y".to_string()]);
900 assert_eq!(export_command("ABCD"), "gpg --export --armor ABCD > key.asc");
901 }
902
903 #[test]
913 fn subkeys_expired_are_said_and_renewed_one_by_one() {
914 let _alone = ON_CARD_TESTS.lock().unwrap_or_else(|e| e.into_inner());
915 let Some(home) = ThrowawayHome::new("sub") else { return };
916 let past = "20220101T000000";
917 home.gpg(&["--faked-system-time", past, "--quick-gen-key", "Old Key <old@example.org>", "ed25519", "cert,sign", "1y"]);
918 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();
919 home.gpg(&["--faked-system-time", "20220101T000100", "--quick-add-key", &primary, "cv25519", "encr", "1y"]);
923 home.gpg(&["--faked-system-time", "20220101T000200", "--quick-add-key", &primary, "ed25519", "auth", "1y"]);
924 home.gpg(&["--quick-set-expire", &primary, "2y"]);
926 home.gpg(&["--quick-set-expire", &primary, "2y", "*"]);
927 let gnupg = home.gnupg();
928 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
929 let exported = gnupg.export(&primary).unwrap();
930 let parts = securitykey::parts_in(&exported);
931 let roles: Vec<(securitykey::Role, bool)> = parts.iter().map(|p| (p.role, p.expires.is_some_and(|at| at <= now))).collect();
932 assert_eq!(roles, [(securitykey::Role::Primary, false), (securitykey::Role::Encryption, true), (securitykey::Role::Authentication, true)], "GnuPG exported: {}", String::from_utf8_lossy(&exported));
933 let tr = Translator::new("en");
934 let said = securitykey::expired_parts(&tr, &parts, now);
935 assert_eq!(said.len(), 2, "{said:?}");
936 let encryption = securitykey::short_fingerprint(&parts[1].fingerprint);
937 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]);
938 assert!(said[1].starts_with("Its authentication subkey ("), "{}", said[1]);
939 let mut listed = gnupg.subkeys(&primary);
941 listed.sort();
942 let mut expected = vec![parts[1].fingerprint.clone(), parts[2].fingerprint.clone()];
943 expected.sort();
944 assert_eq!(listed, expected);
945 gnupg.renew(&primary, "2y", &|_| {}).unwrap();
946 let renewed = securitykey::parts_in(&gnupg.export(&primary).unwrap());
947 assert!(renewed.iter().all(|p| p.expires.is_some_and(|at| at > now + 700 * 86_400)), "{renewed:?}");
948 assert!(securitykey::expired_parts(&tr, &renewed, now).is_empty());
949 let until = securitykey::parts_until(&tr, &renewed);
950 assert!(until.starts_with("primary key until ") && until.contains("encryption subkey until ") && until.contains("authentication subkey until "), "{until}");
951 }
952
953 #[cfg(unix)]
957 #[test]
958 fn renewing_ends_with_the_key_let_go_once() {
959 let _alone = ON_CARD_TESTS.lock().unwrap_or_else(|e| e.into_inner());
960 let dir = std::env::temp_dir().join(format!("sioul-gpg-standin-{}", std::process::id()));
961 let _ = std::fs::remove_dir_all(&dir);
962 std::fs::create_dir_all(&dir).unwrap();
963 let primary = "0123456789ABCDEF0123456789ABCDEF01234567";
964 let sub = "89ABCDEF0123456789ABCDEF0123456789ABCDEF";
965 let asked = dir.join("asked");
966 let script = |name: &str, body: String| {
967 let path = dir.join(name);
968 executable(&path, &body);
969 path
970 };
971 let fail = dir.join("fail");
972 let gpg = script(
973 "gpg",
974 format!(
975 "#!/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",
976 asked = asked.display(),
977 fail = fail.display()
978 ),
979 );
980 script("gpgconf", format!("#!/bin/sh\necho \"gpgconf $*\" >> '{}'\n", asked.display()));
981 let home = dir.join("home");
982 let gnupg = Gnupg::with_home(&gpg, &home);
983 gnupg.renew(primary, "2y", &|_| {}).unwrap();
984 let lines: Vec<String> = std::fs::read_to_string(&asked).unwrap().lines().map(str::to_string).collect();
985 let homedir = format!("--homedir {}", home.display());
986 assert_eq!(
987 lines,
988 [
989 format!("gpg {homedir} --batch --no-tty --status-fd 2 --quick-set-expire {primary} 2y"),
990 format!("gpg {homedir} --batch --no-tty --status-fd 2 --quick-set-expire {primary} 2y {sub}"),
991 format!("gpgconf {homedir} --kill scdaemon"),
992 ],
993 "the key and its expired subkey, each named; then the key let go, once"
994 );
995 std::fs::write(&asked, "").unwrap();
997 std::fs::write(&fail, "").unwrap();
998 assert_eq!(gnupg.renew(primary, "2y", &|_| {}), Err(GnupgProblem::TouchMissed));
999 let lines = std::fs::read_to_string(&asked).unwrap();
1000 assert_eq!(lines.lines().filter(|l| l.starts_with("gpgconf")).count(), 1, "{lines}");
1001 std::fs::remove_dir_all(&dir).unwrap();
1002 }
1003
1004 static ON_CARD_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(());
1010
1011 #[test]
1012 fn gnupg_says_where_it_is_and_a_touch_missed() {
1013 let _alone = ON_CARD_TESTS.lock().unwrap_or_else(|e| e.into_inner());
1014 assert_eq!(status_of("[GNUPG:] PINENTRY_LAUNCHED 41235 gnome3 1.3.1 - - - - 1000/1000 0"), Some(Status::Pinentry(41235)));
1015 assert_eq!(status_of("[GNUPG:] FAILURE sign 100663358"), Some(Status::Failed(GPG_ERR_TIMEOUT)), "6 << 24 | 62: the smart card daemon's timeout");
1016 assert_eq!(status_of("[GNUPG:] ERROR keyedit.setexpire 62"), Some(Status::Failed(GPG_ERR_TIMEOUT)));
1017 assert_eq!(status_of("[GNUPG:] FAILURE sign 83918950"), Some(Status::Failed(83918950 & 0xFFFF)));
1018 assert_eq!(status_of("[GNUPG:] KEY_CONSIDERED ABCD 0"), None);
1019 assert_eq!(status_of("gpg: signing failed: Délai d'attente dépassé"), None);
1020 assert!(may_look());
1022 let first = OnCard::start();
1023 let second = OnCard::start();
1024 assert!(!may_look());
1025 drop(first);
1026 assert!(!may_look());
1027 drop(second);
1028 assert!(may_look());
1029 }
1030
1031 fn stand_in_posts(answers: Vec<(u16, &'static str)>) -> (String, std::sync::mpsc::Receiver<(String, String)>) {
1034 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1035 let address = format!("http://{}", listener.local_addr().unwrap());
1036 let (tell, heard) = std::sync::mpsc::channel();
1037 std::thread::spawn(move || {
1038 for (status, body) in answers {
1039 let Ok((stream, _)) = listener.accept() else { return };
1040 let mut reader = BufReader::new(stream.try_clone().unwrap());
1041 let mut line = String::new();
1042 reader.read_line(&mut line).unwrap();
1043 let mut length = 0;
1044 loop {
1045 let mut header = String::new();
1046 reader.read_line(&mut header).unwrap();
1047 if header.trim_end().is_empty() {
1048 break;
1049 }
1050 if let Some((name, value)) = header.split_once(':')
1051 && name.eq_ignore_ascii_case("content-length")
1052 {
1053 length = value.trim().parse().unwrap_or(0);
1054 }
1055 }
1056 let mut sent = vec![0u8; length];
1057 std::io::Read::read_exact(&mut reader, &mut sent).unwrap();
1058 let _ = tell.send((line.split_whitespace().nth(1).unwrap_or("").to_string(), String::from_utf8_lossy(&sent).to_string()));
1059 let mut stream = stream;
1060 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());
1061 }
1062 });
1063 (address, heard)
1064 }
1065
1066 #[test]
1070 fn keys_openpgp_gets_the_certificate_and_mails_each_address() {
1071 let (_, public) = SoftCard::generate("0006:12345678", &["Card Holder <me@example.org>", "Card Holder <old@example.org>"], "123456").unwrap();
1072 let agent: ureq::Agent = ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(5))).http_status_as_error(false).build().into();
1073 let (base, heard) = stand_in_posts(vec![
1074 (200, r#"{"key_fpr":"ABCD","status":{"me@example.org":"unpublished","old@example.org":"published"},"token":"t0k3n"}"#),
1075 (200, r#"{"key_fpr":"ABCD","status":{"me@example.org":"pending","old@example.org":"published"},"token":"t0k3n"}"#),
1076 ]);
1077 let published = send_at(&agent, &base, &public, "fr").unwrap();
1078 assert_eq!(published, Published { fingerprint: "ABCD".into(), mailed: vec!["me@example.org".into()], published: vec!["old@example.org".into()] });
1079 let (path, body) = heard.recv().unwrap();
1080 assert_eq!(path, "/vks/v1/upload");
1081 let sent: serde_json::Value = serde_json::from_str(&body).unwrap();
1082 assert_eq!(sent["keytext"].as_str().unwrap().as_bytes(), &public[..], "the public certificate, as it is");
1083 let (path, body) = heard.recv().unwrap();
1084 assert_eq!(path, "/vks/v1/request-verify");
1085 assert_eq!(serde_json::from_str::<serde_json::Value>(&body).unwrap(), serde_json::json!({ "token": "t0k3n", "addresses": ["me@example.org"], "locale": ["fr"] }));
1086 let (base, heard) = stand_in_posts(vec![(200, r#"{"key_fpr":"ABCD","status":{"me@example.org":"published"},"token":"t"}"#)]);
1088 assert_eq!(send_at(&agent, &base, &public, "en").unwrap().mailed, Vec::<String>::new());
1089 assert_eq!(heard.try_iter().count(), 1);
1090 let (base, _heard) = stand_in_posts(vec![(400, r#"{"error":"Rate limit exceeded"}"#)]);
1092 assert!(matches!(send_at(&agent, &base, &public, "en"), Err(SyncError::Server(said)) if said.contains("Rate limit exceeded")));
1093 let https: ureq::Agent = ureq::Agent::config_builder().https_only(true).timeout_global(Some(Duration::from_secs(5))).build().into();
1095 let (base, _heard) = stand_in_posts(vec![]);
1096 assert!(send_at(&https, &base, &public, "en").is_err());
1097 }
1098}