Skip to main content

pubhubs/cli/
enter.rs

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/// Wrapper around `Vec<Handle>` that parses from a `|`-separated list of handles
18#[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    /// Enter this pubhubs environment
48    #[arg(short, long, value_name = "ENVIRONMENT", default_value = "stable")]
49    environment: Environment,
50
51    /// Contact PHC at this url, overriding --environment
52    #[arg(short, long, value_name = "PHC_URL")]
53    url: Option<url::Url>,
54
55    /// Whether to wait for a pubhubs yivi card
56    #[arg(short, long)]
57    wait_for_card: bool,
58
59    /// Whether to use 'chained session drip', AuthStartReq::yivi_chained_session_drip
60    #[arg(long)]
61    chained_session_drip: bool,
62
63    /// Ask for confirmation before proceeding at certain points.  Useful for letting something
64    /// time out.
65    #[arg(long)]
66    confirm: bool,
67
68    /// Comment to use on the pubhubs card, provided a card is requested
69    #[arg(long, value_name = "COMMENT")]
70    card_comment: Option<String>,
71
72    /// Handle identifying the hub
73    #[arg(value_name = "HUB")]
74    hub_handle: Option<Handle>,
75
76    /// Instead of the displaying the actual client url after entering a hub,
77    /// display the _local_ client url.  Useful when running your local client against main.
78    #[arg(short, long)]
79    local_client: bool,
80
81    /// The local client url used by  --local-client.
82    #[arg(long, value_name = "URL", default_value = "http://localhost:8001")]
83    local_client_url: url::Url,
84
85    /// Use this pubhubs authentication token
86    #[arg(short, long, value_name = "AUTH_TOKEN")]
87    auth_token: Option<AuthToken>,
88
89    /// Identifying attribute type to use
90    #[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    /// Add these attributes when entering pubhubs
99    #[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    /// Don't add any attributes when entering pubhubs
108    #[arg(long, conflicts_with = "add_attr_type", conflicts_with = "auth_token")]
109    dont_add_attrs: bool,
110
111    /// Don't create a new account if one of the supplied attributes already bans another account
112    #[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    /// Enter pubhubs using a QR code on the command line; returns an auth token.
328    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        // Getting the disclosure is a bit tricky, as it is retrieved in two different ways
407        // depending on whether we're waiting for a card.
408        //
409        // If we're *not* waiting for a card, we simply call `yivi_cli_session` to get a disclosure, and that's that.
410        //
411        // But if we're waiting for a card, `yivi_cli_session` will not return the disclosure until
412        // we release the issuance request to the yivi server.  In this case, we obtain the
413        // disclosure via YiviWaitForResult, which is not available otherwise.
414        //
415        // We deal with these two ways to a disclosure by taking both paths in separate (tokio)
416        // tasks, and letting these tasks both send their disclosure over disclosure_sender
417        let (disclosure_sender, mut disclosure_receiver) = tokio::sync::mpsc::channel(1);
418
419        if self.wait_for_card {
420            // before we can move a future to a separate task via spawn_local, we must first clone
421            // anything we want to pass to it by reference
422            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
619/// Starts a yivi session with `yivi_requestor_url`, printing the QR code to the command line.
620async 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    // make sure frontend_url ends with a '/' so Url::join works as expected
647    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    //yivi_requestor_url
667    //    .join(&format!("/session/{requestor_token}/statusevents"))?
668    //    .as_str(),
669
670    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/// Represents a [yivi session package](https://github.com/privacybydesign/irmago/blob/f9718c334af76a3ad2fa23019d17957878cd2032/server/api.go#L30).
718#[derive(serde::Deserialize, Debug, Clone)]
719#[serde(rename_all = "camelCase")]
720struct YiviSessionPackage {
721    session_ptr: serde_json::Value,
722
723    /// Requestor token
724    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// https://github.com/privacybydesign/irmago/blob/773a229329a063043831a4c21e72b139b9600f4b/requests.go#L235
740#[derive(serde::Deserialize, Debug, Clone)]
741struct FrontendSessionRequest {
742    authorization: String,
743    // some fields omitted
744}
745
746/// <https://github.com/privacybydesign/irmago/blob/773a229329a063043831a4c21e72b139b9600f4b/messages.go#L571>
747#[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}