iroh_db_security/
invitation.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use chacha20poly1305::{
4    XChaCha20Poly1305, XNonce,
5    aead::{Aead as _, KeyInit as _, Payload},
6};
7use iroh_db_core::{AuthorId, CapabilityId, DomainId};
8use zeroize::{Zeroize, ZeroizeOnDrop};
9
10use crate::{
11    AuthorityCheckpoint, CapabilityCertificate, ControlTransition, DomainDescriptor, DomainKey,
12    DomainSeed, IrohSigner, Permission, key::invitation_signature_message,
13};
14
15const INVITATION_VERSION: u16 = 2;
16const MAX_INVITATION_BYTES: usize = 1024 * 1024;
17const TICKET_AAD: &[u8] = b"iroh-db/offline-invitation-ticket/v1";
18
19/// A signed endpoint-targeted domain bootstrap payload.
20#[derive(Clone)]
21pub struct Invitation {
22    descriptor: DomainDescriptor,
23    chain: Vec<CapabilityCertificate>,
24    controller_chain: Vec<CapabilityCertificate>,
25    authority_evidence: Vec<CapabilityCertificate>,
26    checkpoint: AuthorityCheckpoint,
27    seed: DomainSeed,
28    subject: AuthorId,
29    issuer: AuthorId,
30    nonce: [u8; 32],
31    signature: [u8; 64],
32}
33
34impl Invitation {
35    /// Issues a signed invitation containing a validated least-privilege chain and epoch key.
36    pub fn issue(
37        descriptor: DomainDescriptor,
38        chain: Vec<CapabilityCertificate>,
39        seed: DomainSeed,
40        subject: AuthorId,
41        issuer: &IrohSigner,
42    ) -> Result<Self, InvitationError> {
43        let root = chain.first().ok_or(InvitationError::InvalidChain)?.clone();
44        let issuer_capability = chain
45            .last()
46            .filter(|capability| capability.subject() == issuer.author())
47            .or_else(|| chain.iter().rev().nth(1))
48            .ok_or(InvitationError::InvalidChain)?
49            .id()
50            .map_err(authority_error)?;
51        Self::issue_at_checkpoint(
52            descriptor,
53            chain,
54            vec![root],
55            seed,
56            None,
57            Vec::new(),
58            issuer_capability,
59            subject,
60            issuer,
61        )
62    }
63
64    /// Issues a current-epoch invitation carrying a signed compact authority baseline.
65    #[allow(clippy::too_many_arguments)]
66    pub fn issue_at_checkpoint(
67        descriptor: DomainDescriptor,
68        chain: Vec<CapabilityCertificate>,
69        controller_chain: Vec<CapabilityCertificate>,
70        seed: DomainSeed,
71        head: Option<ControlTransition>,
72        subject_revocations: Vec<(AuthorId, u64)>,
73        issuer_capability: CapabilityId,
74        subject: AuthorId,
75        issuer: &IrohSigner,
76    ) -> Result<Self, InvitationError> {
77        let active_chains = vec![chain.clone(), controller_chain.clone()];
78        Self::issue_at_checkpoint_with_evidence(
79            descriptor,
80            chain,
81            controller_chain,
82            &active_chains,
83            seed,
84            head,
85            subject_revocations,
86            issuer_capability,
87            subject,
88            issuer,
89        )
90    }
91
92    /// Issues a checkpoint with complete evidence for the domain's active members.
93    #[allow(clippy::too_many_arguments)]
94    pub fn issue_at_checkpoint_with_evidence(
95        descriptor: DomainDescriptor,
96        chain: Vec<CapabilityCertificate>,
97        controller_chain: Vec<CapabilityCertificate>,
98        active_chains: &[Vec<CapabilityCertificate>],
99        seed: DomainSeed,
100        head: Option<ControlTransition>,
101        subject_revocations: Vec<(AuthorId, u64)>,
102        issuer_capability: CapabilityId,
103        subject: AuthorId,
104        issuer: &IrohSigner,
105    ) -> Result<Self, InvitationError> {
106        let mut nonce = [0_u8; 32];
107        getrandom::fill(&mut nonce).map_err(|_| InvitationError::RandomnessUnavailable)?;
108        let mut evidence = BTreeMap::new();
109        for capability in chain
110            .iter()
111            .chain(&controller_chain)
112            .chain(active_chains.iter().flatten())
113        {
114            evidence.insert(
115                capability.id().map_err(authority_error)?,
116                capability.clone(),
117            );
118        }
119        let capability_ids = evidence.keys().copied().collect::<Vec<_>>();
120        let authority_evidence = evidence.into_values().collect::<Vec<_>>();
121        let leaf = chain.last().ok_or(InvitationError::InvalidChain)?;
122        let controller = controller_chain
123            .last()
124            .ok_or(InvitationError::InvalidChain)?;
125        let mut active_capability_ids = active_chains
126            .iter()
127            .filter_map(|active_chain| active_chain.last())
128            .map(CapabilityCertificate::id)
129            .collect::<Result<Vec<_>, _>>()
130            .map_err(authority_error)?;
131        active_capability_ids.extend([
132            leaf.id().map_err(authority_error)?,
133            controller.id().map_err(authority_error)?,
134            issuer_capability,
135        ]);
136        let checkpoint = AuthorityCheckpoint::issue(
137            descriptor.domain_id(),
138            seed.epoch(),
139            head,
140            capability_ids,
141            active_capability_ids,
142            subject_revocations,
143            issuer_capability,
144            subject,
145            issuer,
146        )
147        .map_err(authority_error)?;
148        let mut invitation = Self {
149            descriptor,
150            chain,
151            controller_chain,
152            authority_evidence,
153            checkpoint,
154            seed,
155            subject,
156            issuer: issuer.author(),
157            nonce,
158            signature: [0; 64],
159        };
160        invitation.validate_chain()?;
161        invitation.signature = issuer.sign_invitation(&invitation.signing_bytes()?);
162        Ok(invitation)
163    }
164
165    /// Returns the target endpoint that alone may import this invitation.
166    pub const fn subject(&self) -> AuthorId {
167        self.subject
168    }
169
170    pub const fn issuer(&self) -> AuthorId {
171        self.issuer
172    }
173
174    /// Returns the signed immutable domain descriptor.
175    pub const fn descriptor(&self) -> &DomainDescriptor {
176        &self.descriptor
177    }
178
179    /// Returns the validated root-to-leaf capability chain.
180    pub fn capability_chain(&self) -> &[CapabilityCertificate] {
181        &self.chain
182    }
183
184    pub fn controller_chain(&self) -> &[CapabilityCertificate] {
185        &self.controller_chain
186    }
187
188    pub fn authority_evidence(&self) -> &[CapabilityCertificate] {
189        &self.authority_evidence
190    }
191
192    pub const fn authority_checkpoint(&self) -> &AuthorityCheckpoint {
193        &self.checkpoint
194    }
195
196    /// Exports the sensitive current epoch seed for the targeted key-vault import path.
197    pub fn domain_seed(&self) -> DomainSeed {
198        self.seed.clone()
199    }
200
201    /// Strictly encodes the signed online invitation payload.
202    pub fn encode_canonical(&self) -> Result<Vec<u8>, InvitationError> {
203        let bytes = minicbor::to_vec(InvitationWire {
204            content: self.signing_bytes()?,
205            issuer: self.issuer,
206            signature: self.signature,
207        })
208        .map_err(codec_error)?;
209        if bytes.len() > MAX_INVITATION_BYTES {
210            return Err(InvitationError::TooLarge(bytes.len()));
211        }
212        Ok(bytes)
213    }
214
215    /// Strictly decodes and validates a signed invitation.
216    pub fn decode_canonical(bytes: &[u8]) -> Result<Self, InvitationError> {
217        if bytes.len() > MAX_INVITATION_BYTES {
218            return Err(InvitationError::TooLarge(bytes.len()));
219        }
220        let wire: InvitationWire = decode_exact(bytes)?;
221        let content: InvitationContent = decode_exact(&wire.content)?;
222        if content.version != INVITATION_VERSION {
223            return Err(InvitationError::UnsupportedVersion(content.version));
224        }
225        let descriptor =
226            DomainDescriptor::decode_canonical(&content.descriptor).map_err(authority_error)?;
227        let chain = content
228            .capability_chain
229            .iter()
230            .map(|bytes| CapabilityCertificate::decode_canonical(bytes).map_err(authority_error))
231            .collect::<Result<Vec<_>, _>>()?;
232        let controller_chain = content
233            .controller_chain
234            .iter()
235            .map(|bytes| CapabilityCertificate::decode_canonical(bytes).map_err(authority_error))
236            .collect::<Result<Vec<_>, _>>()?;
237        let authority_evidence = content
238            .authority_evidence
239            .iter()
240            .map(|bytes| CapabilityCertificate::decode_canonical(bytes).map_err(authority_error))
241            .collect::<Result<Vec<_>, _>>()?;
242        let checkpoint =
243            AuthorityCheckpoint::decode_canonical(&content.checkpoint).map_err(authority_error)?;
244        let invitation = Self {
245            descriptor,
246            chain,
247            controller_chain,
248            authority_evidence,
249            checkpoint,
250            seed: DomainSeed::from_parts(
251                content.domain_id,
252                content.epoch,
253                DomainKey::from_bytes(content.domain_key),
254            ),
255            subject: content.subject,
256            issuer: wire.issuer,
257            nonce: content.nonce,
258            signature: wire.signature,
259        };
260        invitation.validate_chain()?;
261        verify_signature(
262            invitation.issuer,
263            &invitation_signature_message(&wire.content),
264            &invitation.signature,
265        )?;
266        if invitation.signing_bytes()? != wire.content || invitation.encode_canonical()? != bytes {
267            return Err(InvitationError::NonCanonical);
268        }
269        Ok(invitation)
270    }
271
272    /// Encrypts this invitation into an offline ticket and returns its one-time bearer secret.
273    pub fn seal_offline(&self) -> Result<(InvitationTicket, InvitationSecret), InvitationError> {
274        let mut secret = [0_u8; 32];
275        let mut nonce = [0_u8; 24];
276        getrandom::fill(&mut secret).map_err(|_| InvitationError::RandomnessUnavailable)?;
277        getrandom::fill(&mut nonce).map_err(|_| InvitationError::RandomnessUnavailable)?;
278        let ciphertext = XChaCha20Poly1305::new((&secret).into())
279            .encrypt(
280                &XNonce::from(nonce),
281                Payload {
282                    msg: &self.encode_canonical()?,
283                    aad: TICKET_AAD,
284                },
285            )
286            .map_err(|_| InvitationError::EncryptionFailed)?;
287        Ok((
288            InvitationTicket { nonce, ciphertext },
289            InvitationSecret(secret),
290        ))
291    }
292
293    fn signing_bytes(&self) -> Result<Vec<u8>, InvitationError> {
294        let capability_chain = self
295            .chain
296            .iter()
297            .map(|capability| capability.encode_canonical().map_err(authority_error))
298            .collect::<Result<Vec<_>, _>>()?;
299        let controller_chain = self
300            .controller_chain
301            .iter()
302            .map(|capability| capability.encode_canonical().map_err(authority_error))
303            .collect::<Result<Vec<_>, _>>()?;
304        let authority_evidence = self
305            .authority_evidence
306            .iter()
307            .map(|capability| capability.encode_canonical().map_err(authority_error))
308            .collect::<Result<Vec<_>, _>>()?;
309        minicbor::to_vec(InvitationContent {
310            version: INVITATION_VERSION,
311            descriptor: self
312                .descriptor
313                .encode_canonical()
314                .map_err(authority_error)?,
315            capability_chain,
316            domain_id: self.seed.domain_id(),
317            epoch: self.seed.epoch(),
318            domain_key: *self.seed.domain_key().as_bytes(),
319            subject: self.subject,
320            nonce: self.nonce,
321            controller_chain,
322            authority_evidence,
323            checkpoint: self
324                .checkpoint
325                .encode_canonical()
326                .map_err(authority_error)?,
327        })
328        .map_err(codec_error)
329    }
330
331    fn validate_chain(&self) -> Result<(), InvitationError> {
332        self.descriptor.verify().map_err(authority_error)?;
333        let root = self.chain.first().ok_or(InvitationError::InvalidChain)?;
334        if self.chain.len() > 9
335            || root.issuer_capability().is_some()
336            || root.domain_id() != self.descriptor.domain_id()
337            || root.subject() != self.descriptor.owner()
338            || self.seed.domain_id() != self.descriptor.domain_id()
339        {
340            return Err(InvitationError::InvalidChain);
341        }
342        for pair in self.chain.windows(2) {
343            pair[1]
344                .validate_delegation(&pair[0])
345                .map_err(authority_error)?;
346        }
347        let leaf = self.chain.last().ok_or(InvitationError::InvalidChain)?;
348        if leaf.subject() != self.subject
349            || !leaf.permits(Permission::Read, self.seed.epoch())
350            || leaf.issuer_endpoint() != self.issuer
351        {
352            return Err(InvitationError::InvalidChain);
353        }
354        self.validate_checkpoint(root, leaf)
355    }
356
357    #[allow(clippy::too_many_lines)]
358    fn validate_checkpoint(
359        &self,
360        root: &CapabilityCertificate,
361        leaf: &CapabilityCertificate,
362    ) -> Result<(), InvitationError> {
363        self.checkpoint.verify().map_err(authority_error)?;
364        if self.checkpoint.domain_id() != self.descriptor.domain_id()
365            || self.checkpoint.epoch() != self.seed.epoch()
366            || self.checkpoint.subject() != self.subject
367            || self.checkpoint.issuer() != self.issuer
368        {
369            return Err(InvitationError::InvalidChain);
370        }
371        let expected_anchor = self
372            .checkpoint
373            .head()
374            .map(ControlTransition::id)
375            .transpose()
376            .map_err(authority_error)?;
377        let expected_sequence = self
378            .checkpoint
379            .head()
380            .map_or(0, ControlTransition::sequence);
381        if leaf.issued_control_sequence() != expected_sequence
382            || leaf.issued_control_head() != expected_anchor
383        {
384            return Err(InvitationError::InvalidChain);
385        }
386        let controller = self
387            .controller_chain
388            .last()
389            .ok_or(InvitationError::InvalidChain)?;
390        if self.controller_chain.first() != Some(root)
391            || self
392                .controller_chain
393                .iter()
394                .any(|capability| capability.domain_id() != self.descriptor.domain_id())
395        {
396            return Err(InvitationError::InvalidChain);
397        }
398        for pair in self.controller_chain.windows(2) {
399            pair[1]
400                .validate_delegation(&pair[0])
401                .map_err(authority_error)?;
402        }
403        let (controller_subject, controller_id) = self.checkpoint.head().map_or(
404            (self.descriptor.owner(), self.controller_chain[0].id()),
405            |head| (head.controller(), Ok(head.controller_capability())),
406        );
407        if let Some(head) = self.checkpoint.head() {
408            head.verify().map_err(authority_error)?;
409            if self.seed.distribution_digest() != head.key_distribution_digest() {
410                return Err(InvitationError::InvalidChain);
411            }
412            for subject in head.revoked_subjects() {
413                if !self
414                    .checkpoint
415                    .subject_revocations()
416                    .contains(&(*subject, head.sequence()))
417                {
418                    return Err(InvitationError::InvalidChain);
419                }
420            }
421        }
422        if controller.subject() != controller_subject
423            || controller.id().map_err(authority_error)?
424                != controller_id.map_err(authority_error)?
425            || !controller.permits(Permission::Admin, self.seed.epoch())
426        {
427            return Err(InvitationError::InvalidChain);
428        }
429        let ids = self
430            .authority_evidence
431            .iter()
432            .map(CapabilityCertificate::id)
433            .collect::<Result<Vec<_>, _>>()
434            .map_err(authority_error)?;
435        if !ids.windows(2).all(|pair| pair[0] < pair[1])
436            || ids.as_slice() != self.checkpoint.capability_ids()
437        {
438            return Err(InvitationError::InvalidChain);
439        }
440        let evidence = self
441            .authority_evidence
442            .iter()
443            .zip(ids.iter().copied())
444            .map(|(capability, id)| (id, capability))
445            .collect::<BTreeMap<_, _>>();
446        let root_id = root.id().map_err(authority_error)?;
447        if evidence.get(&root_id).copied() != Some(root)
448            || self
449                .chain
450                .iter()
451                .chain(&self.controller_chain)
452                .any(|capability| {
453                    capability
454                        .id()
455                        .ok()
456                        .and_then(|id| evidence.get(&id).copied())
457                        != Some(capability)
458                })
459        {
460            return Err(InvitationError::InvalidChain);
461        }
462        for capability in &self.authority_evidence {
463            if capability.domain_id() != self.descriptor.domain_id() {
464                return Err(InvitationError::InvalidChain);
465            }
466            if let Some(parent_id) = capability.issuer_capability() {
467                let parent = evidence
468                    .get(&parent_id)
469                    .ok_or(InvitationError::InvalidChain)?;
470                capability
471                    .validate_delegation(parent)
472                    .map_err(authority_error)?;
473            } else if capability != root {
474                return Err(InvitationError::InvalidChain);
475            }
476        }
477        let issuer_capability = self
478            .authority_evidence
479            .iter()
480            .find(|capability| capability.id().ok() == Some(self.checkpoint.issuer_capability()))
481            .ok_or(InvitationError::InvalidChain)?;
482        if issuer_capability.subject() != self.issuer
483            || !issuer_capability.permits(Permission::Invite, self.seed.epoch())
484        {
485            return Err(InvitationError::InvalidChain);
486        }
487        let required_active = [
488            leaf.id().map_err(authority_error)?,
489            controller.id().map_err(authority_error)?,
490            self.checkpoint.issuer_capability(),
491        ];
492        if required_active
493            .iter()
494            .any(|id| !self.checkpoint.active_capability_ids().contains(id))
495        {
496            return Err(InvitationError::InvalidChain);
497        }
498        let mut active_subjects = BTreeSet::new();
499        for id in self.checkpoint.active_capability_ids() {
500            let capability = evidence.get(id).ok_or(InvitationError::InvalidChain)?;
501            if !active_subjects.insert(capability.subject()) {
502                return Err(InvitationError::InvalidChain);
503            }
504        }
505        for capability in &self.authority_evidence {
506            if self
507                .checkpoint
508                .subject_revocations()
509                .iter()
510                .find(|(subject, _)| *subject == capability.subject())
511                .is_some_and(|(_, sequence)| *sequence > capability.issued_control_sequence())
512            {
513                return Err(InvitationError::InvalidChain);
514            }
515        }
516        Ok(())
517    }
518}
519
520impl std::fmt::Debug for Invitation {
521    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
522        formatter.write_str("Invitation([redacted])")
523    }
524}
525
526/// An encrypted offline invitation envelope safe to store without its bearer secret.
527#[derive(Debug, Clone, PartialEq, Eq)]
528pub struct InvitationTicket {
529    nonce: [u8; 24],
530    ciphertext: Vec<u8>,
531}
532
533impl InvitationTicket {
534    /// Decrypts and validates a ticket for exactly the local endpoint identity.
535    pub fn open(
536        &self,
537        secret: &InvitationSecret,
538        local_subject: AuthorId,
539    ) -> Result<Invitation, InvitationError> {
540        let plaintext = XChaCha20Poly1305::new((&secret.0).into())
541            .decrypt(
542                &XNonce::from(self.nonce),
543                Payload {
544                    msg: &self.ciphertext,
545                    aad: TICKET_AAD,
546                },
547            )
548            .map_err(|_| InvitationError::DecryptionFailed)?;
549        let invitation = Invitation::decode_canonical(&plaintext)?;
550        if invitation.subject != local_subject {
551            return Err(InvitationError::WrongSubject);
552        }
553        Ok(invitation)
554    }
555
556    /// Strictly encodes the opaque ticket.
557    pub fn encode_canonical(&self) -> Result<Vec<u8>, InvitationError> {
558        minicbor::to_vec(TicketWire {
559            version: INVITATION_VERSION,
560            nonce: self.nonce,
561            ciphertext: self.ciphertext.clone(),
562        })
563        .map_err(codec_error)
564    }
565
566    /// Returns the content identifier used for durable one-time import consumption.
567    pub fn id(&self) -> Result<[u8; 32], InvitationError> {
568        Ok(*blake3::hash(&self.encode_canonical()?).as_bytes())
569    }
570
571    /// Strictly decodes an opaque ticket without attempting decryption.
572    pub fn decode_canonical(bytes: &[u8]) -> Result<Self, InvitationError> {
573        let wire: TicketWire = decode_exact(bytes)?;
574        if wire.version != INVITATION_VERSION {
575            return Err(InvitationError::UnsupportedVersion(wire.version));
576        }
577        let ticket = Self {
578            nonce: wire.nonce,
579            ciphertext: wire.ciphertext,
580        };
581        if ticket.encode_canonical()? != bytes {
582            return Err(InvitationError::NonCanonical);
583        }
584        Ok(ticket)
585    }
586}
587
588/// A one-time offline invitation bearer secret.
589#[derive(Clone, Zeroize, ZeroizeOnDrop)]
590pub struct InvitationSecret([u8; 32]);
591
592impl std::fmt::Debug for InvitationSecret {
593    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
594        formatter.write_str("InvitationSecret([redacted])")
595    }
596}
597
598#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
599pub enum InvitationError {
600    #[error("invitation codec failed: {0}")]
601    Codec(String),
602    #[error("invitation is not canonical")]
603    NonCanonical,
604    #[error("unsupported invitation version: {0}")]
605    UnsupportedVersion(u16),
606    #[error("invitation capability chain is invalid")]
607    InvalidChain,
608    #[error("invitation signature is invalid")]
609    InvalidSignature,
610    #[error("invitation endpoint key is invalid")]
611    InvalidAuthor,
612    #[error("invitation targets another endpoint")]
613    WrongSubject,
614    #[error("invitation encryption failed")]
615    EncryptionFailed,
616    #[error("invitation ticket decryption failed")]
617    DecryptionFailed,
618    #[error("cryptographic randomness is unavailable")]
619    RandomnessUnavailable,
620    #[error("invitation exceeds its bound: {0}")]
621    TooLarge(usize),
622    #[error("authority validation failed: {0}")]
623    Authority(String),
624}
625
626#[derive(minicbor::Encode, minicbor::Decode)]
627#[cbor(array)]
628struct InvitationContent {
629    #[n(0)]
630    version: u16,
631    #[n(1)]
632    descriptor: Vec<u8>,
633    #[n(2)]
634    capability_chain: Vec<Vec<u8>>,
635    #[n(3)]
636    domain_id: DomainId,
637    #[n(4)]
638    epoch: u64,
639    #[n(5)]
640    domain_key: [u8; 32],
641    #[n(6)]
642    subject: AuthorId,
643    #[n(7)]
644    nonce: [u8; 32],
645    #[n(8)]
646    controller_chain: Vec<Vec<u8>>,
647    #[n(9)]
648    checkpoint: Vec<u8>,
649    #[n(10)]
650    authority_evidence: Vec<Vec<u8>>,
651}
652
653#[derive(minicbor::Encode, minicbor::Decode)]
654#[cbor(array)]
655struct InvitationWire {
656    #[n(0)]
657    content: Vec<u8>,
658    #[n(1)]
659    issuer: AuthorId,
660    #[n(2)]
661    signature: [u8; 64],
662}
663
664#[derive(minicbor::Encode, minicbor::Decode)]
665#[cbor(array)]
666struct TicketWire {
667    #[n(0)]
668    version: u16,
669    #[n(1)]
670    nonce: [u8; 24],
671    #[n(2)]
672    ciphertext: Vec<u8>,
673}
674
675fn verify_signature(
676    author: AuthorId,
677    message: &[u8],
678    signature: &[u8; 64],
679) -> Result<(), InvitationError> {
680    let public_key = iroh_base::PublicKey::try_from(author.as_bytes())
681        .map_err(|_| InvitationError::InvalidAuthor)?;
682    public_key
683        .verify(message, &iroh_base::Signature::from_bytes(signature))
684        .map_err(|_| InvitationError::InvalidSignature)
685}
686
687fn decode_exact<T>(bytes: &[u8]) -> Result<T, InvitationError>
688where
689    T: for<'bytes> minicbor::Decode<'bytes, ()>,
690{
691    let mut decoder = minicbor::Decoder::new(bytes);
692    let value = decoder.decode().map_err(codec_error)?;
693    if decoder.position() != bytes.len() {
694        return Err(InvitationError::NonCanonical);
695    }
696    Ok(value)
697}
698
699#[allow(clippy::needless_pass_by_value)]
700fn codec_error(error: impl std::fmt::Display) -> InvitationError {
701    InvitationError::Codec(error.to_string())
702}
703
704#[allow(clippy::needless_pass_by_value)]
705fn authority_error(error: impl std::fmt::Display) -> InvitationError {
706    InvitationError::Authority(error.to_string())
707}