1use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
30use ed25519_dalek::{Signature, Verifier, VerifyingKey};
31use serde::{Deserialize, Serialize};
32use sha2::{Digest, Sha256};
33
34use crate::attestation::{Signer, SignerError};
35
36pub const TYPE_INVITATION: &str = "treeship/invitation/v1";
41
42pub const MAX_INVITATION_LIFETIME_SECS: u64 = 7 * 24 * 60 * 60;
48
49pub const DEFAULT_INVITATION_LIFETIME_SECS: u64 = 60 * 60;
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(tag = "kind", rename_all = "snake_case")]
61pub enum InviteeRestriction {
62 Pubkey { fingerprint: String },
66 Cert {
69 issuer_pubkey: String,
70 allowed_subjects: Vec<String>,
71 },
72 Open,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct GrantedCapabilities {
82 #[serde(default)]
86 pub action_types: Vec<String>,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct InvitationStatement {
93 #[serde(rename = "type")]
94 pub type_: String,
95
96 pub session_ref: String,
98
99 pub issuer: String,
103
104 pub invitee_restriction: InviteeRestriction,
105
106 pub granted_capabilities: GrantedCapabilities,
107
108 pub expires_at: String,
110
111 pub max_uses: u32,
114
115 pub nonce: String,
118}
119
120impl InvitationStatement {
121 pub fn new(
123 session_ref: impl Into<String>,
124 issuer: impl Into<String>,
125 invitee_restriction: InviteeRestriction,
126 granted_capabilities: GrantedCapabilities,
127 expires_at: impl Into<String>,
128 nonce: impl Into<String>,
129 ) -> Self {
130 Self {
131 type_: TYPE_INVITATION.into(),
132 session_ref: session_ref.into(),
133 issuer: issuer.into(),
134 invitee_restriction,
135 granted_capabilities,
136 expires_at: expires_at.into(),
137 max_uses: 1,
138 nonce: nonce.into(),
139 }
140 }
141
142 pub fn canonical_for_signing(&self) -> String {
162 let restriction_digest = canonical_json_digest(&self.invitee_restriction);
163 let capabilities_digest = canonical_json_digest(&self.granted_capabilities);
164 let nonce_d = nonce_digest_hex(&self.nonce);
165 format!(
166 "v1|invitation|{}|{}|{}|{}|{}|{}|{}",
167 self.session_ref,
168 self.issuer,
169 restriction_digest,
170 capabilities_digest,
171 self.expires_at,
172 self.max_uses,
173 nonce_d,
174 )
175 }
176
177 pub fn sign_canonical(&self, signer: &dyn Signer) -> Result<String, SignerError> {
183 let canonical = self.canonical_for_signing();
184 let sig = signer.sign(canonical.as_bytes())?;
185 Ok(URL_SAFE_NO_PAD.encode(sig))
186 }
187
188 pub fn verify_canonical(&self, signature_b64url: &str) -> bool {
194 let pk_bytes = match URL_SAFE_NO_PAD.decode(self.issuer.as_bytes()) {
195 Ok(b) if b.len() == 32 => b,
196 _ => return false,
197 };
198 let sig_bytes = match URL_SAFE_NO_PAD.decode(signature_b64url.as_bytes()) {
199 Ok(b) if b.len() == 64 => b,
200 _ => return false,
201 };
202 let mut pk_arr = [0u8; 32];
203 pk_arr.copy_from_slice(&pk_bytes);
204 let mut sig_arr = [0u8; 64];
205 sig_arr.copy_from_slice(&sig_bytes);
206 let vk = match VerifyingKey::from_bytes(&pk_arr) {
207 Ok(k) => k,
208 Err(_) => return false,
209 };
210 let sig = Signature::from_bytes(&sig_arr);
211 vk.verify_strict(self.canonical_for_signing().as_bytes(), &sig)
212 .is_ok()
213 }
214
215 pub fn validate_for_mint(&self, now_unix_secs: u64) -> Result<(), InvitationError> {
225 if self.session_ref.trim().is_empty() {
226 return Err(InvitationError::EmptyField("session_ref"));
227 }
228 if self.nonce.trim().is_empty() {
229 return Err(InvitationError::EmptyField("nonce"));
230 }
231 if self.max_uses != 1 {
232 return Err(InvitationError::MaxUsesUnsupported {
233 max_uses: self.max_uses,
234 });
235 }
236 let pk_bytes = URL_SAFE_NO_PAD
238 .decode(self.issuer.as_bytes())
239 .map_err(|_| InvitationError::IssuerNotEd25519)?;
240 if pk_bytes.len() != 32 {
241 return Err(InvitationError::IssuerNotEd25519);
242 }
243 let expires_secs =
244 parse_rfc3339_to_unix(&self.expires_at).ok_or(InvitationError::ExpiresAtNotRfc3339)?;
245 if expires_secs <= now_unix_secs {
246 return Err(InvitationError::ExpiresInPast);
247 }
248 let lifetime = expires_secs - now_unix_secs;
249 if lifetime > MAX_INVITATION_LIFETIME_SECS {
250 return Err(InvitationError::LifetimeTooLong {
251 requested_secs: lifetime,
252 max_secs: MAX_INVITATION_LIFETIME_SECS,
253 });
254 }
255 Ok(())
256 }
257
258 pub fn is_expired(&self, now_unix_secs: u64) -> bool {
262 match parse_rfc3339_to_unix(&self.expires_at) {
263 Some(secs) => now_unix_secs >= secs,
264 None => true,
265 }
266 }
267
268 pub fn nonce_digest(&self) -> String {
272 nonce_digest_hex(&self.nonce)
273 }
274}
275
276#[derive(Debug, Clone, PartialEq, Eq)]
281pub enum InvitationError {
282 EmptyField(&'static str),
283 IssuerNotEd25519,
284 ExpiresAtNotRfc3339,
285 ExpiresInPast,
286 LifetimeTooLong { requested_secs: u64, max_secs: u64 },
287 MaxUsesUnsupported { max_uses: u32 },
288}
289
290impl std::fmt::Display for InvitationError {
291 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292 match self {
293 Self::EmptyField(name) => write!(f, "invitation field {name} must not be empty"),
294 Self::IssuerNotEd25519 => write!(
295 f,
296 "invitation issuer must decode to a 32-byte Ed25519 public key (base64url-no-pad)",
297 ),
298 Self::ExpiresAtNotRfc3339 => write!(
299 f,
300 "invitation expires_at must be RFC 3339 (e.g. 2026-05-18T12:00:00Z)",
301 ),
302 Self::ExpiresInPast => write!(
303 f,
304 "invitation expires_at must be in the future at mint time"
305 ),
306 Self::LifetimeTooLong {
307 requested_secs,
308 max_secs,
309 } => write!(
310 f,
311 "invitation lifetime {requested_secs}s exceeds protocol max {max_secs}s ({} days)",
312 max_secs / (24 * 60 * 60),
313 ),
314 Self::MaxUsesUnsupported { max_uses } => write!(
315 f,
316 "invitation max_uses must be 1 in Phase 1 (got {max_uses}); \
317 multi-use invitations are a future-version feature",
318 ),
319 }
320 }
321}
322
323impl std::error::Error for InvitationError {}
324
325pub(crate) fn canonical_json_digest<T: Serialize>(value: &T) -> String {
339 let json_value = serde_json::to_value(value)
340 .expect("canonical_json_digest: serialize must not fail for in-crate types");
341 let canonical = canonical_json_string(&json_value);
342 let digest = Sha256::digest(canonical.as_bytes());
343 format!("sha256:{}", hex::encode(digest))
344}
345
346fn canonical_json_string(value: &serde_json::Value) -> String {
351 use std::collections::BTreeMap;
352 match value {
353 serde_json::Value::Object(map) => {
354 let sorted: BTreeMap<&String, String> = map
355 .iter()
356 .map(|(k, v)| (k, canonical_json_string(v)))
357 .collect();
358 let mut out = String::from("{");
359 let mut first = true;
360 for (k, v) in sorted {
361 if !first {
362 out.push(',');
363 }
364 first = false;
365 let key_json = serde_json::to_string(k).expect("string serializes to JSON");
366 out.push_str(&key_json);
367 out.push(':');
368 out.push_str(&v);
369 }
370 out.push('}');
371 out
372 }
373 serde_json::Value::Array(items) => {
374 let mut out = String::from("[");
375 let mut first = true;
376 for v in items {
377 if !first {
378 out.push(',');
379 }
380 first = false;
381 out.push_str(&canonical_json_string(v));
382 }
383 out.push(']');
384 out
385 }
386 other => serde_json::to_string(other).expect("scalar JSON value serializes"),
387 }
388}
389
390fn nonce_digest_hex(raw_nonce: &str) -> String {
394 let digest = Sha256::digest(raw_nonce.as_bytes());
395 format!("sha256:{}", hex::encode(digest))
396}
397
398pub fn parse_rfc3339_to_unix(s: &str) -> Option<u64> {
407 let b = s.as_bytes();
409 if b.len() != 20
410 || b[10] != b'T'
411 || b[19] != b'Z'
412 || b[4] != b'-'
413 || b[7] != b'-'
414 || b[13] != b':'
415 || b[16] != b':'
416 {
417 return None;
418 }
419 let year: i64 = std::str::from_utf8(&b[0..4]).ok()?.parse().ok()?;
420 let month: u32 = std::str::from_utf8(&b[5..7]).ok()?.parse().ok()?;
421 let day: u32 = std::str::from_utf8(&b[8..10]).ok()?.parse().ok()?;
422 let hour: u32 = std::str::from_utf8(&b[11..13]).ok()?.parse().ok()?;
423 let min: u32 = std::str::from_utf8(&b[14..16]).ok()?.parse().ok()?;
424 let sec: u32 = std::str::from_utf8(&b[17..19]).ok()?.parse().ok()?;
425 if !(1970..=9999).contains(&year)
426 || !(1..=12).contains(&month)
427 || !(1..=31).contains(&day)
428 || hour > 23
429 || min > 59
430 || sec > 60
431 {
432 return None;
433 }
434 let mut days: i64 = 0;
436 for y in 1970..year {
437 days += if is_leap(y as u64) { 366 } else { 365 };
438 }
439 let months = if is_leap(year as u64) {
440 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
441 } else {
442 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
443 };
444 for m in 1..month {
445 days += months[(m - 1) as usize];
446 }
447 days += (day - 1) as i64;
448 let total = days * 86_400 + (hour as i64) * 3600 + (min as i64) * 60 + (sec as i64);
449 if total < 0 {
450 return None;
451 }
452 Some(total as u64)
453}
454
455fn is_leap(y: u64) -> bool {
456 (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0)
457}
458
459pub fn generate_nonce() -> String {
462 use rand::{rngs::OsRng, RngCore};
463 let mut buf = [0u8; 16];
464 OsRng.fill_bytes(&mut buf);
465 hex::encode(buf)
466}
467
468pub fn pubkey_fingerprint_short(canonical_pk: &str) -> String {
473 let bytes = Sha256::digest(canonical_pk.as_bytes());
474 hex::encode(bytes)[..16].to_string()
475}
476
477#[cfg(test)]
482mod tests {
483 use super::*;
484 use crate::attestation::Ed25519Signer;
485
486 fn sample_caps() -> GrantedCapabilities {
487 GrantedCapabilities {
488 action_types: vec!["tool.call".into(), "agent.handoff".into()],
489 }
490 }
491
492 fn host_signer() -> Ed25519Signer {
493 Ed25519Signer::from_bytes("host_key", &[7u8; 32]).unwrap()
494 }
495
496 fn fixed_now() -> u64 {
497 1_779_580_800
499 }
500
501 fn one_hour_after(now: u64) -> String {
502 crate::statements::unix_to_rfc3339(now + 3600)
503 }
504
505 fn sample(restriction: InviteeRestriction) -> InvitationStatement {
506 let signer = host_signer();
507 let issuer = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
508 InvitationStatement::new(
509 "ssn_room_abc",
510 issuer,
511 restriction,
512 sample_caps(),
513 one_hour_after(fixed_now()),
514 "nonce_deadbeef",
515 )
516 }
517
518 #[test]
519 fn invitation_round_trips_serde() {
520 let inv = sample(InviteeRestriction::Open);
521 let bytes = serde_json::to_vec(&inv).unwrap();
522 let back: InvitationStatement = serde_json::from_slice(&bytes).unwrap();
523 assert_eq!(back.session_ref, inv.session_ref);
524 assert_eq!(back.type_, TYPE_INVITATION);
525 assert_eq!(back.max_uses, 1);
526 }
527
528 #[test]
533 fn invitation_canonical_includes_all_fields() {
534 let base = sample(InviteeRestriction::Cert {
535 issuer_pubkey: "ed25519:AAA".into(),
536 allowed_subjects: vec!["org-x".into()],
537 });
538 let base_canonical = base.canonical_for_signing();
539
540 let mut m1 = base.clone();
541 m1.session_ref = "ssn_other".into();
542 assert_ne!(
543 m1.canonical_for_signing(),
544 base_canonical,
545 "session_ref must bind"
546 );
547
548 let mut m2 = base.clone();
549 m2.issuer = URL_SAFE_NO_PAD.encode([9u8; 32]);
550 assert_ne!(
551 m2.canonical_for_signing(),
552 base_canonical,
553 "issuer must bind"
554 );
555
556 let mut m3 = base.clone();
557 m3.invitee_restriction = InviteeRestriction::Open;
558 assert_ne!(
559 m3.canonical_for_signing(),
560 base_canonical,
561 "restriction must bind"
562 );
563
564 let mut m4 = base.clone();
565 m4.granted_capabilities
566 .action_types
567 .push("extra.cap".into());
568 assert_ne!(
569 m4.canonical_for_signing(),
570 base_canonical,
571 "capabilities must bind"
572 );
573
574 let mut m5 = base.clone();
575 m5.expires_at = one_hour_after(fixed_now() + 1);
576 assert_ne!(
577 m5.canonical_for_signing(),
578 base_canonical,
579 "expires_at must bind"
580 );
581
582 let mut m6 = base.clone();
586 m6.max_uses = 2;
587 assert_ne!(
588 m6.canonical_for_signing(),
589 base_canonical,
590 "max_uses must bind"
591 );
592
593 let mut m7 = base.clone();
594 m7.nonce = "nonce_other".into();
595 assert_ne!(
596 m7.canonical_for_signing(),
597 base_canonical,
598 "nonce must bind"
599 );
600 }
601
602 #[test]
603 fn invitation_sign_and_verify_roundtrip() {
604 let inv = sample(InviteeRestriction::Open);
605 let signer = host_signer();
606 let sig = inv.sign_canonical(&signer).unwrap();
607 assert!(inv.verify_canonical(&sig));
608 }
609
610 #[test]
611 fn invitation_verify_rejects_wrong_signature() {
612 let inv = sample(InviteeRestriction::Open);
613 let attacker = Ed25519Signer::from_bytes("att", &[3u8; 32]).unwrap();
615 let sig = inv.sign_canonical(&attacker).unwrap();
616 assert!(!inv.verify_canonical(&sig));
617 }
618
619 #[test]
620 fn invitation_verify_rejects_tampered_canonical() {
621 let mut inv = sample(InviteeRestriction::Open);
622 let signer = host_signer();
623 let sig = inv.sign_canonical(&signer).unwrap();
624 inv.session_ref = "ssn_tampered".into();
626 assert!(!inv.verify_canonical(&sig));
627 }
628
629 #[test]
631 fn invitation_expiry_max_7d_enforced() {
632 let now = fixed_now();
633 let signer = host_signer();
634 let issuer = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
635
636 let too_long = crate::statements::unix_to_rfc3339(now + MAX_INVITATION_LIFETIME_SECS + 1);
638 let inv = InvitationStatement::new(
639 "ssn_a",
640 issuer.clone(),
641 InviteeRestriction::Open,
642 sample_caps(),
643 too_long,
644 "n1",
645 );
646 match inv.validate_for_mint(now) {
647 Err(InvitationError::LifetimeTooLong { .. }) => {}
648 other => panic!("expected LifetimeTooLong, got {other:?}"),
649 }
650
651 let exact = crate::statements::unix_to_rfc3339(now + MAX_INVITATION_LIFETIME_SECS);
653 let inv_ok = InvitationStatement::new(
654 "ssn_a",
655 issuer,
656 InviteeRestriction::Open,
657 sample_caps(),
658 exact,
659 "n2",
660 );
661 assert!(inv_ok.validate_for_mint(now).is_ok());
662 }
663
664 #[test]
665 fn invitation_validate_rejects_past_expiry() {
666 let now = fixed_now();
667 let signer = host_signer();
668 let issuer = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
669 let past = crate::statements::unix_to_rfc3339(now - 60);
670 let inv = InvitationStatement::new(
671 "ssn_a",
672 issuer,
673 InviteeRestriction::Open,
674 sample_caps(),
675 past,
676 "n",
677 );
678 assert_eq!(
679 inv.validate_for_mint(now),
680 Err(InvitationError::ExpiresInPast)
681 );
682 }
683
684 #[test]
685 fn invitation_validate_rejects_max_uses_not_one() {
686 let now = fixed_now();
687 let mut inv = sample(InviteeRestriction::Open);
688 inv.max_uses = 2;
689 match inv.validate_for_mint(now) {
690 Err(InvitationError::MaxUsesUnsupported { max_uses }) => assert_eq!(max_uses, 2),
691 other => panic!("expected MaxUsesUnsupported, got {other:?}"),
692 }
693 }
694
695 #[test]
697 fn invitation_pubkey_restriction_enforced() {
698 let signer_a = Ed25519Signer::from_bytes("a", &[1u8; 32]).unwrap();
700 let signer_b = Ed25519Signer::from_bytes("b", &[2u8; 32]).unwrap();
701 let fp_a = pubkey_fingerprint_short(&format!(
702 "ed25519:{}",
703 URL_SAFE_NO_PAD.encode(signer_a.public_key_bytes()),
704 ));
705 let fp_b = pubkey_fingerprint_short(&format!(
706 "ed25519:{}",
707 URL_SAFE_NO_PAD.encode(signer_b.public_key_bytes()),
708 ));
709 assert_ne!(fp_a, fp_b);
710
711 let restriction = InviteeRestriction::Pubkey {
712 fingerprint: fp_a.clone(),
713 };
714
715 let accept_for = |fp: &str| {
718 matches!(
719 &restriction,
720 InviteeRestriction::Pubkey { fingerprint } if fingerprint == fp,
721 )
722 };
723 assert!(accept_for(&fp_a), "matching pubkey must be accepted");
724 assert!(!accept_for(&fp_b), "non-matching pubkey must be rejected");
725 }
726
727 #[test]
729 fn invitation_cert_restriction_enforced() {
730 let restriction = InviteeRestriction::Cert {
731 issuer_pubkey: "ed25519:ISSUER_X".into(),
732 allowed_subjects: vec!["org-x".into(), "org-y".into()],
733 };
734 let accept = |iss: &str, subj: &str| {
736 matches!(
737 &restriction,
738 InviteeRestriction::Cert { issuer_pubkey, allowed_subjects }
739 if issuer_pubkey == iss && allowed_subjects.iter().any(|s| s == subj),
740 )
741 };
742
743 assert!(
744 accept("ed25519:ISSUER_X", "org-x"),
745 "matching issuer+subject accepted"
746 );
747 assert!(
748 !accept("ed25519:ISSUER_OTHER", "org-x"),
749 "wrong issuer rejected"
750 );
751 assert!(
752 !accept("ed25519:ISSUER_X", "org-z"),
753 "wrong subject rejected"
754 );
755 }
756
757 #[test]
759 fn invitation_open_restriction_works() {
760 let restriction = InviteeRestriction::Open;
761 let is_open = matches!(restriction, InviteeRestriction::Open);
765 assert!(is_open);
766 }
767
768 #[test]
769 fn invitation_is_expired_returns_true_past_expiry() {
770 let now = fixed_now();
771 let inv = InvitationStatement::new(
772 "ssn_a",
773 URL_SAFE_NO_PAD.encode([5u8; 32]),
774 InviteeRestriction::Open,
775 sample_caps(),
776 crate::statements::unix_to_rfc3339(now - 1),
777 "n",
778 );
779 assert!(inv.is_expired(now));
780 }
781
782 #[test]
787 fn invitation_nonce_digest_matches_journal_helper() {
788 let inv = sample(InviteeRestriction::Open);
789 assert_eq!(
790 inv.nonce_digest(),
791 crate::statements::nonce_digest(&inv.nonce),
792 );
793 }
794
795 #[test]
796 fn parse_rfc3339_round_trips() {
797 let now = fixed_now();
798 let s = crate::statements::unix_to_rfc3339(now);
799 assert_eq!(parse_rfc3339_to_unix(&s), Some(now));
800
801 assert_eq!(parse_rfc3339_to_unix("not a timestamp"), None);
803 assert_eq!(parse_rfc3339_to_unix("2026-05-18T00:00:00"), None); assert_eq!(parse_rfc3339_to_unix("2026-13-18T00:00:00Z"), None); }
806}