use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use crate::attestation::{Envelope, Signer, SignerError, Signature as DsseSignature};
use crate::statements::invitation::{canonical_json_digest, GrantedCapabilities};
pub const TYPE_SESSION_PARTICIPANT: &str = "treeship/session-participant/v1";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionParticipantStatement {
#[serde(rename = "type")]
pub type_: String,
pub session_ref: String,
pub invitation_ref: String,
pub joining_agent: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub joining_agent_cert_ref: Option<String>,
pub joined_at: String,
pub capabilities: GrantedCapabilities,
}
impl SessionParticipantStatement {
pub fn new(
session_ref: impl Into<String>,
invitation_ref: impl Into<String>,
joining_agent: impl Into<String>,
joined_at: impl Into<String>,
capabilities: GrantedCapabilities,
) -> Self {
Self {
type_: TYPE_SESSION_PARTICIPANT.into(),
session_ref: session_ref.into(),
invitation_ref: invitation_ref.into(),
joining_agent: joining_agent.into(),
joining_agent_cert_ref: None,
joined_at: joined_at.into(),
capabilities,
}
}
pub fn canonical_for_signing(&self) -> String {
let caps_digest = canonical_json_digest(&self.capabilities);
let cert_field = self.joining_agent_cert_ref.as_deref().unwrap_or("");
format!(
"v1|session-participant|{}|{}|{}|{}|{}|{}",
self.session_ref,
self.invitation_ref,
self.joining_agent,
cert_field,
self.joined_at,
caps_digest,
)
}
pub fn sign_as_joining_agent(&self, signer: &dyn Signer) -> Result<String, SignerError> {
let canonical = self.canonical_for_signing();
let sig = signer.sign(canonical.as_bytes())?;
Ok(URL_SAFE_NO_PAD.encode(sig))
}
pub fn sign_as_host(&self, signer: &dyn Signer) -> Result<String, SignerError> {
let canonical = self.canonical_for_signing();
let sig = signer.sign(canonical.as_bytes())?;
Ok(URL_SAFE_NO_PAD.encode(sig))
}
pub fn pending_envelope(
&self,
joining_signer: &dyn Signer,
) -> Result<Envelope, SignerError> {
let sig = self.sign_as_joining_agent(joining_signer)?;
let payload = serde_json::to_vec(self)
.map_err(|e| SignerError(format!("serialize participant: {e}")))?;
Ok(Envelope {
payload: URL_SAFE_NO_PAD.encode(&payload),
payload_type: crate::statements::payload_type("session-participant"),
signatures: vec![DsseSignature {
keyid: joining_signer.key_id().to_string(),
sig,
}],
})
}
pub fn attach_host_countersign(
envelope: &Envelope,
host_signer: &dyn Signer,
) -> Result<Envelope, SignerError> {
let stmt: Self = envelope.unmarshal_statement()
.map_err(|e| SignerError(format!("envelope decode: {e}")))?;
let sig = stmt.sign_as_host(host_signer)?;
let mut out = envelope.clone();
if out.signatures.len() >= 2 {
return Err(SignerError(
"envelope already carries two signatures; refusing to append a third".into(),
));
}
out.signatures.push(DsseSignature {
keyid: host_signer.key_id().to_string(),
sig,
});
Ok(out)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParticipantVerifyError {
BadPayload(String),
MissingHostCountersign,
TooManySignatures(usize),
JoiningAgentSigInvalid,
HostCountersignInvalid,
JoiningAgentNotEd25519,
HostPubkeyNotEd25519,
}
impl std::fmt::Display for ParticipantVerifyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::BadPayload(m) => write!(f, "participant envelope payload invalid: {m}"),
Self::MissingHostCountersign => write!(
f,
"participant envelope carries only the joining agent's signature; \
host countersign required (run `treeship session countersign`)",
),
Self::TooManySignatures(n) => write!(
f,
"participant envelope carries {n} signatures; Phase 1 schema requires exactly 2",
),
Self::JoiningAgentSigInvalid => write!(
f,
"joining agent's signature failed to verify against the statement's canonical bytes",
),
Self::HostCountersignInvalid => write!(
f,
"host countersign failed to verify, or signing key does not match the invitation's issuer",
),
Self::JoiningAgentNotEd25519 => write!(
f,
"participant.joining_agent does not decode as a 32-byte Ed25519 public key",
),
Self::HostPubkeyNotEd25519 => write!(
f,
"expected host pubkey does not decode as a 32-byte Ed25519 public key",
),
}
}
}
impl std::error::Error for ParticipantVerifyError {}
pub fn verify_participant_envelope(
envelope: &Envelope,
expected_host_pubkey: &str,
) -> Result<SessionParticipantStatement, ParticipantVerifyError> {
let stmt: SessionParticipantStatement = envelope
.unmarshal_statement()
.map_err(|e| ParticipantVerifyError::BadPayload(e.to_string()))?;
if stmt.type_ != TYPE_SESSION_PARTICIPANT {
return Err(ParticipantVerifyError::BadPayload(format!(
"wrong type: got {}, expected {}", stmt.type_, TYPE_SESSION_PARTICIPANT,
)));
}
match envelope.signatures.len() {
2 => {}
1 => return Err(ParticipantVerifyError::MissingHostCountersign),
n => return Err(ParticipantVerifyError::TooManySignatures(n)),
}
let canonical = stmt.canonical_for_signing();
let joiner_pk_bytes = URL_SAFE_NO_PAD
.decode(stmt.joining_agent.as_bytes())
.ok()
.and_then(|b| if b.len() == 32 { Some(b) } else { None })
.ok_or(ParticipantVerifyError::JoiningAgentNotEd25519)?;
let mut pk_arr = [0u8; 32];
pk_arr.copy_from_slice(&joiner_pk_bytes);
let joiner_vk = VerifyingKey::from_bytes(&pk_arr)
.map_err(|_| ParticipantVerifyError::JoiningAgentNotEd25519)?;
let joiner_sig_bytes = URL_SAFE_NO_PAD
.decode(envelope.signatures[0].sig.as_bytes())
.map_err(|_| ParticipantVerifyError::JoiningAgentSigInvalid)?;
if joiner_sig_bytes.len() != 64 {
return Err(ParticipantVerifyError::JoiningAgentSigInvalid);
}
let mut joiner_sig_arr = [0u8; 64];
joiner_sig_arr.copy_from_slice(&joiner_sig_bytes);
let joiner_sig = Signature::from_bytes(&joiner_sig_arr);
if joiner_vk.verify(canonical.as_bytes(), &joiner_sig).is_err() {
return Err(ParticipantVerifyError::JoiningAgentSigInvalid);
}
let host_pk_bytes = URL_SAFE_NO_PAD
.decode(expected_host_pubkey.as_bytes())
.ok()
.and_then(|b| if b.len() == 32 { Some(b) } else { None })
.ok_or(ParticipantVerifyError::HostPubkeyNotEd25519)?;
let mut host_pk_arr = [0u8; 32];
host_pk_arr.copy_from_slice(&host_pk_bytes);
let host_vk = VerifyingKey::from_bytes(&host_pk_arr)
.map_err(|_| ParticipantVerifyError::HostPubkeyNotEd25519)?;
let host_sig_bytes = URL_SAFE_NO_PAD
.decode(envelope.signatures[1].sig.as_bytes())
.map_err(|_| ParticipantVerifyError::HostCountersignInvalid)?;
if host_sig_bytes.len() != 64 {
return Err(ParticipantVerifyError::HostCountersignInvalid);
}
let mut host_sig_arr = [0u8; 64];
host_sig_arr.copy_from_slice(&host_sig_bytes);
let host_sig = Signature::from_bytes(&host_sig_arr);
if host_vk.verify(canonical.as_bytes(), &host_sig).is_err() {
return Err(ParticipantVerifyError::HostCountersignInvalid);
}
Ok(stmt)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::attestation::Ed25519Signer;
use crate::statements::invitation::{GrantedCapabilities, InviteeRestriction, InvitationStatement};
fn caps() -> GrantedCapabilities {
GrantedCapabilities {
action_types: vec!["tool.call".into()],
}
}
fn keys() -> (Ed25519Signer, Ed25519Signer) {
(
Ed25519Signer::from_bytes("host", &[7u8; 32]).unwrap(),
Ed25519Signer::from_bytes("agent", &[11u8; 32]).unwrap(),
)
}
fn build_pair() -> (InvitationStatement, SessionParticipantStatement, Ed25519Signer, Ed25519Signer) {
let (host, agent) = keys();
let host_pk = URL_SAFE_NO_PAD.encode(host.public_key_bytes());
let agent_pk = URL_SAFE_NO_PAD.encode(agent.public_key_bytes());
let inv = InvitationStatement::new(
"ssn_room", host_pk.clone(),
InviteeRestriction::Open, caps(),
"2030-01-01T00:00:00Z", "nonce_xyz",
);
let part = SessionParticipantStatement::new(
"ssn_room", "art_invitation_001",
agent_pk, "2026-05-18T01:00:00Z", caps(),
);
(inv, part, host, agent)
}
#[test]
fn participant_requires_two_signatures() {
let (inv, part, _host, agent) = build_pair();
let pending = part.pending_envelope(&agent).unwrap();
assert_eq!(pending.signatures.len(), 1);
match verify_participant_envelope(&pending, &inv.issuer) {
Err(ParticipantVerifyError::MissingHostCountersign) => {}
other => panic!("expected MissingHostCountersign, got {other:?}"),
}
}
#[test]
fn participant_pending_plus_countersign_verifies() {
let (inv, part, host, agent) = build_pair();
let pending = part.pending_envelope(&agent).unwrap();
let finalized = SessionParticipantStatement::attach_host_countersign(&pending, &host).unwrap();
assert_eq!(finalized.signatures.len(), 2);
let back = verify_participant_envelope(&finalized, &inv.issuer).unwrap();
assert_eq!(back.session_ref, part.session_ref);
assert_eq!(back.invitation_ref, part.invitation_ref);
}
#[test]
fn participant_requires_host_countersign_match() {
let (inv, part, _real_host, agent) = build_pair();
let imposter = Ed25519Signer::from_bytes("imposter", &[42u8; 32]).unwrap();
let pending = part.pending_envelope(&agent).unwrap();
let bad = SessionParticipantStatement::attach_host_countersign(&pending, &imposter).unwrap();
match verify_participant_envelope(&bad, &inv.issuer) {
Err(ParticipantVerifyError::HostCountersignInvalid) => {}
other => panic!("expected HostCountersignInvalid, got {other:?}"),
}
}
#[test]
fn participant_capabilities_immutable() {
let (inv, part, host, agent) = build_pair();
let pending = part.pending_envelope(&agent).unwrap();
let mut finalized = SessionParticipantStatement::attach_host_countersign(&pending, &host).unwrap();
let mut tampered: SessionParticipantStatement = finalized.unmarshal_statement().unwrap();
tampered.capabilities.action_types.push("smuggled.cap".into());
let new_payload = serde_json::to_vec(&tampered).unwrap();
finalized.payload = URL_SAFE_NO_PAD.encode(&new_payload);
match verify_participant_envelope(&finalized, &inv.issuer) {
Err(ParticipantVerifyError::JoiningAgentSigInvalid) => {}
other => panic!("expected JoiningAgentSigInvalid, got {other:?}"),
}
}
#[test]
fn participant_canonical_includes_all_fields() {
let (_inv, part, _h, _a) = build_pair();
let base = part.canonical_for_signing();
let mut m1 = part.clone(); m1.session_ref = "ssn_other".into();
assert_ne!(m1.canonical_for_signing(), base, "session_ref must bind");
let mut m2 = part.clone(); m2.invitation_ref = "art_other".into();
assert_ne!(m2.canonical_for_signing(), base, "invitation_ref must bind");
let mut m3 = part.clone();
m3.joining_agent = URL_SAFE_NO_PAD.encode([9u8; 32]);
assert_ne!(m3.canonical_for_signing(), base, "joining_agent must bind");
let mut m4 = part.clone();
m4.joining_agent_cert_ref = Some("art_cert_x".into());
assert_ne!(m4.canonical_for_signing(), base, "cert_ref must bind");
let mut m5 = part.clone(); m5.joined_at = "2030-01-01T00:00:00Z".into();
assert_ne!(m5.canonical_for_signing(), base, "joined_at must bind");
let mut m6 = part.clone();
m6.capabilities.action_types.push("extra".into());
assert_ne!(m6.canonical_for_signing(), base, "capabilities must bind");
}
#[test]
fn participant_rejects_more_than_two_signatures() {
let (inv, part, host, agent) = build_pair();
let pending = part.pending_envelope(&agent).unwrap();
let mut finalized = SessionParticipantStatement::attach_host_countersign(&pending, &host).unwrap();
finalized.signatures.push(DsseSignature {
keyid: "extra".into(),
sig: URL_SAFE_NO_PAD.encode([0u8; 64]),
});
match verify_participant_envelope(&finalized, &inv.issuer) {
Err(ParticipantVerifyError::TooManySignatures(3)) => {}
other => panic!("expected TooManySignatures(3), got {other:?}"),
}
}
}