Skip to main content

treeship_core/statements/
invitation.rs

1//! Agent invitation statement -- Phase 1 of the agent-invitations spec.
2//!
3//! See `docs/specs/agent-invitations-rooms.md`.
4//!
5//! An invitation is structurally an Approval Grant for `action_type =
6//! "session.join"`. The host (the session's owning signing key) mints a
7//! single-use, expiring, restriction-bound grant; the joining agent
8//! redeems it by emitting a participant event (see
9//! `session_participant.rs`). Replay protection comes from the existing
10//! Approval Use Journal -- the invitation's nonce is hashed into a
11//! `nonce_digest` and the journal rejects double-consumption.
12//!
13//! Phase 1 scope (decisions locked in by the maintainer):
14//!
15//! * `invitee_restriction` default is `Cert` for production. `Pubkey` is
16//!   the tighter option; `Open` is opt-in only.
17//! * `expires_at` default is 1 hour; the protocol-level maximum is 7
18//!   days, enforced at mint time via `validate_for_mint`.
19//! * `max_uses` is always 1 in Phase 1 -- the schema carries it for
20//!   forward-compat but mint rejects any other value.
21//! * Authority is HostOnly: the issuer pubkey is the session's owning
22//!   signing key. Delegation is Phase 2.
23//!
24//! The canonical signing string follows the v0.10.4 pattern: a
25//! pipe-delimited line that binds every field that participates in
26//! verification dispatch. New fields added in future versions go through
27//! a `canonical_version` bump, not a silent extension.
28
29use 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
36// ---------------------------------------------------------------------------
37// Type constants
38// ---------------------------------------------------------------------------
39
40pub const TYPE_INVITATION: &str = "treeship/invitation/v1";
41
42/// Maximum allowed lifetime of a freshly minted invitation, in seconds.
43/// 7 days. Enforced at mint time. Verifiers do NOT re-check this bound
44/// (an invitation that was minted under a different binary with a
45/// looser bound would still verify cryptographically; the protocol-level
46/// guarantee is "the host promised to bound their own mints").
47pub const MAX_INVITATION_LIFETIME_SECS: u64 = 7 * 24 * 60 * 60;
48
49/// Default invitation lifetime when the operator does not specify one.
50/// 1 hour. Matches the recommendation in the spec.
51pub const DEFAULT_INVITATION_LIFETIME_SECS: u64 = 60 * 60;
52
53// ---------------------------------------------------------------------------
54// Schema
55// ---------------------------------------------------------------------------
56
57/// Who may redeem an invitation. Three shapes; the default for new
58/// invitations is `Cert`.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(tag = "kind", rename_all = "snake_case")]
61pub enum InviteeRestriction {
62    /// Tightest: only the agent whose pubkey hashes to `fingerprint` may
63    /// join. Fingerprint is `sha256(canonical_pubkey)`'s first 16 hex
64    /// chars, matching `pubkey_fingerprint` in the trust CLI.
65    Pubkey { fingerprint: String },
66    /// Production sweet spot: any agent holding a certificate issued by
67    /// `issuer_pubkey` whose subject is in `allowed_subjects`.
68    Cert {
69        issuer_pubkey: String,
70        allowed_subjects: Vec<String>,
71    },
72    /// Anyone holding the blob may redeem. Opt-in only; the CLI refuses
73    /// to mint an Open invitation without an explicit `--open` flag.
74    Open,
75}
76
77/// Capabilities granted to the joining agent. Phase 1 carries only
78/// `action_types`; `workflow_node_ids` comes in Phase 3 once PR #107
79/// (workflow declarations) lands.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct GrantedCapabilities {
82    /// Dot-namespaced action labels the joining agent is authorized to
83    /// emit (e.g. `["tool.call", "agent.handoff"]`). Empty means no
84    /// capabilities (degenerate; the CLI warns).
85    #[serde(default)]
86    pub action_types: Vec<String>,
87}
88
89/// One signed invitation. Wrap in a DSSE envelope via
90/// `crate::attestation::sign` with `payload_type("invitation")` to seal.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct InvitationStatement {
93    #[serde(rename = "type")]
94    pub type_: String,
95
96    /// `session_id` (e.g. `ssn_<hex>`) that this invitation joins.
97    pub session_ref: String,
98
99    /// Issuer's Ed25519 public key as base64url-no-pad. Verifiers MUST
100    /// confirm this key is present in the trust root store under kind
101    /// `SessionHost` before honoring the invitation.
102    pub issuer: String,
103
104    pub invitee_restriction: InviteeRestriction,
105
106    pub granted_capabilities: GrantedCapabilities,
107
108    /// RFC 3339 expiry timestamp.
109    pub expires_at: String,
110
111    /// Always 1 in Phase 1. The schema carries the field so multi-use
112    /// invitations (roadmap) don't need a canonical-format bump.
113    pub max_uses: u32,
114
115    /// Random hex-encoded nonce. The Approval Use Journal indexes its
116    /// SHA-256 digest, so the journal never sees the raw nonce.
117    pub nonce: String,
118}
119
120impl InvitationStatement {
121    /// Construct an invitation with the current canonical type tag.
122    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    /// Canonical signing bytes. Pipe-delimited, version-prefixed,
143    /// following the v0.10.4 `Checkpoint::canonical_for_signing` shape.
144    ///
145    /// Format:
146    /// `"v1|invitation|{session_ref}|{issuer}|{restriction_canonical}|{capabilities_canonical}|{expires_at}|{max_uses}|{nonce_digest}"`
147    ///
148    /// `restriction_canonical` and `capabilities_canonical` are
149    /// `sha256:<hex>` digests over the sorted-key canonical JSON
150    /// serialization of the field. Hashing them keeps the canonical
151    /// string a single line regardless of field cardinality (cert
152    /// `allowed_subjects` is a Vec; embedding it directly would require
153    /// a sub-delimiter and reopen the parser-mismatch attack surface
154    /// that pipe-delimited canonicals are designed to avoid).
155    ///
156    /// `nonce_digest` (not the raw nonce) is bound for the same reason
157    /// the Approval Use Journal stores the digest: the raw nonce is
158    /// already in the signed envelope's payload bytes, so binding the
159    /// digest into the canonical adds redundancy without exposing the
160    /// nonce in a second place.
161    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    /// Sign the invitation under the host's keypair. The signature is
178    /// over the canonical bytes (see `canonical_for_signing`), encoded
179    /// as base64url-no-pad. The signed envelope (DSSE) is produced by
180    /// callers via `crate::attestation::sign`; this helper produces
181    /// just the raw signature so callers can compose either way.
182    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    /// Verify the supplied `signature_b64url` against `self.issuer`'s
189    /// pubkey over the canonical bytes. Returns `true` only when both
190    /// the pubkey decodes cleanly AND the signature math checks out.
191    /// Does NOT consult trust roots -- the caller is responsible for
192    /// checking that `self.issuer` is pinned under kind `SessionHost`.
193    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    /// Mint-time validation: rejects invitations that violate
215    /// protocol-level invariants the verifier alone cannot enforce.
216    ///
217    /// * `expires_at` parses as RFC 3339 and is in the future.
218    /// * `expires_at - now_unix_secs` does not exceed
219    ///   `MAX_INVITATION_LIFETIME_SECS` (7 days).
220    /// * `max_uses == 1` (Phase 1).
221    /// * `session_ref` and `nonce` are non-empty.
222    /// * `issuer` decodes as a 32-byte Ed25519 pubkey.
223    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        // issuer parse check
234        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    /// True when `now_unix_secs >= expires_at`. Verifiers call this at
256    /// redeem time. Returns true on a malformed `expires_at` so that a
257    /// tampered field fails closed.
258    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    /// Returns `sha256(<raw nonce>)` in `sha256:<hex>` form. Same digest
266    /// the Approval Use Journal indexes by; callers route invitation
267    /// consumption through the journal using this value.
268    pub fn nonce_digest(&self) -> String {
269        nonce_digest_hex(&self.nonce)
270    }
271}
272
273// ---------------------------------------------------------------------------
274// Error type
275// ---------------------------------------------------------------------------
276
277#[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
316// ---------------------------------------------------------------------------
317// Helpers
318// ---------------------------------------------------------------------------
319
320/// Helper used by both invitation + participant canonicals: a deterministic
321/// digest over the sorted-key canonical JSON of a serializable value.
322/// Folds variable-length fields into a fixed-length string so the
323/// pipe-delimited canonical stays single-line and unambiguous.
324///
325/// Panics if the value cannot serialize -- caller types are all in-crate
326/// concrete structs/enums with primitive fields, so failure here would
327/// signal a programming bug (same audit lane C rationale as the
328/// approval_use record-digest helpers).
329pub(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
337/// Sorted-key canonical JSON. Mirrors `merkle::checkpoint::canonical_json_string`
338/// (intentionally a copy rather than a cross-module pub use; the merkle
339/// version is private and this module needs the same behavior without
340/// reaching into a sibling's internals).
341fn 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
379/// `sha256(<raw_nonce>)` as `sha256:<hex>`. Shared with the journal
380/// (`statements::approval_use::nonce_digest`); kept here so the
381/// invitation module does not depend on the journal-side type.
382fn nonce_digest_hex(raw_nonce: &str) -> String {
383    let digest = Sha256::digest(raw_nonce.as_bytes());
384    format!("sha256:{}", hex::encode(digest))
385}
386
387/// Parse RFC 3339 / ISO 8601 timestamps in the subset Treeship emits
388/// (`YYYY-MM-DDTHH:MM:SSZ`). Returns Unix epoch seconds. Returns
389/// `None` on any parse failure -- callers that need a hard error
390/// translate this into the appropriate `InvitationError`.
391///
392/// We deliberately do not pull in `chrono` here -- the statements module
393/// is dep-light by design and already implements `unix_to_rfc3339`. This
394/// is the inverse.
395fn parse_rfc3339_to_unix(s: &str) -> Option<u64> {
396    // Strict shape: 20 bytes, "YYYY-MM-DDTHH:MM:SSZ".
397    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    // Days since 1970-01-01 to start of (year, month, day).
418    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
440/// Generate a random hex-encoded nonce suitable for an invitation.
441/// 16 bytes -> 32 hex chars; matches the entropy of an Ed25519 keyid.
442pub 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
449/// `sha256(<canonical_pk>)` truncated to first 16 hex chars. Mirrors
450/// `pubkey_fingerprint` in the trust CLI so a `Pubkey` restriction can
451/// be checked against either input format. Operators paste either form
452/// into `--invitee-pubkey`.
453pub 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// ---------------------------------------------------------------------------
459// Tests
460// ---------------------------------------------------------------------------
461
462#[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        // 2026-05-18T00:00:00Z
479        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    /// The canonical signing bytes MUST include every field. Mutating
510    /// any one of them must change the canonical (and thus break the
511    /// signature). This pins the audit-lane-D property: no
512    /// wire-controllable field is unbound.
513    #[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        // max_uses is locked at 1 in Phase 1, but the schema field is
539        // bound into the canonical so a future relax doesn't silently
540        // verify older invitations under the wrong value.
541        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        // Sign with a different key than `inv.issuer`.
560        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        // Mutate after signing -- verification must fail.
571        inv.session_ref = "ssn_tampered".into();
572        assert!(!inv.verify_canonical(&sig));
573    }
574
575    /// Q2 default: invitations MUST NOT mint with > 7d expiry.
576    #[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        // 7 days + 1 second -> rejected.
583        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        // Exactly 7 days -> accepted.
595        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    /// Q1: Pubkey restriction rejects join with a wrong pubkey.
628    #[test]
629    fn invitation_pubkey_restriction_enforced() {
630        // Mint a Pubkey-restricted invitation against signer_a's fp.
631        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        // Join-time check (mirrors what the CLI does on `session join`):
646        // accept iff the joining agent's pubkey-fp equals the restriction's fp.
647        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    /// Q1: Cert restriction rejects join without a matching cert.
656    #[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        // Helper: would this (issuer, subject) be accepted?
663        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    /// Q1: Open restriction accepts any joining agent.
675    #[test]
676    fn invitation_open_restriction_works() {
677        let restriction = InviteeRestriction::Open;
678        // Open is unconditionally accepted at restriction-check time.
679        // Defense in depth still comes from the journal (single-use)
680        // and the expiry.
681        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    /// The nonce_digest helper matches the journal's nonce digest helper.
698    /// Pins that invitations and the Approval Use Journal will agree on
699    /// the index key when the CLI routes invitation consumption through
700    /// the journal.
701    #[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        // Bad shapes must return None, not panic.
717        assert_eq!(parse_rfc3339_to_unix("not a timestamp"), None);
718        assert_eq!(parse_rfc3339_to_unix("2026-05-18T00:00:00"), None); // no Z
719        assert_eq!(parse_rfc3339_to_unix("2026-13-18T00:00:00Z"), None); // bad month
720    }
721}