1use crate::backend::{QtThread, Shared, json, load_config, local_path, offline, say, tell, tr};
20use cxx_qt_lib::QString;
21use sioul_core::compose::Draft;
22use sioul_core::pgp::{self, Keys, Place};
23use sioul_core::securitykey::{self, CardAccess, CardError, CardInfo, KnownCard, NoReaders, Password, PinMemory, Purpose, Readers, Remedy, SoftCard, SoftReaders, Touch};
24use sioul_sync::securitykey::{Gnupg, GnupgProblem, GnupgStep, Looked, Released};
25use sioul_sync::send::BuildError;
26use std::collections::BTreeSet;
27use std::sync::atomic::{AtomicBool, Ordering};
28use std::sync::{Arc, Mutex, OnceLock};
29use std::time::{Duration, Instant};
30
31static PIN: Mutex<PinMemory> = Mutex::new(PinMemory::new());
33static WATCHING: AtomicBool = AtomicBool::new(false);
35static WAITING: Mutex<BTreeSet<String>> = Mutex::new(BTreeSet::new());
37static SETUP: Mutex<Option<CardInfo>> = Mutex::new(None);
39static WINDOW: Mutex<Option<QtThread>> = Mutex::new(None);
41
42const LOOK_EVERY: Duration = Duration::from_millis(1500);
44const WAIT_AT_MOST: Duration = Duration::from_secs(5 * 60);
46const SETUP_BAND: &str = "setup";
48
49struct Demo {
52 readers: SoftReaders,
53 certificate: Vec<u8>,
54}
55
56fn demo() -> Option<&'static Demo> {
57 static DEMO: OnceLock<Option<Demo>> = OnceLock::new();
58 DEMO.get_or_init(|| {
59 let config = load_config();
60 let accounts: Vec<_> = config.accounts.iter().filter(|a| a.address.is_some()).collect();
61 let mut userids: Vec<String> = Vec::new();
63 for account in &accounts {
64 let address = account.address.clone().unwrap_or_default();
65 if userids.iter().any(|u| u.ends_with(&format!("<{address}>"))) {
66 continue;
67 }
68 let name = accounts.iter().filter(|a| a.address.as_deref() == Some(address.as_str())).find_map(|a| a.name.clone()).unwrap_or_default();
69 userids.push(format!("{name} <{address}>").trim().to_string());
70 }
71 let userids: Vec<&str> = userids.iter().map(String::as_str).collect();
72 let (mut card, certificate) = SoftCard::generate("0006:01234567", &userids, "123456").ok()?;
73 card.info.holder = accounts.first().and_then(|a| a.name.clone()).unwrap_or_default();
74 if let Some(slot) = card.info.sign.as_mut() {
75 slot.touch = Touch::On;
76 }
77 card.touch_delay = Duration::from_secs(4);
78 Some(Demo { readers: SoftReaders::with(vec![card]), certificate })
79 })
80 .as_ref()
81}
82
83fn readers() -> &'static dyn Readers {
85 static NONE: NoReaders = NoReaders;
86 if offline() {
87 return demo().map_or(&NONE as &dyn Readers, |d| &d.readers as &dyn Readers);
88 }
89 sioul_sync::securitykey::readers()
90}
91
92#[derive(serde::Serialize, Default)]
94struct Band {
95 state: &'static str,
97 line: String,
99 tries: String,
101 warm: bool,
103 action: &'static str,
106 fingerprint: String,
108 ident: String,
110 commands: Vec<String>,
112}
113
114fn tell_band(qt: &QtThread, context: &str, band: Band) {
115 let (context, state) = (context.to_string(), json(&band));
116 let _ = qt.queue(move |mut sioul| sioul.as_mut().security_key_changed(QString::from(&context), QString::from(&state)));
117}
118
119fn working(qt: &QtThread, context: &str, id: &str) {
120 tell_band(qt, context, Band { state: "working", line: tr().text(id, None), ..Band::default() });
121}
122
123fn problem_line(qt: &QtThread, context: &str, line: String, action: &'static str) {
124 tell_band(qt, context, Band { state: "problem", line, warm: true, action, ..Band::default() });
125}
126
127pub(crate) fn for_draft(id: &str) -> String {
131 let Some(draft) = Draft::by_id(id).filter(|d| d.sign) else { return String::new() };
132 let Some(address) = load_config().account(&draft.account).and_then(|a| a.address.clone()) else { return String::new() };
133 let keys = Keys::load();
134 match keys.own_for(&address) {
135 Some(pgp::Own { place: Place::Card { ident }, .. }) => {
136 let label = keys.cards.iter().find(|c| c.ident == ident).map(|c| c.label(tr())).unwrap_or_default();
137 json(&serde_json::json!({ "ident": ident, "label": label }))
138 }
139 Some(_) => String::new(),
140 None => expired_line(&keys, &address).map(|line| json(&serde_json::json!({ "expired": line }))).unwrap_or_default(),
141 }
142}
143
144pub(crate) fn expired_line(keys: &Keys, address: &str) -> Option<String> {
147 let (known, cert) = keys.card_for(address)?;
148 let expires = securitykey::expiry_of(cert, &[&known.sign, &known.decrypt]);
149 securitykey::expiry_line(tr(), expires, &cert.fingerprint().to_hex(), now_unix()).filter(|(_, warm)| *warm).map(|(line, _)| line)
150}
151
152fn now_unix() -> i64 {
153 jiff::Timestamp::now().as_second()
154}
155
156fn pin_for(ident: &str, typed: Option<&Password>, purpose: Purpose) -> Result<(CardInfo, Option<Password>), CardError> {
160 let info = securitykey::cards(readers())?.into_iter().find(|c| c.ident.eq_ignore_ascii_case(ident)).ok_or(CardError::Absent)?;
161 if info.pin_tries == 0 {
162 return Err(CardError::Blocked);
163 }
164 if let Some(typed) = typed {
165 return Ok((info, Some(typed.clone())));
166 }
167 if purpose == Purpose::Sign && info.pin_each_signature {
168 return Ok((info, None));
169 }
170 let held = PIN.lock().ok().and_then(|mut memory| memory.get(ident, Instant::now()));
171 Ok((info, held))
172}
173
174fn ask_pin(qt: &QtThread, context: &str, info: &CardInfo, purpose: Purpose) {
176 let known = securitykey::known_cards().into_iter().find(|c| c.ident.eq_ignore_ascii_case(&info.ident)).unwrap_or_default();
177 let tries = known.tries_line(info.pin_tries, tr()).unwrap_or_default();
178 let line = if purpose == Purpose::Sign && info.pin_each_signature { "seckey-pin-each" } else { "seckey-pin" };
179 tell_band(qt, context, Band { state: "pin", line: tr().text(line, None), warm: !tries.is_empty(), tries, ..Band::default() });
180}
181
182fn keys_changed() {
184 if let Some(qt) = WINDOW.lock().ok().and_then(|w| w.clone()) {
185 let _ = qt.queue(|mut sioul| sioul.as_mut().keys_changed());
186 }
187}
188
189fn remember(ident: &str, pin: Password, reader: Option<String>, info: &CardInfo) {
191 if let Ok(mut memory) = PIN.lock() {
192 memory.keep(ident, pin, &reader.unwrap_or_else(|| info.reader.clone()), Instant::now());
193 }
194 keys_changed();
195 let mut known = securitykey::known_cards().into_iter().find(|c| c.ident.eq_ignore_ascii_case(ident));
196 if let Some(known) = known.as_mut() {
197 let before = known.clone();
198 known.update(info);
199 if *known != before {
200 let updated = known.clone();
201 let _ = securitykey::change_known(|cards| {
202 if let Some(card) = cards.iter_mut().find(|c| c.ident == updated.ident) {
203 *card = updated;
204 }
205 });
206 }
207 }
208 watch_pin();
209}
210
211fn watch_pin() {
214 if WATCHING.swap(true, Ordering::Relaxed) {
215 return;
216 }
217 std::thread::spawn(|| {
218 loop {
219 std::thread::sleep(LOOK_EVERY);
220 if !sioul_sync::securitykey::may_look() {
222 continue;
223 }
224 let present = readers().present();
225 let Ok(mut memory) = PIN.lock() else { break };
226 memory.expire(Instant::now());
227 if let Ok(present) = present {
228 memory.went_away(&present);
229 }
230 if memory.holder().is_none() {
231 break;
232 }
233 }
234 WATCHING.store(false, Ordering::Relaxed);
235 keys_changed();
236 });
237}
238
239pub(crate) fn forget_pin() -> String {
241 if let Ok(mut memory) = PIN.lock() {
242 memory.forget();
243 }
244 tr().text("seckey-pin-forgotten", None)
245}
246
247pub(crate) fn closing() {
249 if let Ok(mut memory) = PIN.lock() {
250 memory.forget();
251 }
252 pgp::SessionKeys::global().forget();
253}
254
255fn keep_taken(ident: &str, pin: &Password, access: &CardAccess, info: &CardInfo) {
258 if access.pin_accepted() {
259 remember(ident, pin.clone(), access.reader(), info);
260 }
261}
262
263fn failed(qt: &QtThread, context: &str, error: CardError, purpose: Purpose, typed: bool) {
266 if matches!(error, CardError::WrongPin { .. } | CardError::Blocked) && !typed {
267 if let Ok(mut memory) = PIN.lock() {
269 memory.forget();
270 }
271 }
272 let line = error.sentence(tr(), purpose);
273 match error.remedy() {
274 Remedy::Pin => tell_band(qt, context, Band { state: "pin", line, warm: true, ..Band::default() }),
275 Remedy::Again => problem_line(qt, context, line, "retry"),
276 Remedy::Release => problem_line(qt, context, line, "release"),
277 Remedy::None => problem_line(qt, context, line, ""),
278 }
279}
280
281fn know_window(qt: &QtThread) {
283 if let Ok(mut window) = WINDOW.lock() {
284 *window = Some(qt.clone());
285 }
286}
287
288pub(crate) fn sign_and_send(qt: &QtThread, shared: &Arc<Shared>, id: &str, typed: String) {
291 know_window(qt);
292 let typed = (!typed.is_empty()).then(|| Password::from(typed));
293 let (qt, shared, id) = (qt.clone(), Arc::clone(shared), id.to_string());
294 std::thread::spawn(move || sign_now(&qt, &shared, &id, typed));
295}
296
297fn sign_now(qt: &QtThread, shared: &Arc<Shared>, id: &str, typed: Option<Password>) {
298 working(qt, id, "seckey-reading");
299 let Some(draft) = Draft::by_id(id) else { return problem_line(qt, id, tr().text("mail-draft-gone", None), "") };
300 let config = load_config();
301 let Some(account) = config.account(&draft.account).filter(|a| a.syncs()).cloned() else {
302 return problem_line(qt, id, say("account-unknown", &[("id", draft.account.clone())]), "");
303 };
304 let keys = Keys::load();
305 let ident = match keys.own_for(account.address.as_deref().unwrap_or_default()) {
306 Some(pgp::Own { place: Place::Card { ident }, .. }) => ident,
307 _ => {
309 match crate::mail::send(qt, shared, id) {
310 Some(problem) => problem_line(qt, id, problem, ""),
311 None => tell_band(qt, id, Band { state: "done", ..Band::default() }),
312 }
313 return;
314 }
315 };
316 let (info, pin) = match pin_for(&ident, typed.as_ref(), Purpose::Sign) {
317 Ok(found) => found,
318 Err(CardError::Absent) => return wait_for(qt, shared, id, &ident, Purpose::Sign, typed),
319 Err(error) => return failed(qt, id, error, Purpose::Sign, typed.is_some()),
320 };
321 let Some(pin) = pin else { return ask_pin(qt, id, &info, Purpose::Sign) };
322 working(qt, id, "seckey-signing");
323 let (touch_qt, context, line) = (qt.clone(), id.to_string(), touch_line(info.sign.as_ref()));
325 let touch = move || tell_band(&touch_qt, &context, Band { state: "touch", line: line.clone(), ..Band::default() });
326 let access = CardAccess::new(readers(), &pin, &touch);
327 match sioul_sync::send::build_draft(&account, &draft, tr(), Some(&access)) {
328 Ok(outgoing) => {
329 remember(&ident, pin.clone(), access.reader(), &info);
330 crate::mail::send_signed(qt, shared, id, account, outgoing);
331 tell_band(qt, id, Band { state: "done", ..Band::default() });
332 }
333 Err(BuildError::Card(CardError::Absent)) => wait_for(qt, shared, id, &ident, Purpose::Sign, typed),
334 Err(BuildError::Card(error)) => {
335 keep_taken(&ident, &pin, &access, &info);
336 failed(qt, id, error, Purpose::Sign, typed.is_some());
337 }
338 Err(other) => problem_line(qt, id, other.into_sync(tr()).sentence(tr(), &account.id).replace('`', "'"), ""),
340 }
341}
342
343fn touch_line(slot: Option<&securitykey::SlotInfo>) -> String {
347 slot.map_or(Touch::Unknown, |s| s.touch).words(tr()).unwrap_or_else(|| tr().text("seckey-touch-maybe", None))
348}
349
350pub(crate) fn open_message(qt: &QtThread, shared: &Arc<Shared>, key: &str, typed: String) {
354 know_window(qt);
355 let typed = (!typed.is_empty()).then(|| Password::from(typed));
356 let (qt, shared, key) = (qt.clone(), Arc::clone(shared), key.to_string());
357 std::thread::spawn(move || open_now(&qt, &shared, &key, typed));
358}
359
360fn open_now(qt: &QtThread, shared: &Arc<Shared>, key: &str, typed: Option<Password>) {
361 working(qt, key, "seckey-reading");
362 let Some(raw) = crate::mail::locate(key).and_then(|(_, file)| std::fs::read(file).ok()) else {
363 return problem_line(qt, key, tr().text("mail-message-gone", None), "");
364 };
365 let ident = match crate::crypto::open(&raw) {
367 Some((Some(_), _)) | None => return tell_band(qt, key, Band { state: "done", ..Band::default() }),
368 Some((None, view)) => match view.security_key {
369 Some(ident) => ident,
370 None => return problem_line(qt, key, tr().text("pgp-not-opened", None), ""),
371 },
372 };
373 let (info, pin) = match pin_for(&ident, typed.as_ref(), Purpose::Open) {
374 Ok(found) => found,
375 Err(CardError::Absent) => return wait_for(qt, shared, key, &ident, Purpose::Open, typed),
376 Err(error) => return failed(qt, key, error, Purpose::Open, typed.is_some()),
377 };
378 let Some(pin) = pin else { return ask_pin(qt, key, &info, Purpose::Open) };
379 working(qt, key, "seckey-opening");
380 let (touch_qt, context, line) = (qt.clone(), key.to_string(), touch_line(info.decrypt.as_ref()));
381 let touch = move || tell_band(&touch_qt, &context, Band { state: "touch", line: line.clone(), ..Band::default() });
382 let access = CardAccess::new(readers(), &pin, &touch);
383 let passphrase = |fingerprint: &pgp::Fingerprint| sioul_sync::secret::pgp_passphrase(&fingerprint.to_hex());
384 let unlock = pgp::Unlock::new(&passphrase, pgp::SessionKeys::global()).with_card(&access);
385 match pgp::open(&raw, &Keys::load(), &unlock) {
386 Some((Some(_), _)) => {
387 remember(&ident, pin.clone(), access.reader(), &info);
388 tell_band(qt, key, Band { state: "done", ..Band::default() });
389 }
390 _ => match access.failure() {
391 Some(CardError::Absent) => wait_for(qt, shared, key, &ident, Purpose::Open, typed),
392 Some(error) => {
393 keep_taken(&ident, &pin, &access, &info);
394 failed(qt, key, error, Purpose::Open, typed.is_some());
395 }
396 None => problem_line(qt, key, tr().text("pgp-not-opened", None), ""),
397 },
398 }
399}
400
401fn wait_for(qt: &QtThread, shared: &Arc<Shared>, context: &str, ident: &str, purpose: Purpose, typed: Option<Password>) {
404 if let Ok(mut waiting) = WAITING.lock() {
405 waiting.insert(context.to_string());
406 }
407 tell_band(qt, context, Band { state: "plug", line: CardError::Absent.sentence(tr(), purpose), ..Band::default() });
408 let (qt, shared, context, ident) = (qt.clone(), Arc::clone(shared), context.to_string(), ident.to_string());
409 std::thread::spawn(move || {
410 let since = Instant::now();
411 let mut seen: Option<Vec<String>> = None;
412 loop {
413 std::thread::sleep(LOOK_EVERY);
414 if !WAITING.lock().is_ok_and(|w| w.contains(&context)) {
415 return;
416 }
417 if since.elapsed() > WAIT_AT_MOST {
418 if let Ok(mut waiting) = WAITING.lock() {
419 waiting.remove(&context);
420 }
421 return problem_line(&qt, &context, CardError::Absent.sentence(tr(), purpose), "retry");
422 }
423 if !sioul_sync::securitykey::may_look() {
425 continue;
426 }
427 let present = readers().present().ok();
429 if present == seen {
430 continue;
431 }
432 seen = present;
433 match key_in(readers(), &ident) {
434 Ok(_) => {
435 if let Ok(mut waiting) = WAITING.lock() {
436 waiting.remove(&context);
437 }
438 return match purpose {
439 Purpose::Sign => sign_now(&qt, &shared, &context, typed),
440 _ => open_now(&qt, &shared, &context, typed),
441 };
442 }
443 Err(CardError::Busy) => {
445 if let Ok(mut waiting) = WAITING.lock() {
446 waiting.remove(&context);
447 }
448 return problem_line(&qt, &context, CardError::Busy.sentence(tr(), purpose), "release");
449 }
450 Err(_) => {}
451 }
452 }
453 });
454}
455
456pub(crate) fn not_now(context: &str) {
458 if let Ok(mut waiting) = WAITING.lock() {
459 waiting.remove(context);
460 }
461}
462
463pub(crate) fn release() -> String {
466 let released = if offline() { Released::Done } else { sioul_sync::securitykey::release_gnupg() };
467 json(&serde_json::json!({ "line": released.sentence(tr()), "done": released == Released::Done }))
468}
469
470fn holder_name(written: &str) -> String {
473 match written.split_once("<<") {
474 Some((surname, given)) => format!("{} {}", given.replace('<', " ").trim(), surname.replace('<', " ").trim()).trim().to_string(),
475 None => written.replace('<', " ").trim().to_string(),
476 }
477}
478
479fn describe(info: &CardInfo) -> String {
481 let holder = holder_name(&info.holder);
482 let holder = if holder.is_empty() { String::new() } else { format!(", {holder}") };
483 let made = |slot: &securitykey::SlotInfo| match slot.created {
484 0 => slot.algorithm.clone(),
485 at => say("seckey-made-on", &[("algorithm", slot.algorithm.clone()), ("date", securitykey::full_date(tr(), i64::from(at)))]),
486 };
487 let (sign, decrypt) = (info.sign.as_ref().map(made), info.decrypt.as_ref().map(made));
488 let id = match (&sign, &decrypt) {
489 (Some(_), Some(_)) => "seckey-found-both",
490 (Some(_), None) => "seckey-found-sign",
491 _ => "seckey-found-decrypt",
492 };
493 say(id, &[("label", info.label(tr())), ("holder", holder), ("sign", sign.unwrap_or_default()), ("decrypt", decrypt.unwrap_or_default())])
494}
495
496pub(crate) fn read_for_setup(qt: &QtThread) {
498 let qt = qt.clone();
499 std::thread::spawn(move || {
500 working(&qt, SETUP_BAND, "seckey-reading");
501 match securitykey::cards(readers()) {
502 Ok(found) => {
503 let known = securitykey::known_cards();
504 let Some(info) = found.iter().find(|c| !known.iter().any(|k| k.ident == c.ident)).or(found.first()).cloned() else { return };
506 let line = describe(&info);
507 let fingerprint = fingerprint_of(&info);
508 let commands = vec![sioul_sync::securitykey::export_command(&fingerprint)];
509 if let Ok(mut setup) = SETUP.lock() {
510 *setup = Some(info);
511 }
512 tell_band(&qt, SETUP_BAND, Band { state: "found", line, fingerprint, commands, ..Band::default() });
513 }
514 Err(error) => {
515 let action = if error.remedy() == Remedy::Release { "release" } else { "retry" };
516 problem_line(&qt, SETUP_BAND, error.sentence(tr(), Purpose::Read), action);
517 }
518 }
519 });
520}
521
522fn fingerprint_of(info: &CardInfo) -> String {
524 info.sign.as_ref().or(info.decrypt.as_ref()).map(|s| s.fingerprint.clone()).unwrap_or_default()
525}
526
527fn card_for_lookup(ident: &str) -> Result<CardInfo, CardError> {
530 let read = SETUP.lock().ok().and_then(|s| s.clone()).filter(|c| ident.is_empty() || c.ident.eq_ignore_ascii_case(ident));
531 let known = || securitykey::known_cards().into_iter().find(|c| c.ident.eq_ignore_ascii_case(ident)).map(|known| as_info(&known));
532 match read.or_else(known) {
533 Some(info) => Ok(info),
534 None => key_in(readers(), ident),
536 }
537}
538
539fn key_in(readers: &dyn Readers, ident: &str) -> Result<CardInfo, CardError> {
541 securitykey::cards(readers)?.into_iter().find(|c| ident.is_empty() || c.ident.eq_ignore_ascii_case(ident)).ok_or(CardError::Absent)
542}
543
544fn setup_problem(error: &CardError, words: &sioul_core::i18n::Translator) -> Band {
548 let action = match error.remedy() {
549 Remedy::Release => "release",
550 _ => "retry",
551 };
552 Band { state: "problem", line: error.sentence(words, Purpose::Read), warm: true, action, ..Band::default() }
553}
554
555fn as_info(known: &KnownCard) -> CardInfo {
557 let slot = |fingerprint: &str, touch: Touch| (!fingerprint.is_empty()).then(|| securitykey::SlotInfo { fingerprint: fingerprint.to_string(), created: 0, algorithm: String::new(), touch, public: None });
558 CardInfo {
559 ident: known.ident.clone(),
560 manufacturer: known.manufacturer,
561 serial: known.serial,
562 holder: known.holder.clone(),
563 url: String::new(),
564 sign: slot(&known.sign, known.touch_sign),
565 decrypt: slot(&known.decrypt, known.touch_decrypt),
566 pin_tries: known.pin_tries_full,
567 reset_tries: 0,
568 admin_tries: 0,
569 pin_each_signature: known.pin_each_signature,
570 signatures: 0,
571 reader: String::new(),
572 }
573}
574
575fn your_addresses() -> Vec<String> {
577 load_config().accounts.iter().filter(|a| a.syncs()).filter_map(|a| a.address.clone()).map(|a| a.to_ascii_lowercase()).collect()
578}
579
580pub(crate) fn look_for_certificate(qt: &QtThread, ident: &str) {
583 let (qt, ident) = (qt.clone(), ident.to_string());
584 std::thread::spawn(move || {
585 let info = match card_for_lookup(&ident) {
586 Ok(info) => info,
587 Err(error) => return tell_band(&qt, SETUP_BAND, setup_problem(&error, tr())),
588 };
589 working(&qt, SETUP_BAND, "seckey-looking");
590 let looked = if offline() {
591 match demo().map(|d| securitykey::certificate_in(&d.certificate, &info)) {
593 Some(Ok((cert, check))) => Ok(Looked::Found { cert, check, source: "https://keys.openpgp.org/".into() }),
594 _ => Ok(Looked::NotFound),
595 }
596 } else {
597 sioul_sync::securitykey::find_certificate(&info, &your_addresses())
598 };
599 match looked {
600 Ok(Looked::Found { cert, check, source }) => adopt(&qt, &info, &cert, &check, &source),
601 Ok(Looked::Mismatch(why)) => problem_line(&qt, SETUP_BAND, why.sentence(tr()), ""),
602 Ok(Looked::NotFound) => {
603 let fingerprint = fingerprint_of(&info);
604 let commands = vec![sioul_sync::securitykey::export_command(&fingerprint)];
605 tell_band(&qt, SETUP_BAND, Band { state: "missing", line: tr().text("seckey-not-found", None), fingerprint, commands, ..Band::default() })
606 }
607 Err(error) => problem_line(&qt, SETUP_BAND, error.sentence(tr(), ""), "lookup"),
608 }
609 });
610}
611
612pub(crate) fn import_certificate(qt: &QtThread, ident: &str, url: &str) {
614 let info = match card_for_lookup(ident) {
615 Ok(info) => info,
616 Err(error) => return tell_band(qt, SETUP_BAND, setup_problem(&error, tr())),
617 };
618 let path = local_path(url);
619 match std::fs::read(&path) {
620 Ok(bytes) => match securitykey::certificate_in(&bytes, &info) {
621 Ok((cert, check)) => adopt(qt, &info, &cert, &check, "file"),
622 Err(why) => problem_line(qt, SETUP_BAND, why.sentence(tr()), ""),
623 },
624 Err(e) => problem_line(qt, SETUP_BAND, format!("{}: {e}", path.display()), ""),
625 }
626}
627
628fn adopt(qt: &QtThread, info: &CardInfo, cert: &securitykey::Certificate, check: &securitykey::CertCheck, source: &str) {
631 match keep(info, cert, check, source) {
632 Ok(kept) => {
633 let (action, commands) = if kept.expired && renewable() { ("renew-offer", kept.commands) } else if kept.expired { ("", kept.commands) } else { ("", Vec::new()) };
635 tell_band(qt, SETUP_BAND, Band { state: "done", line: kept.line, warm: kept.warm, action, ident: info.ident.clone(), commands, ..Band::default() });
636 let _ = qt.queue(|mut sioul| sioul.as_mut().keys_changed());
637 }
638 Err(e) => problem_line(qt, SETUP_BAND, e, ""),
639 }
640}
641
642struct Kept {
644 line: String,
645 warm: bool,
646 expired: bool,
648 commands: Vec<String>,
650}
651
652
653fn keep(info: &CardInfo, cert: &securitykey::Certificate, check: &securitykey::CertCheck, source: &str) -> Result<Kept, String> {
656 let yours = your_addresses();
657 let named: Vec<String> = check.addresses.iter().filter(|a| yours.contains(a)).cloned().collect();
658 let addresses = if check.addresses.is_empty() { yours } else { named };
661 securitykey::adopt(info, cert, source, addresses.clone(), now_unix())?;
662 let names = cert.userids().map(|u| String::from_utf8_lossy(u.userid().value()).to_string()).collect::<Vec<_>>().join(", ");
663 let mut line = if check.addresses.is_empty() {
664 tr().text("seckey-kept-no-address", None)
665 } else if addresses.is_empty() {
666 say("seckey-kept-not-yours", &[("names", names)])
667 } else {
668 say("seckey-kept", &[("names", names), ("addresses", addresses.join(", "))])
669 };
670 let mut warm = addresses.is_empty();
671 let (expiry, late, expired) = securitykey::expiry_words(tr(), cert, check.expires, now_unix());
672 if !expiry.is_empty() {
673 line = format!("{line} {expiry}");
674 warm |= late;
675 }
676 if let Ok(mut setup) = SETUP.lock() {
677 *setup = None;
678 }
679 Ok(Kept { line, warm, expired, commands: sioul_sync::securitykey::renew_commands(&check.cert, &securitykey::subkeys_of(cert)) })
680}
681
682fn gnupg_sentence(problem: &GnupgProblem, fingerprint: &str) -> String {
684 match problem {
685 GnupgProblem::Missing => tr().text("seckey-gnupg-missing", None),
686 GnupgProblem::Sandboxed => tr().text("seckey-gnupg-sandboxed", None),
687 GnupgProblem::NoSuchKey => say("seckey-gnupg-no-key", &[("fingerprint", fingerprint.to_string())]),
688 GnupgProblem::TouchMissed => tr().text("seckey-gnupg-touch-missed", None),
689 GnupgProblem::Failed(detail) => say("seckey-gnupg-failed", &[("detail", detail.replace('`', "'"))]),
691 }
692}
693
694fn gnupg() -> Result<Gnupg, GnupgProblem> {
696 if offline() { Err(GnupgProblem::Missing) } else { Gnupg::find() }
697}
698
699pub(crate) fn import_from_gnupg(qt: &QtThread, ident: &str) {
703 let (qt, ident) = (qt.clone(), ident.to_string());
704 std::thread::spawn(move || {
705 let info = match card_for_lookup(&ident) {
706 Ok(info) => info,
707 Err(error) => return tell_band(&qt, SETUP_BAND, setup_problem(&error, tr())),
708 };
709 working(&qt, SETUP_BAND, "seckey-gnupg-reading");
710 let fingerprint = fingerprint_of(&info);
711 let exported = match demo().filter(|_| offline()) {
712 Some(demo) => Ok(demo.certificate.clone()),
713 None => gnupg().and_then(|g| g.export(&fingerprint)),
714 };
715 match exported {
716 Ok(bytes) => match securitykey::certificate_in(&bytes, &info) {
717 Ok((cert, check)) => adopt(&qt, &info, &cert, &check, "gnupg"),
718 Err(why) => problem_line(&qt, SETUP_BAND, why.sentence(tr()), ""),
719 },
720 Err(problem) => {
721 let commands = vec![sioul_sync::securitykey::export_command(&fingerprint)];
722 tell_band(&qt, SETUP_BAND, Band { state: "problem", line: gnupg_sentence(&problem, &fingerprint), warm: true, fingerprint, commands, ..Band::default() });
723 }
724 }
725 });
726}
727
728fn renewable() -> bool {
732 gnupg().is_ok()
733}
734
735pub(crate) fn renew(qt: &QtThread, ident: &str) {
739 let (qt, ident) = (qt.clone(), ident.to_string());
740 std::thread::spawn(move || {
741 let Some(known) = securitykey::known_cards().into_iter().find(|c| c.ident.eq_ignore_ascii_case(&ident)) else { return problem_line(&qt, SETUP_BAND, CardError::Absent.sentence(tr(), Purpose::Read), "") };
742 let info = as_info(&known);
743 let subkeys = Keys::load().cert(&known.cert).map(securitykey::subkeys_of).unwrap_or_default();
744 let commands: Vec<String> = sioul_sync::securitykey::renew_commands(&known.cert, &subkeys);
745 let touch = known.touch_sign;
747 tell_band(&qt, SETUP_BAND, Band { state: "working", line: tr().text("seckey-renewing", None), ident: ident.clone(), ..Band::default() });
748 let watch = |step: GnupgStep| tell_band(&qt, SETUP_BAND, Band { state: "working", line: renew_line(step, touch, tr()), ident: ident.clone(), ..Band::default() });
749 let renewed = gnupg().and_then(|g| {
750 g.renew(&known.cert, "2y", &watch)?;
751 g.export(&known.cert)
752 });
753 let failed = |line: String, action: &'static str| tell_band(&qt, SETUP_BAND, Band { state: "problem", line, warm: true, action, ident: ident.clone(), commands: commands.clone(), ..Band::default() });
754 match renewed {
755 Ok(bytes) => match securitykey::certificate_in(&bytes, &info) {
756 Ok((cert, _)) if !securitykey::expired_parts(tr(), &securitykey::parts(&cert), now_unix()).is_empty() => {
758 let still = securitykey::expired_parts(tr(), &securitykey::parts(&cert), now_unix()).join(" ");
759 failed(say("seckey-renew-incomplete", &[("parts", still)]), "renew");
760 }
761 Ok((cert, check)) => match keep(&info, &cert, &check, "gnupg") {
762 Ok(_) => {
763 let parts = securitykey::parts_until(tr(), &securitykey::parts(&cert));
764 tell_band(&qt, SETUP_BAND, Band { state: "done", line: say("seckey-renewed", &[("parts", parts)]), action: "publish", ident: ident.clone(), ..Band::default() });
765 let _ = qt.queue(|mut sioul| sioul.as_mut().keys_changed());
766 }
767 Err(e) => failed(e, ""),
768 },
769 Err(why) => failed(why.sentence(tr()), ""),
770 },
771 Err(GnupgProblem::NoSuchKey) => failed(tr().text("seckey-renew-unknown", None), ""),
773 Err(GnupgProblem::TouchMissed) => failed(gnupg_sentence(&GnupgProblem::TouchMissed, &known.cert), "renew"),
775 Err(problem) => failed(gnupg_sentence(&problem, &known.cert), ""),
776 }
777 });
778}
779
780fn renew_line(step: GnupgStep, touch: Touch, tr: &sioul_core::i18n::Translator) -> String {
785 match (step, touch.words(tr)) {
786 (GnupgStep::Pin, Some(_)) => format!("{} {}", tr.text("seckey-gnupg-pin", None), tr.text("seckey-gnupg-then-touch", None)),
787 (GnupgStep::Pin, None) => tr.text("seckey-gnupg-pin", None),
788 (GnupgStep::Card, Some(words)) => words,
789 (GnupgStep::Card, None) => tr.text("seckey-renewing", None),
790 }
791}
792
793pub(crate) fn send_to_keys_openpgp(qt: &QtThread, ident: &str) {
798 let (qt, ident) = (qt.clone(), ident.to_string());
799 std::thread::spawn(move || {
800 let Some(known) = securitykey::known_cards().into_iter().find(|c| c.ident.eq_ignore_ascii_case(&ident)) else { return problem_line(&qt, SETUP_BAND, CardError::Absent.sentence(tr(), Purpose::Read), "") };
801 let Some(armored) = pgp::export_public(&known.cert) else { return problem_line(&qt, SETUP_BAND, tr().text("seckey-not-found", None), "") };
802 working(&qt, SETUP_BAND, "seckey-sending");
803 let sent = if offline() {
804 Ok(sioul_sync::securitykey::Published { fingerprint: known.cert.clone(), mailed: known.addresses.clone(), published: Vec::new() })
805 } else {
806 sioul_sync::securitykey::send_to_keys_openpgp(armored.as_bytes(), tr().language())
807 };
808 match sent {
809 Ok(published) => {
810 let line = if !published.mailed.is_empty() {
811 say("seckey-sent-mailed", &[("addresses", published.mailed.join(", "))])
812 } else if !published.published.is_empty() {
813 say("seckey-sent-published", &[("addresses", published.published.join(", "))])
814 } else {
815 tr().text("seckey-sent-no-address", None)
816 };
817 tell_band(&qt, SETUP_BAND, Band { state: "done", line, ..Band::default() });
818 }
819 Err(error) => problem_line(&qt, SETUP_BAND, error.sentence(tr(), ""), ""),
820 }
821 });
822}
823
824pub(crate) fn stop_using(qt: &QtThread, shared: &Shared, ident: &str) {
827 if PIN.lock().is_ok_and(|m| m.holder().is_some_and(|h| h.eq_ignore_ascii_case(ident))) {
828 let _ = forget_pin();
829 }
830 let line = match securitykey::forget(ident) {
831 Ok(()) => tr().text("seckey-stopped", None),
832 Err(e) => e,
833 };
834 tell(qt, shared, line);
835 let _ = qt.queue(|mut sioul| sioul.as_mut().keys_changed());
836}
837
838#[derive(serde::Serialize)]
840struct KnownView {
841 ident: String,
842 title: String,
844 signs_for: String,
846 source: String,
848 expiry: String,
850 late: bool,
851 touch: String,
853 pin_held: bool,
855 renewable: bool,
857 by_hand: Vec<String>,
860}
861
862pub(crate) fn known_view() -> String {
864 let keys = Keys::load();
865 let holder = PIN.lock().ok().and_then(|m| m.holder().map(str::to_string));
866 let cards: Vec<KnownView> = keys
867 .cards
868 .iter()
869 .map(|card| {
870 let name = holder_name(&card.holder);
871 let title = if name.is_empty() { card.label(tr()) } else { format!("{}, {name}", card.label(tr())) };
872 let source = match card.source.as_str() {
873 "file" => say("seckey-source-file", &[("date", securitykey::full_date(tr(), card.checked))]),
874 "gnupg" => say("seckey-source-gnupg", &[("date", securitykey::full_date(tr(), card.checked))]),
875 url => say("seckey-source", &[("source", host_of(url)), ("date", securitykey::full_date(tr(), card.checked))]),
876 };
877 let cert = keys.cert(&card.cert);
878 let (expiry, late, _) = cert.map(|cert| securitykey::expiry_words(tr(), cert, securitykey::expiry_of(cert, &[&card.sign, &card.decrypt]), now_unix())).unwrap_or_default();
879 let subkeys = cert.map(securitykey::subkeys_of).unwrap_or_default();
880 let unknown = (card.touch_sign == Touch::Unknown && !card.sign.is_empty()) || (card.touch_decrypt == Touch::Unknown && !card.decrypt.is_empty());
881 let touch = match (card.touch_sign.asked() && !card.sign.is_empty(), card.touch_decrypt.asked() && !card.decrypt.is_empty()) {
882 _ if unknown => "seckey-touch-unknown",
883 (true, true) => "seckey-touch-both",
884 (true, false) => "seckey-touch-sign",
885 (false, true) => "seckey-touch-open",
886 (false, false) => "seckey-touch-none",
887 };
888 KnownView {
889 ident: card.ident.clone(),
890 title,
891 signs_for: if card.addresses.is_empty() { tr().text("seckey-signs-for-none", None) } else { say("seckey-signs-for", &[("addresses", card.addresses.join(", "))]) },
892 source,
893 expiry,
894 late,
895 touch: tr().text(touch, None),
896 pin_held: holder.as_deref().is_some_and(|h| h.eq_ignore_ascii_case(&card.ident)),
897 renewable: late && renewable(),
899 by_hand: std::iter::once(sioul_sync::securitykey::export_command(&card.cert)).chain(if late { sioul_sync::securitykey::renew_commands(&card.cert, &subkeys) } else { Vec::new() }).collect(),
900 }
901 })
902 .collect();
903 json(&serde_json::json!({ "cards": cards }))
904}
905
906fn host_of(url: &str) -> String {
908 url.split_once("://").map_or(url, |(_, rest)| rest).split(['/', '?', '#']).next().unwrap_or(url).to_string()
909}
910
911#[cfg(test)]
912mod tests {
913 use super::*;
914
915 #[test]
916 fn the_holder_reads_in_the_usual_order() {
917 assert_eq!(holder_name("Doe<<Jane<Marie"), "Jane Marie Doe");
918 assert_eq!(holder_name("Jane Doe"), "Jane Doe");
919 assert_eq!(holder_name(""), "");
920 assert_eq!(host_of("https://keys.openpgp.org/vks/v1/by-fingerprint/ABC"), "keys.openpgp.org");
921 assert_eq!(host_of("https://openpgpkey.example.org/.well-known/openpgpkey/example.org/hu/x?l=jane"), "openpgpkey.example.org");
922 }
923
924 #[test]
932 fn a_key_gnupg_holds_offers_to_release_it() {
933 let tr = sioul_core::i18n::Translator::new("en");
934 let held = SoftReaders { cards: std::sync::Mutex::new(Vec::new()), busy: 1, failure: None };
935 assert_eq!(key_in(&held, "0006:12345678").map(|c| c.ident), Err(CardError::Busy));
936 assert_eq!(key_in(&held, "").map(|c| c.ident), Err(CardError::Busy), "the last step of adding it, the key not read yet");
937 let band = setup_problem(&CardError::Busy, &tr);
938 assert_eq!((band.state, band.action, band.warm), ("problem", "release", true));
939 assert!(band.line.starts_with("Another program holds your security key") && band.line.ends_with("`pcsc-shared`"), "{}", band.line);
940 assert_eq!(key_in(&SoftReaders::with(Vec::new()), "").map(|c| c.ident), Err(CardError::Absent));
942 assert_eq!(setup_problem(&CardError::Absent, &tr).action, "retry");
943 }
944
945 #[test]
946 fn the_renewal_says_the_pin_then_the_touch() {
947 let tr = sioul_core::i18n::Translator::new("en");
948 let pin_then_touch = renew_line(GnupgStep::Pin, Touch::On, &tr);
949 assert!(pin_then_touch.contains("PIN") && pin_then_touch.contains("hold your finger"), "{pin_then_touch}");
950 let touch = renew_line(GnupgStep::Card, Touch::On, &tr);
951 assert!(touch.starts_with("Touch your security key now") && touch.contains("15 seconds"), "{touch}");
952 assert_eq!(renew_line(GnupgStep::Card, Touch::Fixed, &tr), touch);
953 assert!(renew_line(GnupgStep::Card, Touch::Unknown, &tr).starts_with("If your key asks for a touch"));
954 assert!(!renew_line(GnupgStep::Pin, Touch::Off, &tr).contains("touch"));
955 assert_eq!(renew_line(GnupgStep::Card, Touch::Off, &tr), tr.text("seckey-renewing", None));
956 let fr = sioul_core::i18n::Translator::new("fr");
958 assert!(renew_line(GnupgStep::Card, Touch::On, &fr).starts_with("Touchez votre clé de sécurité"));
959 }
960}