Skip to main content

treeship_core/statements/
session_participant.rs

1//! Session-participant statement -- Phase 1 of the agent-invitations spec.
2//!
3//! See `docs/specs/agent-invitations-rooms.md`.
4//!
5//! A participant event records "agent X joined session S by redeeming
6//! invitation I." Two signatures are required at the envelope layer:
7//!
8//!   * `joining_agent` signs first to assert "I'm joining"
9//!   * `host` countersigns to assert "I observed this join and confirm
10//!     it consumed the invitation"
11//!
12//! Either signature alone is invalid (Q4 decision). The verifier
13//! (`verify_envelope_signatures`) enforces both presences AND that the
14//! joining_agent's sig is over the canonical bytes and the host's sig
15//! is over the same bytes -- and that the host pubkey matches the
16//! invitation's issuer.
17//!
18//! `capabilities` is copied from the invitation at join time and is
19//! immutable: any change to the field after the joining signature is
20//! emitted invalidates BOTH signatures via the canonical binding.
21
22use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
23use ed25519_dalek::{Signature, Verifier, VerifyingKey};
24use serde::{Deserialize, Serialize};
25
26use crate::attestation::{Envelope, Signature as DsseSignature, Signer, SignerError};
27use crate::statements::invitation::{canonical_json_digest, GrantedCapabilities};
28
29// ---------------------------------------------------------------------------
30// Type constants
31// ---------------------------------------------------------------------------
32
33pub const TYPE_SESSION_PARTICIPANT: &str = "treeship/session-participant/v1";
34
35// ---------------------------------------------------------------------------
36// Schema
37// ---------------------------------------------------------------------------
38
39/// The unsigned payload. Wrap in a DSSE envelope; the envelope MUST
40/// carry two signatures (joining agent first, then host countersign).
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct SessionParticipantStatement {
43    #[serde(rename = "type")]
44    pub type_: String,
45
46    /// Same `session_ref` as the invitation. The verifier checks
47    /// equality.
48    pub session_ref: String,
49
50    /// Artifact id of the invitation this participant event redeems.
51    pub invitation_ref: String,
52
53    /// Joining agent's Ed25519 public key (base64url-no-pad).
54    pub joining_agent: String,
55
56    /// Optional certificate artifact id; set when the invitation's
57    /// restriction was `Cert` and the joining agent presented a cert.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub joining_agent_cert_ref: Option<String>,
60
61    /// RFC 3339.
62    pub joined_at: String,
63
64    /// COPIED from the invitation at join time. Immutable -- any
65    /// mutation invalidates the joining_agent and host signatures.
66    pub capabilities: GrantedCapabilities,
67}
68
69impl SessionParticipantStatement {
70    pub fn new(
71        session_ref: impl Into<String>,
72        invitation_ref: impl Into<String>,
73        joining_agent: impl Into<String>,
74        joined_at: impl Into<String>,
75        capabilities: GrantedCapabilities,
76    ) -> Self {
77        Self {
78            type_: TYPE_SESSION_PARTICIPANT.into(),
79            session_ref: session_ref.into(),
80            invitation_ref: invitation_ref.into(),
81            joining_agent: joining_agent.into(),
82            joining_agent_cert_ref: None,
83            joined_at: joined_at.into(),
84            capabilities,
85        }
86    }
87
88    /// Canonical signing bytes. Same pipe-delimited v0.10.4 shape as
89    /// `InvitationStatement::canonical_for_signing`.
90    ///
91    /// Format:
92    /// `"v1|session-participant|{session_ref}|{invitation_ref}|{joining_agent}|{cert_ref_or_empty}|{joined_at}|{capabilities_canonical}"`
93    pub fn canonical_for_signing(&self) -> String {
94        let caps_digest = canonical_json_digest(&self.capabilities);
95        let cert_field = self.joining_agent_cert_ref.as_deref().unwrap_or("");
96        format!(
97            "v1|session-participant|{}|{}|{}|{}|{}|{}",
98            self.session_ref,
99            self.invitation_ref,
100            self.joining_agent,
101            cert_field,
102            self.joined_at,
103            caps_digest,
104        )
105    }
106
107    /// Sign with the joining agent's keypair. Returns the base64url
108    /// signature suitable to drop into a DSSE envelope's first
109    /// signature slot.
110    pub fn sign_as_joining_agent(&self, signer: &dyn Signer) -> Result<String, SignerError> {
111        let canonical = self.canonical_for_signing();
112        let sig = signer.sign(canonical.as_bytes())?;
113        Ok(URL_SAFE_NO_PAD.encode(sig))
114    }
115
116    /// Sign with the host's keypair (the countersign). The verifier
117    /// rejects participant envelopes whose host signature is missing
118    /// or whose signing key does not match the invitation's issuer.
119    pub fn sign_as_host(&self, signer: &dyn Signer) -> Result<String, SignerError> {
120        let canonical = self.canonical_for_signing();
121        let sig = signer.sign(canonical.as_bytes())?;
122        Ok(URL_SAFE_NO_PAD.encode(sig))
123    }
124
125    /// Build a DSSE envelope around the statement carrying ONLY the
126    /// joining_agent signature. The verifier rejects this as
127    /// `MissingHostCountersign`; this constructor exists so the
128    /// `treeship session join` CLI can emit a "pending countersign"
129    /// blob the host then fills in via `treeship session countersign`.
130    pub fn pending_envelope(&self, joining_signer: &dyn Signer) -> Result<Envelope, SignerError> {
131        let sig = self.sign_as_joining_agent(joining_signer)?;
132        let payload = serde_json::to_vec(self)
133            .map_err(|e| SignerError(format!("serialize participant: {e}")))?;
134        Ok(Envelope {
135            payload: URL_SAFE_NO_PAD.encode(&payload),
136            payload_type: crate::statements::payload_type("session-participant"),
137            signatures: vec![DsseSignature {
138                keyid: joining_signer.key_id().to_string(),
139                sig,
140            }],
141        })
142    }
143
144    /// Add the host's countersign to a pending envelope. Returns the
145    /// finalized envelope with both signatures. Order is preserved:
146    /// signatures[0] = joining_agent, signatures[1] = host.
147    pub fn attach_host_countersign(
148        envelope: &Envelope,
149        host_signer: &dyn Signer,
150    ) -> Result<Envelope, SignerError> {
151        // Decode + re-canonicalize the embedded statement so the
152        // countersign covers the exact bytes the joining agent did.
153        let stmt: Self = envelope
154            .unmarshal_statement()
155            .map_err(|e| SignerError(format!("envelope decode: {e}")))?;
156        let sig = stmt.sign_as_host(host_signer)?;
157        let mut out = envelope.clone();
158        // Refuse to append duplicate host signatures; idempotency at the
159        // CLI surface is the caller's job. Here we just guarantee that
160        // an envelope with two signatures already does not grow a third.
161        if out.signatures.len() >= 2 {
162            return Err(SignerError(
163                "envelope already carries two signatures; refusing to append a third".into(),
164            ));
165        }
166        out.signatures.push(DsseSignature {
167            keyid: host_signer.key_id().to_string(),
168            sig,
169        });
170        Ok(out)
171    }
172}
173
174// ---------------------------------------------------------------------------
175// Verification
176// ---------------------------------------------------------------------------
177
178/// Why a participant envelope was rejected.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub enum ParticipantVerifyError {
181    /// Envelope payload didn't deserialize as a participant statement
182    /// (wrong type, bad JSON, truncated).
183    BadPayload(String),
184    /// The envelope has only one signature -- the host countersign is
185    /// missing. This is the failure mode for envelopes emitted by
186    /// `treeship session join` before `treeship session countersign`
187    /// runs. Surface to the operator as "pending countersign," not as
188    /// "forgery."
189    MissingHostCountersign,
190    /// The envelope has more than two signatures. Phase 1 schema is
191    /// strictly two (joining_agent + host). Future multi-party rooms
192    /// can relax via a canonical bump.
193    TooManySignatures(usize),
194    /// The joining agent's signature did not verify against
195    /// `statement.joining_agent`'s pubkey over the canonical bytes.
196    JoiningAgentSigInvalid,
197    /// The host's signature did not verify, OR the signing key does
198    /// not match the invitation's issuer (`expected_host_pubkey`).
199    HostCountersignInvalid,
200    /// The joining_agent field doesn't decode as a 32-byte Ed25519 key.
201    JoiningAgentNotEd25519,
202    /// The expected host pubkey provided by the caller doesn't decode.
203    HostPubkeyNotEd25519,
204}
205
206impl std::fmt::Display for ParticipantVerifyError {
207    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
208        match self {
209            Self::BadPayload(m) => write!(f, "participant envelope payload invalid: {m}"),
210            Self::MissingHostCountersign => write!(
211                f,
212                "participant envelope carries only the joining agent's signature; \
213                 host countersign required (run `treeship session countersign`)",
214            ),
215            Self::TooManySignatures(n) => write!(
216                f,
217                "participant envelope carries {n} signatures; Phase 1 schema requires exactly 2",
218            ),
219            Self::JoiningAgentSigInvalid => write!(
220                f,
221                "joining agent's signature failed to verify against the statement's canonical bytes",
222            ),
223            Self::HostCountersignInvalid => write!(
224                f,
225                "host countersign failed to verify, or signing key does not match the invitation's issuer",
226            ),
227            Self::JoiningAgentNotEd25519 => write!(
228                f,
229                "participant.joining_agent does not decode as a 32-byte Ed25519 public key",
230            ),
231            Self::HostPubkeyNotEd25519 => write!(
232                f,
233                "expected host pubkey does not decode as a 32-byte Ed25519 public key",
234            ),
235        }
236    }
237}
238
239impl std::error::Error for ParticipantVerifyError {}
240
241/// Verify a participant envelope. Requires:
242///
243///   1. Exactly two signatures.
244///   2. signatures[0] verifies against `statement.joining_agent`.
245///   3. signatures[1] verifies against `expected_host_pubkey`.
246///   4. `expected_host_pubkey` is the base64url-no-pad encoding of the
247///      invitation's `issuer` field. Caller looks the invitation up and
248///      passes it in; this function does not consult any external state.
249///
250/// On success, returns the decoded statement so the caller can apply
251/// it (write the finalized event into the session log, etc.).
252pub fn verify_participant_envelope(
253    envelope: &Envelope,
254    expected_host_pubkey: &str,
255) -> Result<SessionParticipantStatement, ParticipantVerifyError> {
256    let stmt: SessionParticipantStatement = envelope
257        .unmarshal_statement()
258        .map_err(|e| ParticipantVerifyError::BadPayload(e.to_string()))?;
259
260    if stmt.type_ != TYPE_SESSION_PARTICIPANT {
261        return Err(ParticipantVerifyError::BadPayload(format!(
262            "wrong type: got {}, expected {}",
263            stmt.type_, TYPE_SESSION_PARTICIPANT,
264        )));
265    }
266
267    match envelope.signatures.len() {
268        2 => {}
269        1 => return Err(ParticipantVerifyError::MissingHostCountersign),
270        n => return Err(ParticipantVerifyError::TooManySignatures(n)),
271    }
272
273    let canonical = stmt.canonical_for_signing();
274
275    // signatures[0] : joining_agent
276    let joiner_pk_bytes = URL_SAFE_NO_PAD
277        .decode(stmt.joining_agent.as_bytes())
278        .ok()
279        .and_then(|b| if b.len() == 32 { Some(b) } else { None })
280        .ok_or(ParticipantVerifyError::JoiningAgentNotEd25519)?;
281    let mut pk_arr = [0u8; 32];
282    pk_arr.copy_from_slice(&joiner_pk_bytes);
283    let joiner_vk = VerifyingKey::from_bytes(&pk_arr)
284        .map_err(|_| ParticipantVerifyError::JoiningAgentNotEd25519)?;
285    let joiner_sig_bytes = URL_SAFE_NO_PAD
286        .decode(envelope.signatures[0].sig.as_bytes())
287        .map_err(|_| ParticipantVerifyError::JoiningAgentSigInvalid)?;
288    if joiner_sig_bytes.len() != 64 {
289        return Err(ParticipantVerifyError::JoiningAgentSigInvalid);
290    }
291    let mut joiner_sig_arr = [0u8; 64];
292    joiner_sig_arr.copy_from_slice(&joiner_sig_bytes);
293    let joiner_sig = Signature::from_bytes(&joiner_sig_arr);
294    if joiner_vk
295        .verify_strict(canonical.as_bytes(), &joiner_sig)
296        .is_err()
297    {
298        return Err(ParticipantVerifyError::JoiningAgentSigInvalid);
299    }
300
301    // signatures[1] : host countersign
302    let host_pk_bytes = URL_SAFE_NO_PAD
303        .decode(expected_host_pubkey.as_bytes())
304        .ok()
305        .and_then(|b| if b.len() == 32 { Some(b) } else { None })
306        .ok_or(ParticipantVerifyError::HostPubkeyNotEd25519)?;
307    let mut host_pk_arr = [0u8; 32];
308    host_pk_arr.copy_from_slice(&host_pk_bytes);
309    let host_vk = VerifyingKey::from_bytes(&host_pk_arr)
310        .map_err(|_| ParticipantVerifyError::HostPubkeyNotEd25519)?;
311    let host_sig_bytes = URL_SAFE_NO_PAD
312        .decode(envelope.signatures[1].sig.as_bytes())
313        .map_err(|_| ParticipantVerifyError::HostCountersignInvalid)?;
314    if host_sig_bytes.len() != 64 {
315        return Err(ParticipantVerifyError::HostCountersignInvalid);
316    }
317    let mut host_sig_arr = [0u8; 64];
318    host_sig_arr.copy_from_slice(&host_sig_bytes);
319    let host_sig = Signature::from_bytes(&host_sig_arr);
320    if host_vk
321        .verify_strict(canonical.as_bytes(), &host_sig)
322        .is_err()
323    {
324        return Err(ParticipantVerifyError::HostCountersignInvalid);
325    }
326
327    Ok(stmt)
328}
329
330// ---------------------------------------------------------------------------
331// Tests
332// ---------------------------------------------------------------------------
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use crate::attestation::Ed25519Signer;
338    use crate::statements::invitation::{
339        GrantedCapabilities, InvitationStatement, InviteeRestriction,
340    };
341
342    fn caps() -> GrantedCapabilities {
343        GrantedCapabilities {
344            action_types: vec!["tool.call".into()],
345        }
346    }
347
348    fn keys() -> (Ed25519Signer, Ed25519Signer) {
349        (
350            Ed25519Signer::from_bytes("host", &[7u8; 32]).unwrap(),
351            Ed25519Signer::from_bytes("agent", &[11u8; 32]).unwrap(),
352        )
353    }
354
355    fn build_pair() -> (
356        InvitationStatement,
357        SessionParticipantStatement,
358        Ed25519Signer,
359        Ed25519Signer,
360    ) {
361        let (host, agent) = keys();
362        let host_pk = URL_SAFE_NO_PAD.encode(host.public_key_bytes());
363        let agent_pk = URL_SAFE_NO_PAD.encode(agent.public_key_bytes());
364
365        let inv = InvitationStatement::new(
366            "ssn_room",
367            host_pk.clone(),
368            InviteeRestriction::Open,
369            caps(),
370            "2030-01-01T00:00:00Z",
371            "nonce_xyz",
372        );
373        let part = SessionParticipantStatement::new(
374            "ssn_room",
375            "art_invitation_001",
376            agent_pk,
377            "2026-05-18T01:00:00Z",
378            caps(),
379        );
380        (inv, part, host, agent)
381    }
382
383    /// Q4 default: an envelope with only the joining_agent signature
384    /// must be rejected.
385    #[test]
386    fn participant_requires_two_signatures() {
387        let (inv, part, _host, agent) = build_pair();
388        let pending = part.pending_envelope(&agent).unwrap();
389        assert_eq!(pending.signatures.len(), 1);
390        match verify_participant_envelope(&pending, &inv.issuer) {
391            Err(ParticipantVerifyError::MissingHostCountersign) => {}
392            other => panic!("expected MissingHostCountersign, got {other:?}"),
393        }
394    }
395
396    /// Round-trip: pending envelope + host countersign + verify -> Ok.
397    #[test]
398    fn participant_pending_plus_countersign_verifies() {
399        let (inv, part, host, agent) = build_pair();
400        let pending = part.pending_envelope(&agent).unwrap();
401        let finalized =
402            SessionParticipantStatement::attach_host_countersign(&pending, &host).unwrap();
403        assert_eq!(finalized.signatures.len(), 2);
404        let back = verify_participant_envelope(&finalized, &inv.issuer).unwrap();
405        assert_eq!(back.session_ref, part.session_ref);
406        assert_eq!(back.invitation_ref, part.invitation_ref);
407    }
408
409    /// Q4: countersign by a key that ISN'T the invitation's issuer
410    /// must be rejected, even if the signature math checks out under
411    /// that other key.
412    #[test]
413    fn participant_requires_host_countersign_match() {
414        let (inv, part, _real_host, agent) = build_pair();
415        let imposter = Ed25519Signer::from_bytes("imposter", &[42u8; 32]).unwrap();
416        let pending = part.pending_envelope(&agent).unwrap();
417        let bad =
418            SessionParticipantStatement::attach_host_countersign(&pending, &imposter).unwrap();
419        match verify_participant_envelope(&bad, &inv.issuer) {
420            Err(ParticipantVerifyError::HostCountersignInvalid) => {}
421            other => panic!("expected HostCountersignInvalid, got {other:?}"),
422        }
423    }
424
425    /// Capabilities in the participant envelope are immutable: any
426    /// mutation after the signatures land invalidates both.
427    #[test]
428    fn participant_capabilities_immutable() {
429        let (inv, part, host, agent) = build_pair();
430        let pending = part.pending_envelope(&agent).unwrap();
431        let mut finalized =
432            SessionParticipantStatement::attach_host_countersign(&pending, &host).unwrap();
433
434        // Mutate the embedded statement: bump capabilities and rewrap.
435        let mut tampered: SessionParticipantStatement = finalized.unmarshal_statement().unwrap();
436        tampered
437            .capabilities
438            .action_types
439            .push("smuggled.cap".into());
440        let new_payload = serde_json::to_vec(&tampered).unwrap();
441        finalized.payload = URL_SAFE_NO_PAD.encode(&new_payload);
442
443        // Both signatures were over the original capabilities; the
444        // new canonical bytes differ, so verification fails.
445        match verify_participant_envelope(&finalized, &inv.issuer) {
446            Err(ParticipantVerifyError::JoiningAgentSigInvalid) => {}
447            other => panic!("expected JoiningAgentSigInvalid, got {other:?}"),
448        }
449    }
450
451    /// The canonical signing bytes MUST include every field. Mutating
452    /// any one of them must change the canonical (and thus break both
453    /// signatures). Pins the same property as the invitation test.
454    #[test]
455    fn participant_canonical_includes_all_fields() {
456        let (_inv, part, _h, _a) = build_pair();
457        let base = part.canonical_for_signing();
458
459        let mut m1 = part.clone();
460        m1.session_ref = "ssn_other".into();
461        assert_ne!(m1.canonical_for_signing(), base, "session_ref must bind");
462
463        let mut m2 = part.clone();
464        m2.invitation_ref = "art_other".into();
465        assert_ne!(m2.canonical_for_signing(), base, "invitation_ref must bind");
466
467        let mut m3 = part.clone();
468        m3.joining_agent = URL_SAFE_NO_PAD.encode([9u8; 32]);
469        assert_ne!(m3.canonical_for_signing(), base, "joining_agent must bind");
470
471        let mut m4 = part.clone();
472        m4.joining_agent_cert_ref = Some("art_cert_x".into());
473        assert_ne!(m4.canonical_for_signing(), base, "cert_ref must bind");
474
475        let mut m5 = part.clone();
476        m5.joined_at = "2030-01-01T00:00:00Z".into();
477        assert_ne!(m5.canonical_for_signing(), base, "joined_at must bind");
478
479        let mut m6 = part.clone();
480        m6.capabilities.action_types.push("extra".into());
481        assert_ne!(m6.canonical_for_signing(), base, "capabilities must bind");
482    }
483
484    /// An envelope with three signatures is rejected up front. The
485    /// schema is strictly two-sig in Phase 1.
486    #[test]
487    fn participant_rejects_more_than_two_signatures() {
488        let (inv, part, host, agent) = build_pair();
489        let pending = part.pending_envelope(&agent).unwrap();
490        let mut finalized =
491            SessionParticipantStatement::attach_host_countersign(&pending, &host).unwrap();
492        // Cheat in a third signature directly (the attach helper refuses).
493        finalized.signatures.push(DsseSignature {
494            keyid: "extra".into(),
495            sig: URL_SAFE_NO_PAD.encode([0u8; 64]),
496        });
497        match verify_participant_envelope(&finalized, &inv.issuer) {
498            Err(ParticipantVerifyError::TooManySignatures(3)) => {}
499            other => panic!("expected TooManySignatures(3), got {other:?}"),
500        }
501    }
502}