1use std::collections::HashMap;
2
3use anyhow::{Context as _, Result};
4use futures::stream::StreamExt as _;
5use futures_util::FutureExt as _;
6
7use crate::api;
8use crate::attr;
9use crate::client;
10use crate::handle::Handle;
11use crate::misc::jwt;
12use crate::servers::Constellation;
13use crate::servers::yivi;
14
15use super::common::{self, Environment};
16
17use api::phc::user::AuthToken;
18
19#[derive(Debug, Clone)]
21struct HandleChoice {
22 inner: Vec<Handle>,
23}
24
25impl std::ops::Deref for HandleChoice {
26 type Target = Vec<Handle>;
27
28 fn deref(&self) -> &Vec<Handle> {
29 &self.inner
30 }
31}
32
33impl core::str::FromStr for HandleChoice {
34 type Err = anyhow::Error;
35
36 fn from_str(s: &str) -> Result<Self, Self::Err> {
37 let mut handles: Vec<Handle> = Default::default();
38
39 for part in s.split('|') {
40 handles.push(Handle::from_str(part)?);
41 }
42
43 Ok(Self { inner: handles })
44 }
45}
46
47#[derive(clap::Args, Debug)]
48pub struct EnterArgs {
49 #[arg(short, long, value_name = "ENVIRONMENT", default_value = "stable")]
51 environment: Environment,
52
53 #[arg(short, long, value_name = "PHC_URL")]
55 url: Option<url::Url>,
56
57 #[arg(short, long)]
59 wait_for_card: bool,
60
61 #[arg(long)]
63 chained_session_drip: bool,
64
65 #[arg(long)]
68 confirm: bool,
69
70 #[arg(long, value_name = "COMMENT")]
72 card_comment: Option<String>,
73
74 #[arg(value_name = "HUB")]
76 hub_handle: Option<Handle>,
77
78 #[arg(short, long)]
81 local_client: bool,
82
83 #[arg(long, value_name = "URL", default_value = "http://localhost:8001")]
85 local_client_url: url::Url,
86
87 #[arg(short, long, value_name = "AUTH_TOKEN")]
89 auth_token: Option<AuthToken>,
90
91 #[arg(
93 long,
94 default_value = "email",
95 value_name = "ATTR_TYPE",
96 conflicts_with = "auth_token"
97 )]
98 id_attr_type: HandleChoice,
99
100 #[arg(
102 long,
103 default_value = "phone",
104 value_name = "ATTR_TYPE",
105 conflicts_with = "auth_token"
106 )]
107 add_attr_type: Vec<Handle>,
108
109 #[arg(long, conflicts_with = "add_attr_type", conflicts_with = "auth_token")]
111 dont_add_attrs: bool,
112
113 #[arg(long, conflicts_with = "auth_token")]
115 register_only_with_unique_attrs: bool,
116}
117
118impl EnterArgs {
119 pub fn run(mut self, _spec: &mut clap::Command) -> Result<()> {
120 env_logger::init();
121
122 if self.dont_add_attrs {
123 self.add_attr_type.clear();
124 }
125
126 tokio::runtime::Builder::new_current_thread()
127 .enable_all()
128 .build()?
129 .block_on(tokio::task::LocalSet::new().run_until(self.run_async()))
130 }
131
132 fn url(&self) -> std::borrow::Cow<'_, url::Url> {
133 common::phc_url(self.environment, &self.url)
134 }
135
136 async fn confirm(&self, msg: &str) {
137 if !self.confirm {
138 return;
139 }
140
141 use tokio::io::AsyncBufReadExt as _;
142
143 let mut stdin = tokio::io::BufReader::new(tokio::io::stdin());
144 println!("{msg} - press <ENTER> to continue");
145 let _ = stdin.read_line(&mut String::new()).await;
146 }
147
148 async fn run_async(self) -> Result<()> {
149 let client = client::Client::builder().agent(client::Agent::Cli).finish();
150
151 let url = self.url();
152 log::info!("contacting pubhubs central at {}", url);
153
154 let Ok(api::phc::user::WelcomeResp {
155 constellation,
156 hubs,
157 }) = client
158 .query_with_retry::<api::phc::user::WelcomeEP, _, _>(url.as_ref(), api::NoPayload)
159 .await
160 else {
161 anyhow::bail!("cannot reach pubhubs central at {}", url);
162 };
163
164 if let Some(hub_handle) = &self.hub_handle
165 && !hubs.contains_key(hub_handle)
166 {
167 anyhow::bail!(
168 "no such hub {}; choose from: {}",
169 hub_handle,
170 hubs.keys()
171 .map(Handle::as_str)
172 .collect::<Vec<&str>>()
173 .join(", ")
174 )
175 }
176
177 let api::auths::WelcomeResp { attr_types, .. } = client
178 .query_with_retry::<api::auths::WelcomeEP, _, _>(
179 &constellation.auths_url,
180 api::NoPayload,
181 )
182 .await
183 .with_context(|| {
184 format!(
185 "cannot reach authentication server at {}",
186 constellation.auths_url
187 )
188 })?;
189
190 let auth_token = match self.auth_token {
191 Some(auth_token) => auth_token,
192 None => {
193 let auth_token = self
194 .get_auth_token(&client, &constellation, &attr_types)
195 .await?;
196 println!("global auth token: {auth_token}");
197 auth_token
198 }
199 };
200
201 let Some(hub_handle) = self.hub_handle else {
202 return Ok(());
203 };
204
205 let Some(hub_info) = hubs.get(&hub_handle) else {
206 panic!("did we not already check we have details on this hub?!");
207 };
208
209 let api::hub::EnterStartResp {
210 state: hub_state,
211 nonce: hub_nonce,
212 hhpp_signature_scheme,
213 hub_mac_key,
214 } = client
215 .query_with_retry::<api::hub::EnterStartEP, _, _>(&hub_info.url, api::NoPayload)
216 .await
217 .with_context(|| format!("cannot reach hub at {}", hub_info.url))?;
218
219 let ppp_resp = client
220 .query::<api::phc::user::PppEP>(&constellation.phc_url, api::NoPayload)
221 .auth_header(auth_token.clone())
222 .with_retry()
223 .await
224 .context("failed to obtain ppp from phc")?;
225
226 let api::phc::user::PppResp::Success(ppp) = ppp_resp else {
227 anyhow::bail!("failed to obtain ppp from phc: {ppp_resp:?}");
228 };
229
230 let ehpp_resp = client
231 .query::<api::tr::EhppEP>(
232 &constellation.transcryptor_url,
233 api::tr::EhppReq {
234 hub_nonce,
235 hub: hub_info.id,
236 ppp,
237 hub_mac_key,
238 },
239 )
240 .with_retry()
241 .await
242 .context("failed to obtain ehpp from transcryptor")?;
243
244 let api::tr::EhppResp::Success(ehpp) = ehpp_resp else {
245 anyhow::bail!("failed to obtain ehpp from transcryptor: {ehpp_resp:?}");
246 };
247
248 let hhpp_resp = client
249 .query::<api::phc::user::HhppEP>(
250 &constellation.phc_url,
251 api::phc::user::HhppReq {
252 ehpp,
253 hhpp_signature_scheme,
254 },
255 )
256 .auth_header(auth_token.clone())
257 .with_retry()
258 .await
259 .context("failed to obtain hhpp from phc")?;
260
261 let api::phc::user::HhppResp::Success(hhpp) = hhpp_resp else {
262 anyhow::bail!("failed to obtain hhpp from phc: {hhpp_resp:?}");
263 };
264
265 let enter_complete_resp = client
266 .query::<api::hub::EnterCompleteEP>(
267 &hub_info.url,
268 api::hub::EnterCompleteReq {
269 state: hub_state,
270 hhpp,
271 },
272 )
273 .with_retry()
274 .await
275 .context("failed to complete entering hub")?;
276
277 let api::hub::EnterCompleteResp::Entered {
278 access_token: hub_access_token,
279 device_id,
280 new_user,
281 mxid,
282 } = enter_complete_resp
283 else {
284 anyhow::bail!("failed to complete entering hub: {enter_complete_resp:?}");
285 };
286
287 let mut hub_client_url: url::Url = if self.local_client {
288 self.local_client_url
289 } else {
290 let api::hub::InfoResp { hub_client_url, .. } = client
291 .query_with_retry::<api::hub::InfoEP, _, _>(&hub_info.url, api::NoPayload)
292 .await
293 .context("failed to obtain hub information")?;
294
295 hub_client_url
296 };
297
298 hub_client_url.query_pairs_mut().append_pair(
299 "accessToken",
300 &serde_json::json!({
301 "token": hub_access_token,
302 "userId": mxid,
303 })
304 .to_string(),
305 );
306
307 println!("access token: {hub_access_token}");
308 println!("mxid: {mxid}");
309 println!("device id: {device_id}");
310 println!("first time?: {new_user}");
311 println!();
312 println!("hub client url: {hub_client_url}");
313 Ok(())
314 }
315
316 async fn get_auth_token(
318 &self,
319 client: &client::Client,
320 constellation: &Constellation,
321 attr_types: &HashMap<Handle, attr::Type>,
322 ) -> Result<AuthToken> {
323 for id_attr_type_choice in self.id_attr_type.iter() {
324 let Some(_id_attr_info) = attr_types.get(id_attr_type_choice) else {
325 anyhow::bail!(
326 "no such attribute type {}; choose from: {}",
327 id_attr_type_choice,
328 attr_types
329 .keys()
330 .map(Handle::as_str)
331 .collect::<Vec<&str>>()
332 .join(", ")
333 )
334 };
335 }
336
337 let mut add_attrs_info =
338 HashMap::<Handle, attr::Type>::with_capacity(self.add_attr_type.len());
339
340 for attr_type in self.add_attr_type.iter() {
341 let Some(attr_info) = attr_types.get(attr_type) else {
342 anyhow::bail!(
343 "no such attribute type {attr_type}; choose from: {}",
344 attr_types
345 .keys()
346 .map(Handle::as_str)
347 .collect::<Vec<&str>>()
348 .join(", ")
349 )
350 };
351
352 anyhow::ensure!(
353 add_attrs_info
354 .insert(attr_type.clone(), attr_info.clone())
355 .is_none(),
356 "duplicate attribute type {attr_type}"
357 );
358 }
359
360 let mut attr_type_choices: Vec<Vec<Handle>> = Default::default();
361
362 attr_type_choices.push(Vec::<Handle>::clone(&self.id_attr_type));
363
364 for add_attr_ty_handle in self.add_attr_type.iter() {
365 attr_type_choices.push(vec![add_attr_ty_handle.clone()]);
366 }
367
368 let auth_start_resp = client
369 .query_with_retry::<api::auths::AuthStartEP, _, _>(
370 &constellation.auths_url,
371 api::auths::AuthStartReq {
372 source: attr::Source::Yivi,
373 yivi_chained_session: self.wait_for_card,
374 yivi_chained_session_drip: self.chained_session_drip,
375 attr_types: Default::default(),
376 attr_type_choices,
377 },
378 )
379 .await
380 .context("failed to start authentication")?;
381
382 let api::auths::AuthStartResp::Success {
383 task: auth_task,
384 state: auth_state,
385 } = auth_start_resp
386 else {
387 anyhow::bail!("failed to start authentication: AS returned {auth_start_resp:?}");
388 };
389
390 let api::auths::AuthTask::Yivi {
391 disclosure_request,
392 yivi_requestor_url,
393 } = auth_task;
394
395 let (disclosure_sender, mut disclosure_receiver) = tokio::sync::mpsc::channel(1);
407
408 if self.wait_for_card {
409 let client = client.clone();
412 let auth_state = auth_state.clone();
413 let auths_url = constellation.auths_url.clone();
414
415 let fut = async move {
416 client.query::<api::auths::YiviWaitForResultEP>(
417 &auths_url,
418 api::auths::YiviWaitForResultReq {
419 state: auth_state.clone(),
420 },
421 )
422 .timeout(core::time::Duration::from_secs(24*3600))
423 .with_retry().map(|wait_result| -> anyhow::Result<jwt::JWT> {
424 let wait_result = wait_result.context("waiting for result of yivi to be submitted to the authentication server failed")?;
425
426 let api::auths::YiviWaitForResultResp::Success { disclosure } = wait_result else {
427 anyhow::bail!("waiting for result of yivi server to be submitted to authentication server failed: {wait_result:?} ");
428 };
429
430 Ok(disclosure)
431 }).await
432 };
433
434 let disclosure_sender = disclosure_sender.clone();
435
436 tokio::task::spawn_local(async move {
437 disclosure_sender
438 .send(fut.await)
439 .await
440 .expect("did not expect disclosure channel to be closed already");
441 });
442 }
443
444 let fut = yivi_cli_session(yivi_requestor_url.clone(), disclosure_request);
445
446 let yivi_requestor_url_clone = yivi_requestor_url.clone();
447 let disclosure_sender_clone = disclosure_sender.clone();
448
449 tokio::task::spawn_local(async move {
450 let _ = disclosure_sender_clone
451 .send(fut.await.with_context(|| {
452 format!("Yivi disclosure to {yivi_requestor_url_clone} failed")
453 }))
454 .await;
455 });
456
457 let disclosure = disclosure_receiver
458 .recv()
459 .await
460 .context("disclosure channel closed early")??;
461
462 let auth_complete_resp = client
463 .query_with_retry::<api::auths::AuthCompleteEP, _, _>(
464 &constellation.auths_url,
465 api::auths::AuthCompleteReq {
466 proof: api::auths::AuthProof::Yivi { disclosure },
467 state: auth_state.clone(),
468 },
469 )
470 .await
471 .context("failed to complete authentication")?;
472
473 let api::auths::AuthCompleteResp::Success { mut attrs } = auth_complete_resp else {
474 anyhow::bail!("failed to complete authentication: AS returned {auth_complete_resp:?}");
475 };
476
477 let Some((id_attr_type, identifying_attr)) = attrs.shift_remove_index(0) else {
478 anyhow::bail!("did not receive any attribute from authentication server");
479 };
480
481 if !self.id_attr_type.contains(&id_attr_type) {
482 anyhow::bail!(
483 "authentication server returned unexpected attribute type {id_attr_type} for identifying attribute; we were expecting one of {}",
484 self.id_attr_type
485 .iter()
486 .map(Handle::as_str)
487 .collect::<Vec<&str>>()
488 .join(", ")
489 );
490 }
491
492 let enter_resp = client
493 .query_with_retry::<api::phc::user::EnterEP, _, _>(
494 &constellation.phc_url,
495 api::phc::user::EnterReq {
496 identifying_attr: Some(identifying_attr),
497 mode: api::phc::user::EnterMode::LoginOrRegister,
498 add_attrs: attrs.values().map(Clone::clone).collect(),
499 register_only_with_unique_attrs: self.register_only_with_unique_attrs,
500 },
501 )
502 .await
503 .context("failed to enter pubhubs")?;
504
505 let api::phc::user::EnterResp::Entered {
506 auth_token_package: Ok(api::phc::user::AuthTokenPackage { auth_token, .. }),
507 new_account: _new_account,
508 attr_status,
509 } = enter_resp
510 else {
511 anyhow::bail!("failed to enter pubhubs: phc returned {enter_resp:?}");
512 };
513
514 for (attr, attr_status) in attr_status.iter() {
515 if *attr_status == api::phc::user::AttrAddStatus::PleaseTryAgain {
516 log::warn!("adding attribute {} failed", attr.value);
517 }
518 }
519
520 if self.wait_for_card {
521 let api::phc::user::CardPseudResp::Success(card_pseud_package) = client
522 .query::<api::phc::user::CardPseudEP>(&constellation.phc_url, api::NoPayload)
523 .auth_header(auth_token.clone())
524 .with_retry()
525 .await
526 .context("retrieving registration pseudonym failed")?
527 else {
528 anyhow::bail!("failed to retrieve registration pseudonym");
529 };
530
531 let api::auths::CardResp::Success {
532 attr,
533 issuance_request,
534 ..
535 } = client
536 .query_with_retry::<api::auths::CardEP, _, _>(
537 &constellation.auths_url,
538 api::auths::CardReq {
539 card_pseud_package,
540 comment: self.card_comment.clone(),
541 },
542 )
543 .await?
544 else {
545 anyhow::bail!("failed to obtain pubhubs card from authentication server");
546 };
547
548 let enter_resp = client
549 .query::<api::phc::user::EnterEP>(
550 &constellation.phc_url,
551 api::phc::user::EnterReq {
552 identifying_attr: None,
553 mode: api::phc::user::EnterMode::Login,
554 add_attrs: vec![attr],
555 register_only_with_unique_attrs: false,
556 },
557 )
558 .auth_header(auth_token.clone())
559 .with_retry()
560 .await
561 .context("failed to add pubhubs card to account")?;
562
563 let api::phc::user::EnterResp::Entered {
564 auth_token_package: Ok(api::phc::user::AuthTokenPackage { .. }),
565 attr_status,
566 ..
567 } = enter_resp
568 else {
569 anyhow::bail!("failed to add pubhubs card to account: phc returned {enter_resp:?}");
570 };
571
572 for (attr, attr_status) in attr_status.iter() {
573 match *attr_status {
574 api::phc::user::AttrAddStatus::PleaseTryAgain => {
575 anyhow::bail!("adding attribute {} failed", attr.value);
576 }
577 api::phc::user::AttrAddStatus::Added => {
578 println!("pubhubs card was added to account");
579 }
580 api::phc::user::AttrAddStatus::AlreadyThere => {
581 println!("pubhubs card already present");
582 }
583 }
584 }
585
586 self.confirm("releasing yivi server").await;
587
588 let api::auths::YiviReleaseNextSessionResp::Success {} = client
589 .query_with_retry::<api::auths::YiviReleaseNextSessionEP, _, _>(
590 &constellation.auths_url,
591 api::auths::YiviReleaseNextSessionReq {
592 state: auth_state.clone(),
593 next_session: Some(issuance_request),
594 stale_after: None,
595 },
596 )
597 .await
598 .context("starting next yivi session failed")?
599 else {
600 anyhow::bail!("failed to start next yivi session");
601 };
602 }
603
604 Ok(auth_token)
605 }
606}
607
608async fn yivi_cli_session(
610 yivi_requestor_url: impl std::borrow::Borrow<url::Url>,
611 request: jwt::JWT,
612) -> Result<jwt::JWT> {
613 let yivi_requestor_url = yivi_requestor_url.borrow();
614 let client = awc::Client::default();
615
616 let mut resp = client
617 .post(yivi_requestor_url.join("/session")?.as_str())
618 .insert_header(("Content-Type", "text/plain"))
619 .send_body(request.as_str().to_string())
620 .await
621 .map_err(|err| anyhow::anyhow!("failed to start Yivi session: {err}"))?;
622
623 let YiviSessionPackage {
624 session_ptr: session_ptr_json,
625 token: requestor_token,
626 frontend_request: FrontendSessionRequest { authorization, .. },
627 } = resp.json().await?;
628
629 let SessionPtr {
630 url: mut frontend_url,
631 ..
632 } = serde_json::from_value(session_ptr_json.clone())
633 .with_context(|| "failed to parse session pointer returned by yivi server")?;
634
635 if !frontend_url.path().ends_with("/") {
637 frontend_url.set_path(format!("{}/", frontend_url.path()).as_str());
638 }
639
640 log::debug!("requestor token: {requestor_token}; frontend_url: {frontend_url}");
641
642 println!();
643 println!("Please scan the following QR code using your Yivi app.");
644
645 let qr = qrcode::QrCode::new(session_ptr_json.to_string().as_bytes())?;
646
647 let qr_render = qr
648 .render()
649 .light_color(qrcode::render::unicode::Dense1x2::Light)
650 .dark_color(qrcode::render::unicode::Dense1x2::Dark)
651 .build();
652 print!("{qr_render}\n\n");
653
654 let statusevents_url = frontend_url.join("frontend/statusevents")?;
655 log::debug!("{}", statusevents_url);
660
661 let mut statusevents = client
662 .get(statusevents_url.as_str())
663 .insert_header(("Authorization", authorization))
664 .send()
665 .await
666 .map_err(|err| anyhow::anyhow!("failed to listen to statusevents: {err}"))?;
667
668 loop {
669 let data: bytes::Bytes = statusevents
670 .next()
671 .await
672 .ok_or_else(|| anyhow::anyhow!("status events aborted early"))??;
673
674 log::debug!(
675 "received status event: {}",
676 crate::misc::fmt_ext::Bytes(&data)
677 );
678
679 let Some(data) = data.strip_prefix(b"data:") else {
680 continue;
681 };
682
683 let FrontendSessionStatus { status, .. } = serde_json::from_slice(data)?;
684
685 match status {
686 yivi::Status::Done => break,
687 yivi::Status::Pairing | yivi::Status::Connected | yivi::Status::Initialized => continue,
688 yivi::Status::Cancelled => anyhow::bail!("yivi session was cancelled"),
689 yivi::Status::Timeout => anyhow::bail!("yivi session timed out"),
690 }
691 }
692
693 let mut resp = client
694 .get(
695 yivi_requestor_url
696 .join(&format!("/session/{requestor_token}/result-jwt"))?
697 .as_str(),
698 )
699 .send()
700 .await
701 .map_err(|err| anyhow::anyhow!("failed to retrieve session result: {err}"))?;
702
703 Ok(std::str::from_utf8(&resp.body().await?)?.to_string().into())
704}
705
706#[derive(serde::Deserialize, Debug, Clone)]
708#[serde(rename_all = "camelCase")]
709struct YiviSessionPackage {
710 session_ptr: serde_json::Value,
711
712 token: String,
714
715 frontend_request: FrontendSessionRequest,
716}
717
718#[derive(serde::Deserialize, Debug, Clone)]
719struct SessionPtr {
720 #[serde(rename = "u")]
721 url: url::Url,
722
723 #[serde(rename = "irmaqr")]
724 #[expect(dead_code)]
725 session_type: yivi::SessionType,
726}
727
728#[derive(serde::Deserialize, Debug, Clone)]
730struct FrontendSessionRequest {
731 authorization: String,
732 }
734
735#[derive(serde::Deserialize, Debug, Clone)]
737#[serde(rename_all = "camelCase")]
738struct FrontendSessionStatus {
739 status: yivi::Status,
740 #[expect(dead_code)]
741 next_session: Option<serde_json::Value>,
742}