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#[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 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 #[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 #[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 pub const fn subject(&self) -> AuthorId {
167 self.subject
168 }
169
170 pub const fn issuer(&self) -> AuthorId {
171 self.issuer
172 }
173
174 pub const fn descriptor(&self) -> &DomainDescriptor {
176 &self.descriptor
177 }
178
179 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 pub fn domain_seed(&self) -> DomainSeed {
198 self.seed.clone()
199 }
200
201 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
528pub struct InvitationTicket {
529 nonce: [u8; 24],
530 ciphertext: Vec<u8>,
531}
532
533impl InvitationTicket {
534 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 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 pub fn id(&self) -> Result<[u8; 32], InvitationError> {
568 Ok(*blake3::hash(&self.encode_canonical()?).as_bytes())
569 }
570
571 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#[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}