1use 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
29pub const TYPE_SESSION_PARTICIPANT: &str = "treeship/session-participant/v1";
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct SessionParticipantStatement {
43 #[serde(rename = "type")]
44 pub type_: String,
45
46 pub session_ref: String,
49
50 pub invitation_ref: String,
52
53 pub joining_agent: String,
55
56 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub joining_agent_cert_ref: Option<String>,
60
61 pub joined_at: String,
63
64 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 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 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 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 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 pub fn attach_host_countersign(
148 envelope: &Envelope,
149 host_signer: &dyn Signer,
150 ) -> Result<Envelope, SignerError> {
151 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 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#[derive(Debug, Clone, PartialEq, Eq)]
180pub enum ParticipantVerifyError {
181 BadPayload(String),
184 MissingHostCountersign,
190 TooManySignatures(usize),
194 JoiningAgentSigInvalid,
197 HostCountersignInvalid,
200 JoiningAgentNotEd25519,
202 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
241pub 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 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 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#[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 #[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 #[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 #[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 #[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 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 match verify_participant_envelope(&finalized, &inv.issuer) {
446 Err(ParticipantVerifyError::JoiningAgentSigInvalid) => {}
447 other => panic!("expected JoiningAgentSigInvalid, got {other:?}"),
448 }
449 }
450
451 #[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 #[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 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}