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(self.canonical_for_signing().as_bytes(), &sig).is_ok()
212 }
213
214 pub fn validate_for_mint(&self, now_unix_secs: u64) -> Result<(), InvitationError> {
224 if self.session_ref.trim().is_empty() {
225 return Err(InvitationError::EmptyField("session_ref"));
226 }
227 if self.nonce.trim().is_empty() {
228 return Err(InvitationError::EmptyField("nonce"));
229 }
230 if self.max_uses != 1 {
231 return Err(InvitationError::MaxUsesUnsupported { max_uses: self.max_uses });
232 }
233 let pk_bytes = URL_SAFE_NO_PAD
235 .decode(self.issuer.as_bytes())
236 .map_err(|_| InvitationError::IssuerNotEd25519)?;
237 if pk_bytes.len() != 32 {
238 return Err(InvitationError::IssuerNotEd25519);
239 }
240 let expires_secs = parse_rfc3339_to_unix(&self.expires_at)
241 .ok_or(InvitationError::ExpiresAtNotRfc3339)?;
242 if expires_secs <= now_unix_secs {
243 return Err(InvitationError::ExpiresInPast);
244 }
245 let lifetime = expires_secs - now_unix_secs;
246 if lifetime > MAX_INVITATION_LIFETIME_SECS {
247 return Err(InvitationError::LifetimeTooLong {
248 requested_secs: lifetime,
249 max_secs: MAX_INVITATION_LIFETIME_SECS,
250 });
251 }
252 Ok(())
253 }
254
255 pub fn is_expired(&self, now_unix_secs: u64) -> bool {
259 match parse_rfc3339_to_unix(&self.expires_at) {
260 Some(secs) => now_unix_secs >= secs,
261 None => true,
262 }
263 }
264
265 pub fn nonce_digest(&self) -> String {
269 nonce_digest_hex(&self.nonce)
270 }
271}
272
273#[derive(Debug, Clone, PartialEq, Eq)]
278pub enum InvitationError {
279 EmptyField(&'static str),
280 IssuerNotEd25519,
281 ExpiresAtNotRfc3339,
282 ExpiresInPast,
283 LifetimeTooLong { requested_secs: u64, max_secs: u64 },
284 MaxUsesUnsupported { max_uses: u32 },
285}
286
287impl std::fmt::Display for InvitationError {
288 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289 match self {
290 Self::EmptyField(name) => write!(f, "invitation field {name} must not be empty"),
291 Self::IssuerNotEd25519 => write!(
292 f,
293 "invitation issuer must decode to a 32-byte Ed25519 public key (base64url-no-pad)",
294 ),
295 Self::ExpiresAtNotRfc3339 => write!(
296 f,
297 "invitation expires_at must be RFC 3339 (e.g. 2026-05-18T12:00:00Z)",
298 ),
299 Self::ExpiresInPast => write!(f, "invitation expires_at must be in the future at mint time"),
300 Self::LifetimeTooLong { requested_secs, max_secs } => write!(
301 f,
302 "invitation lifetime {requested_secs}s exceeds protocol max {max_secs}s ({} days)",
303 max_secs / (24 * 60 * 60),
304 ),
305 Self::MaxUsesUnsupported { max_uses } => write!(
306 f,
307 "invitation max_uses must be 1 in Phase 1 (got {max_uses}); \
308 multi-use invitations are a future-version feature",
309 ),
310 }
311 }
312}
313
314impl std::error::Error for InvitationError {}
315
316pub(crate) fn canonical_json_digest<T: Serialize>(value: &T) -> String {
330 let json_value = serde_json::to_value(value)
331 .expect("canonical_json_digest: serialize must not fail for in-crate types");
332 let canonical = canonical_json_string(&json_value);
333 let digest = Sha256::digest(canonical.as_bytes());
334 format!("sha256:{}", hex::encode(digest))
335}
336
337fn canonical_json_string(value: &serde_json::Value) -> String {
342 use std::collections::BTreeMap;
343 match value {
344 serde_json::Value::Object(map) => {
345 let sorted: BTreeMap<&String, String> = map
346 .iter()
347 .map(|(k, v)| (k, canonical_json_string(v)))
348 .collect();
349 let mut out = String::from("{");
350 let mut first = true;
351 for (k, v) in sorted {
352 if !first { out.push(','); }
353 first = false;
354 let key_json = serde_json::to_string(k)
355 .expect("string serializes to JSON");
356 out.push_str(&key_json);
357 out.push(':');
358 out.push_str(&v);
359 }
360 out.push('}');
361 out
362 }
363 serde_json::Value::Array(items) => {
364 let mut out = String::from("[");
365 let mut first = true;
366 for v in items {
367 if !first { out.push(','); }
368 first = false;
369 out.push_str(&canonical_json_string(v));
370 }
371 out.push(']');
372 out
373 }
374 other => serde_json::to_string(other)
375 .expect("scalar JSON value serializes"),
376 }
377}
378
379fn nonce_digest_hex(raw_nonce: &str) -> String {
383 let digest = Sha256::digest(raw_nonce.as_bytes());
384 format!("sha256:{}", hex::encode(digest))
385}
386
387fn parse_rfc3339_to_unix(s: &str) -> Option<u64> {
396 let b = s.as_bytes();
398 if b.len() != 20 || b[10] != b'T' || b[19] != b'Z'
399 || b[4] != b'-' || b[7] != b'-'
400 || b[13] != b':' || b[16] != b':'
401 {
402 return None;
403 }
404 let year: i64 = std::str::from_utf8(&b[0..4]).ok()?.parse().ok()?;
405 let month: u32 = std::str::from_utf8(&b[5..7]).ok()?.parse().ok()?;
406 let day: u32 = std::str::from_utf8(&b[8..10]).ok()?.parse().ok()?;
407 let hour: u32 = std::str::from_utf8(&b[11..13]).ok()?.parse().ok()?;
408 let min: u32 = std::str::from_utf8(&b[14..16]).ok()?.parse().ok()?;
409 let sec: u32 = std::str::from_utf8(&b[17..19]).ok()?.parse().ok()?;
410 if !(1970..=9999).contains(&year)
411 || !(1..=12).contains(&month)
412 || !(1..=31).contains(&day)
413 || hour > 23 || min > 59 || sec > 60
414 {
415 return None;
416 }
417 let mut days: i64 = 0;
419 for y in 1970..year {
420 days += if is_leap(y as u64) { 366 } else { 365 };
421 }
422 let months = if is_leap(year as u64) {
423 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
424 } else {
425 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
426 };
427 for m in 1..month {
428 days += months[(m - 1) as usize];
429 }
430 days += (day - 1) as i64;
431 let total = days * 86_400 + (hour as i64) * 3600 + (min as i64) * 60 + (sec as i64);
432 if total < 0 { return None; }
433 Some(total as u64)
434}
435
436fn is_leap(y: u64) -> bool {
437 (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0)
438}
439
440pub fn generate_nonce() -> String {
443 use rand::{rngs::OsRng, RngCore};
444 let mut buf = [0u8; 16];
445 OsRng.fill_bytes(&mut buf);
446 hex::encode(buf)
447}
448
449pub fn pubkey_fingerprint_short(canonical_pk: &str) -> String {
454 let bytes = Sha256::digest(canonical_pk.as_bytes());
455 hex::encode(bytes)[..16].to_string()
456}
457
458#[cfg(test)]
463mod tests {
464 use super::*;
465 use crate::attestation::Ed25519Signer;
466
467 fn sample_caps() -> GrantedCapabilities {
468 GrantedCapabilities {
469 action_types: vec!["tool.call".into(), "agent.handoff".into()],
470 }
471 }
472
473 fn host_signer() -> Ed25519Signer {
474 Ed25519Signer::from_bytes("host_key", &[7u8; 32]).unwrap()
475 }
476
477 fn fixed_now() -> u64 {
478 1_779_580_800
480 }
481
482 fn one_hour_after(now: u64) -> String {
483 crate::statements::unix_to_rfc3339(now + 3600)
484 }
485
486 fn sample(restriction: InviteeRestriction) -> InvitationStatement {
487 let signer = host_signer();
488 let issuer = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
489 InvitationStatement::new(
490 "ssn_room_abc",
491 issuer,
492 restriction,
493 sample_caps(),
494 one_hour_after(fixed_now()),
495 "nonce_deadbeef",
496 )
497 }
498
499 #[test]
500 fn invitation_round_trips_serde() {
501 let inv = sample(InviteeRestriction::Open);
502 let bytes = serde_json::to_vec(&inv).unwrap();
503 let back: InvitationStatement = serde_json::from_slice(&bytes).unwrap();
504 assert_eq!(back.session_ref, inv.session_ref);
505 assert_eq!(back.type_, TYPE_INVITATION);
506 assert_eq!(back.max_uses, 1);
507 }
508
509 #[test]
514 fn invitation_canonical_includes_all_fields() {
515 let base = sample(InviteeRestriction::Cert {
516 issuer_pubkey: "ed25519:AAA".into(),
517 allowed_subjects: vec!["org-x".into()],
518 });
519 let base_canonical = base.canonical_for_signing();
520
521 let mut m1 = base.clone(); m1.session_ref = "ssn_other".into();
522 assert_ne!(m1.canonical_for_signing(), base_canonical, "session_ref must bind");
523
524 let mut m2 = base.clone(); m2.issuer = URL_SAFE_NO_PAD.encode([9u8; 32]);
525 assert_ne!(m2.canonical_for_signing(), base_canonical, "issuer must bind");
526
527 let mut m3 = base.clone();
528 m3.invitee_restriction = InviteeRestriction::Open;
529 assert_ne!(m3.canonical_for_signing(), base_canonical, "restriction must bind");
530
531 let mut m4 = base.clone();
532 m4.granted_capabilities.action_types.push("extra.cap".into());
533 assert_ne!(m4.canonical_for_signing(), base_canonical, "capabilities must bind");
534
535 let mut m5 = base.clone(); m5.expires_at = one_hour_after(fixed_now() + 1);
536 assert_ne!(m5.canonical_for_signing(), base_canonical, "expires_at must bind");
537
538 let mut m6 = base.clone(); m6.max_uses = 2;
542 assert_ne!(m6.canonical_for_signing(), base_canonical, "max_uses must bind");
543
544 let mut m7 = base.clone(); m7.nonce = "nonce_other".into();
545 assert_ne!(m7.canonical_for_signing(), base_canonical, "nonce must bind");
546 }
547
548 #[test]
549 fn invitation_sign_and_verify_roundtrip() {
550 let inv = sample(InviteeRestriction::Open);
551 let signer = host_signer();
552 let sig = inv.sign_canonical(&signer).unwrap();
553 assert!(inv.verify_canonical(&sig));
554 }
555
556 #[test]
557 fn invitation_verify_rejects_wrong_signature() {
558 let inv = sample(InviteeRestriction::Open);
559 let attacker = Ed25519Signer::from_bytes("att", &[3u8; 32]).unwrap();
561 let sig = inv.sign_canonical(&attacker).unwrap();
562 assert!(!inv.verify_canonical(&sig));
563 }
564
565 #[test]
566 fn invitation_verify_rejects_tampered_canonical() {
567 let mut inv = sample(InviteeRestriction::Open);
568 let signer = host_signer();
569 let sig = inv.sign_canonical(&signer).unwrap();
570 inv.session_ref = "ssn_tampered".into();
572 assert!(!inv.verify_canonical(&sig));
573 }
574
575 #[test]
577 fn invitation_expiry_max_7d_enforced() {
578 let now = fixed_now();
579 let signer = host_signer();
580 let issuer = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
581
582 let too_long = crate::statements::unix_to_rfc3339(now + MAX_INVITATION_LIFETIME_SECS + 1);
584 let inv = InvitationStatement::new(
585 "ssn_a", issuer.clone(),
586 InviteeRestriction::Open, sample_caps(),
587 too_long, "n1",
588 );
589 match inv.validate_for_mint(now) {
590 Err(InvitationError::LifetimeTooLong { .. }) => {}
591 other => panic!("expected LifetimeTooLong, got {other:?}"),
592 }
593
594 let exact = crate::statements::unix_to_rfc3339(now + MAX_INVITATION_LIFETIME_SECS);
596 let inv_ok = InvitationStatement::new(
597 "ssn_a", issuer,
598 InviteeRestriction::Open, sample_caps(),
599 exact, "n2",
600 );
601 assert!(inv_ok.validate_for_mint(now).is_ok());
602 }
603
604 #[test]
605 fn invitation_validate_rejects_past_expiry() {
606 let now = fixed_now();
607 let signer = host_signer();
608 let issuer = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
609 let past = crate::statements::unix_to_rfc3339(now - 60);
610 let inv = InvitationStatement::new(
611 "ssn_a", issuer, InviteeRestriction::Open, sample_caps(), past, "n",
612 );
613 assert_eq!(inv.validate_for_mint(now), Err(InvitationError::ExpiresInPast));
614 }
615
616 #[test]
617 fn invitation_validate_rejects_max_uses_not_one() {
618 let now = fixed_now();
619 let mut inv = sample(InviteeRestriction::Open);
620 inv.max_uses = 2;
621 match inv.validate_for_mint(now) {
622 Err(InvitationError::MaxUsesUnsupported { max_uses }) => assert_eq!(max_uses, 2),
623 other => panic!("expected MaxUsesUnsupported, got {other:?}"),
624 }
625 }
626
627 #[test]
629 fn invitation_pubkey_restriction_enforced() {
630 let signer_a = Ed25519Signer::from_bytes("a", &[1u8; 32]).unwrap();
632 let signer_b = Ed25519Signer::from_bytes("b", &[2u8; 32]).unwrap();
633 let fp_a = pubkey_fingerprint_short(&format!(
634 "ed25519:{}",
635 URL_SAFE_NO_PAD.encode(signer_a.public_key_bytes()),
636 ));
637 let fp_b = pubkey_fingerprint_short(&format!(
638 "ed25519:{}",
639 URL_SAFE_NO_PAD.encode(signer_b.public_key_bytes()),
640 ));
641 assert_ne!(fp_a, fp_b);
642
643 let restriction = InviteeRestriction::Pubkey { fingerprint: fp_a.clone() };
644
645 let accept_for = |fp: &str| matches!(
648 &restriction,
649 InviteeRestriction::Pubkey { fingerprint } if fingerprint == fp,
650 );
651 assert!(accept_for(&fp_a), "matching pubkey must be accepted");
652 assert!(!accept_for(&fp_b), "non-matching pubkey must be rejected");
653 }
654
655 #[test]
657 fn invitation_cert_restriction_enforced() {
658 let restriction = InviteeRestriction::Cert {
659 issuer_pubkey: "ed25519:ISSUER_X".into(),
660 allowed_subjects: vec!["org-x".into(), "org-y".into()],
661 };
662 let accept = |iss: &str, subj: &str| matches!(
664 &restriction,
665 InviteeRestriction::Cert { issuer_pubkey, allowed_subjects }
666 if issuer_pubkey == iss && allowed_subjects.iter().any(|s| s == subj),
667 );
668
669 assert!(accept("ed25519:ISSUER_X", "org-x"), "matching issuer+subject accepted");
670 assert!(!accept("ed25519:ISSUER_OTHER", "org-x"), "wrong issuer rejected");
671 assert!(!accept("ed25519:ISSUER_X", "org-z"), "wrong subject rejected");
672 }
673
674 #[test]
676 fn invitation_open_restriction_works() {
677 let restriction = InviteeRestriction::Open;
678 let is_open = matches!(restriction, InviteeRestriction::Open);
682 assert!(is_open);
683 }
684
685 #[test]
686 fn invitation_is_expired_returns_true_past_expiry() {
687 let now = fixed_now();
688 let inv = InvitationStatement::new(
689 "ssn_a", URL_SAFE_NO_PAD.encode([5u8; 32]),
690 InviteeRestriction::Open, sample_caps(),
691 crate::statements::unix_to_rfc3339(now - 1),
692 "n",
693 );
694 assert!(inv.is_expired(now));
695 }
696
697 #[test]
702 fn invitation_nonce_digest_matches_journal_helper() {
703 let inv = sample(InviteeRestriction::Open);
704 assert_eq!(
705 inv.nonce_digest(),
706 crate::statements::nonce_digest(&inv.nonce),
707 );
708 }
709
710 #[test]
711 fn parse_rfc3339_round_trips() {
712 let now = fixed_now();
713 let s = crate::statements::unix_to_rfc3339(now);
714 assert_eq!(parse_rfc3339_to_unix(&s), Some(now));
715
716 assert_eq!(parse_rfc3339_to_unix("not a timestamp"), None);
718 assert_eq!(parse_rfc3339_to_unix("2026-05-18T00:00:00"), None); assert_eq!(parse_rfc3339_to_unix("2026-13-18T00:00:00Z"), None); }
721}