use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use ed25519_dalek::{Signature, VerifyingKey};
use serde::{Deserialize, Serialize};
use crate::attestation::{Envelope, Signature as DsseSignature, Signer, SignerError};
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_strict(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_strict(canonical.as_bytes(), &host_sig)
.is_err()
{
return Err(ParticipantVerifyError::HostCountersignInvalid);
}
Ok(stmt)
}
pub fn participant_artifact_id(envelope: &Envelope) -> Result<String, ParticipantVerifyError> {
use sha2::{Digest, Sha256};
let mut pending = envelope.clone();
pending.signatures.truncate(1);
let bytes = pending
.to_json()
.map_err(|e| ParticipantVerifyError::BadPayload(format!("encode pending envelope: {e}")))?;
Ok(format!("art_{}", hex::encode(&Sha256::digest(bytes)[..16])))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParticipantArtifactError {
Participant(ParticipantVerifyError),
Invitation(String),
}
impl std::fmt::Display for ParticipantArtifactError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Participant(e) => write!(f, "{e}"),
Self::Invitation(m) => write!(f, "{m}"),
}
}
}
pub fn verify_participant_artifact(
envelope: &Envelope,
participant_id: &str,
invitation: &Envelope,
invitation_key: VerifyingKey,
session_id: &str,
) -> Result<SessionParticipantStatement, ParticipantArtifactError> {
use crate::statements::invitation::{InvitationStatement, TYPE_INVITATION};
let inv_err = |m: String| ParticipantArtifactError::Invitation(m);
let stmt: SessionParticipantStatement = envelope.unmarshal_statement().map_err(|e| {
ParticipantArtifactError::Participant(ParticipantVerifyError::BadPayload(e.to_string()))
})?;
let inv_sig = invitation.signatures.first().ok_or_else(|| {
inv_err(format!(
"invitation {} carries no signature",
stmt.invitation_ref
))
})?;
let res = crate::attestation::verify_with_key(invitation, &inv_sig.keyid, invitation_key)
.map_err(|e| {
inv_err(format!(
"invitation {} signature invalid: {e}",
stmt.invitation_ref
))
})?;
if res.artifact_id != stmt.invitation_ref {
return Err(inv_err(format!(
"participant names invitation {} but the sealed invitation re-derives to {}",
stmt.invitation_ref, res.artifact_id
)));
}
let inv: InvitationStatement = invitation
.unmarshal_statement()
.map_err(|e| inv_err(format!("invitation payload invalid: {e}")))?;
if inv.type_ != TYPE_INVITATION {
return Err(inv_err(format!(
"{} is not an invitation",
stmt.invitation_ref
)));
}
if inv.issuer != URL_SAFE_NO_PAD.encode(invitation_key.to_bytes()) {
return Err(inv_err(format!(
"invitation {} names issuer {} but is signed by a different key ({})",
stmt.invitation_ref, inv.issuer, inv_sig.keyid
)));
}
verify_participant_envelope(envelope, &inv.issuer)
.map_err(ParticipantArtifactError::Participant)?;
if stmt.session_ref != session_id || inv.session_ref != session_id {
return Err(inv_err(format!(
"participant joins session {} by an invitation to {}, but this package seals {}",
stmt.session_ref, inv.session_ref, session_id
)));
}
let derived =
participant_artifact_id(envelope).map_err(ParticipantArtifactError::Participant)?;
if derived != participant_id {
return Err(ParticipantArtifactError::Participant(
ParticipantVerifyError::BadPayload(format!(
"sealed as {participant_id} but the pending envelope re-derives to {derived}"
)),
));
}
Ok(stmt)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::attestation::Ed25519Signer;
use crate::statements::invitation::{
GrantedCapabilities, InvitationStatement, InviteeRestriction,
};
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:?}"),
}
}
}