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