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_strict(self.canonical_for_signing().as_bytes(), &sig)
212            .is_ok()
213    }
214
215    /// Mint-time validation: rejects invitations that violate
216    /// protocol-level invariants the verifier alone cannot enforce.
217    ///
218    /// * `expires_at` parses as RFC 3339 and is in the future.
219    /// * `expires_at - now_unix_secs` does not exceed
220    ///   `MAX_INVITATION_LIFETIME_SECS` (7 days).
221    /// * `max_uses == 1` (Phase 1).
222    /// * `session_ref` and `nonce` are non-empty.
223    /// * `issuer` decodes as a 32-byte Ed25519 pubkey.
224    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        // issuer parse check
237        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    /// True when `now_unix_secs >= expires_at`. Verifiers call this at
259    /// redeem time. Returns true on a malformed `expires_at` so that a
260    /// tampered field fails closed.
261    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    /// Returns `sha256(<raw nonce>)` in `sha256:<hex>` form. Same digest
269    /// the Approval Use Journal indexes by; callers route invitation
270    /// consumption through the journal using this value.
271    pub fn nonce_digest(&self) -> String {
272        nonce_digest_hex(&self.nonce)
273    }
274}
275
276// ---------------------------------------------------------------------------
277// Error type
278// ---------------------------------------------------------------------------
279
280#[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
325// ---------------------------------------------------------------------------
326// Helpers
327// ---------------------------------------------------------------------------
328
329/// Helper used by both invitation + participant canonicals: a deterministic
330/// digest over the sorted-key canonical JSON of a serializable value.
331/// Folds variable-length fields into a fixed-length string so the
332/// pipe-delimited canonical stays single-line and unambiguous.
333///
334/// Panics if the value cannot serialize -- caller types are all in-crate
335/// concrete structs/enums with primitive fields, so failure here would
336/// signal a programming bug (same audit lane C rationale as the
337/// approval_use record-digest helpers).
338pub(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
346/// Sorted-key canonical JSON. Mirrors `merkle::checkpoint::canonical_json_string`
347/// (intentionally a copy rather than a cross-module pub use; the merkle
348/// version is private and this module needs the same behavior without
349/// reaching into a sibling's internals).
350fn 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
390/// `sha256(<raw_nonce>)` as `sha256:<hex>`. Shared with the journal
391/// (`statements::approval_use::nonce_digest`); kept here so the
392/// invitation module does not depend on the journal-side type.
393fn nonce_digest_hex(raw_nonce: &str) -> String {
394    let digest = Sha256::digest(raw_nonce.as_bytes());
395    format!("sha256:{}", hex::encode(digest))
396}
397
398/// Parse RFC 3339 / ISO 8601 timestamps in the subset Treeship emits
399/// (`YYYY-MM-DDTHH:MM:SSZ`). Returns Unix epoch seconds. Returns
400/// `None` on any parse failure -- callers that need a hard error
401/// translate this into the appropriate `InvitationError`.
402///
403/// We deliberately do not pull in `chrono` here -- the statements module
404/// is dep-light by design and already implements `unix_to_rfc3339`. This
405/// is the inverse.
406pub fn parse_rfc3339_to_unix(s: &str) -> Option<u64> {
407    // Strict shape: 20 bytes, "YYYY-MM-DDTHH:MM:SSZ".
408    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    // Days since 1970-01-01 to start of (year, month, day).
435    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
459/// Generate a random hex-encoded nonce suitable for an invitation.
460/// 16 bytes -> 32 hex chars; matches the entropy of an Ed25519 keyid.
461pub 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
468/// `sha256(<canonical_pk>)` truncated to first 16 hex chars. Mirrors
469/// `pubkey_fingerprint` in the trust CLI so a `Pubkey` restriction can
470/// be checked against either input format. Operators paste either form
471/// into `--invitee-pubkey`.
472pub 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// ---------------------------------------------------------------------------
478// Tests
479// ---------------------------------------------------------------------------
480
481#[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        // 2026-05-18T00:00:00Z
498        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    /// The canonical signing bytes MUST include every field. Mutating
529    /// any one of them must change the canonical (and thus break the
530    /// signature). This pins the audit-lane-D property: no
531    /// wire-controllable field is unbound.
532    #[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        // max_uses is locked at 1 in Phase 1, but the schema field is
583        // bound into the canonical so a future relax doesn't silently
584        // verify older invitations under the wrong value.
585        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        // Sign with a different key than `inv.issuer`.
614        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        // Mutate after signing -- verification must fail.
625        inv.session_ref = "ssn_tampered".into();
626        assert!(!inv.verify_canonical(&sig));
627    }
628
629    /// Q2 default: invitations MUST NOT mint with > 7d expiry.
630    #[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        // 7 days + 1 second -> rejected.
637        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        // Exactly 7 days -> accepted.
652        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    /// Q1: Pubkey restriction rejects join with a wrong pubkey.
696    #[test]
697    fn invitation_pubkey_restriction_enforced() {
698        // Mint a Pubkey-restricted invitation against signer_a's fp.
699        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        // Join-time check (mirrors what the CLI does on `session join`):
716        // accept iff the joining agent's pubkey-fp equals the restriction's fp.
717        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    /// Q1: Cert restriction rejects join without a matching cert.
728    #[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        // Helper: would this (issuer, subject) be accepted?
735        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    /// Q1: Open restriction accepts any joining agent.
758    #[test]
759    fn invitation_open_restriction_works() {
760        let restriction = InviteeRestriction::Open;
761        // Open is unconditionally accepted at restriction-check time.
762        // Defense in depth still comes from the journal (single-use)
763        // and the expiry.
764        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    /// The nonce_digest helper matches the journal's nonce digest helper.
783    /// Pins that invitations and the Approval Use Journal will agree on
784    /// the index key when the CLI routes invitation consumption through
785    /// the journal.
786    #[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        // Bad shapes must return None, not panic.
802        assert_eq!(parse_rfc3339_to_unix("not a timestamp"), None);
803        assert_eq!(parse_rfc3339_to_unix("2026-05-18T00:00:00"), None); // no Z
804        assert_eq!(parse_rfc3339_to_unix("2026-13-18T00:00:00Z"), None); // bad month
805    }
806}