use serde::{Deserialize, Serialize};
use crate::attestation::{Signer, SignerError};
use super::{nonce_digest, parse_rfc3339_to_unix};
pub const TYPE_SESSION_LIVENESS: &str = "treeship/session-liveness/v1";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionLivenessStatement {
#[serde(rename = "type")]
pub type_: String,
pub session_ref: String,
pub participant_ref: String,
pub joining_agent: String,
pub nonce_digest: String,
pub challenge_issued_at: String,
pub response_signed_at: String,
}
impl SessionLivenessStatement {
pub fn new(
session_ref: impl Into<String>,
participant_ref: impl Into<String>,
joining_agent: impl Into<String>,
nonce: &str,
challenge_issued_at: impl Into<String>,
response_signed_at: impl Into<String>,
) -> Self {
Self {
type_: TYPE_SESSION_LIVENESS.into(),
session_ref: session_ref.into(),
participant_ref: participant_ref.into(),
joining_agent: joining_agent.into(),
nonce_digest: nonce_digest(nonce),
challenge_issued_at: challenge_issued_at.into(),
response_signed_at: response_signed_at.into(),
}
}
pub fn interval_seconds(&self) -> Option<i64> {
let issued = parse_rfc3339_to_unix(&self.challenge_issued_at)? as i64;
let answered = parse_rfc3339_to_unix(&self.response_signed_at)? as i64;
let delta = answered - issued;
(delta >= 0).then_some(delta)
}
pub fn canonical_for_signing(&self) -> String {
format!(
"v1|session-liveness|{}|{}|{}|{}|{}|{}",
self.session_ref,
self.participant_ref,
self.joining_agent,
self.nonce_digest,
self.challenge_issued_at,
self.response_signed_at,
)
}
pub fn sign_as_host(&self, host_signer: &dyn Signer) -> Result<String, SignerError> {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
let sig = host_signer.sign(self.canonical_for_signing().as_bytes())?;
Ok(URL_SAFE_NO_PAD.encode(sig))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum LivenessVerdict {
Attested { interval_seconds: i64 },
Malformed { reason: String },
Absent,
}
impl LivenessVerdict {
pub fn from_statement(stmt: Option<&SessionLivenessStatement>) -> Self {
match stmt {
None => Self::Absent,
Some(s) => match s.interval_seconds() {
Some(interval_seconds) => Self::Attested { interval_seconds },
None => Self::Malformed {
reason: format!(
"challenge_issued_at {:?} and response_signed_at {:?} do not yield a \
non-negative interval",
s.challenge_issued_at, s.response_signed_at
),
},
},
}
}
pub fn summary(&self) -> String {
match self {
Self::Attested { interval_seconds } => {
format!("live-challenged, answered in {interval_seconds}s")
}
Self::Malformed { reason } => format!("liveness evidence unusable: {reason}"),
Self::Absent => "no liveness evidence — cannot tell a live-challenged join from an \
unchallenged one"
.to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::attestation::Ed25519Signer;
fn stmt(issued: &str, answered: &str) -> SessionLivenessStatement {
SessionLivenessStatement::new(
"ssn_abc",
"art_0123456789abcdef0123456789abcdef",
"Zm9vYmFy",
"n_a_real_nonce_value_with_entropy",
issued,
answered,
)
}
#[test]
fn interval_is_the_window_from_challenge_to_answer() {
let s = stmt("2026-08-13T10:00:00Z", "2026-08-13T10:00:04Z");
assert_eq!(s.interval_seconds(), Some(4));
}
#[test]
fn a_slow_answer_is_reported_not_flattened() {
let quick = stmt("2026-08-13T10:00:00Z", "2026-08-13T10:00:04Z");
let slow = stmt("2026-08-13T10:00:00Z", "2026-08-13T10:40:00Z");
assert_eq!(quick.interval_seconds(), Some(4));
assert_eq!(slow.interval_seconds(), Some(2400));
assert_ne!(
LivenessVerdict::from_statement(Some(&quick)),
LivenessVerdict::from_statement(Some(&slow)),
"a 4s and a 40m challenge window must not produce the same verdict"
);
}
#[test]
fn an_answer_before_its_challenge_is_not_a_small_interval() {
let s = stmt("2026-08-13T10:00:00Z", "2026-08-13T09:00:00Z");
assert_eq!(s.interval_seconds(), None);
assert!(matches!(
LivenessVerdict::from_statement(Some(&s)),
LivenessVerdict::Malformed { .. }
));
}
#[test]
fn absent_evidence_does_not_read_as_a_pass() {
let v = LivenessVerdict::from_statement(None);
assert_eq!(v, LivenessVerdict::Absent);
let s = v.summary();
assert!(s.contains("no liveness evidence"), "{s}");
assert!(
!s.contains("live-challenged,"),
"absent must not be phrased like an attestation: {s}"
);
}
#[test]
fn the_nonce_itself_never_enters_the_statement() {
let nonce = "n_super_secret_nonce_material_xyz";
let s = SessionLivenessStatement::new(
"ssn_abc",
"art_x",
"pk",
nonce,
"2026-08-13T10:00:00Z",
"2026-08-13T10:00:01Z",
);
let json = serde_json::to_string(&s).unwrap();
assert!(!json.contains(nonce), "raw nonce leaked into the statement");
assert!(s.nonce_digest.starts_with("sha256:"));
assert!(!s.canonical_for_signing().contains(nonce));
}
#[test]
fn every_field_is_bound_into_the_signed_bytes() {
let base = stmt("2026-08-13T10:00:00Z", "2026-08-13T10:00:04Z");
let canon = base.canonical_for_signing();
let mut other_session = base.clone();
other_session.session_ref = "ssn_other".into();
let mut other_participant = base.clone();
other_participant.participant_ref = "art_ffffffffffffffffffffffffffffffff".into();
let mut other_agent = base.clone();
other_agent.joining_agent = "b3RoZXI".into();
for variant in [other_session, other_participant, other_agent] {
assert_ne!(
canon,
variant.canonical_for_signing(),
"a changed field left the signed bytes identical"
);
}
}
#[test]
fn host_signature_verifies_over_the_canonical_bytes() {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
let host = Ed25519Signer::from_bytes("host", &[7u8; 32]).unwrap();
let s = stmt("2026-08-13T10:00:00Z", "2026-08-13T10:00:04Z");
let sig_b64 = s.sign_as_host(&host).unwrap();
let sig_bytes: [u8; 64] = URL_SAFE_NO_PAD
.decode(&sig_b64)
.unwrap()
.try_into()
.unwrap();
let vk_bytes: [u8; 32] = host.public_key_bytes().try_into().unwrap();
let vk = VerifyingKey::from_bytes(&vk_bytes).unwrap();
assert!(vk
.verify(
s.canonical_for_signing().as_bytes(),
&Signature::from_bytes(&sig_bytes)
)
.is_ok());
let mut tampered = s.clone();
tampered.response_signed_at = "2026-08-13T10:00:03Z".into();
assert!(vk
.verify(
tampered.canonical_for_signing().as_bytes(),
&Signature::from_bytes(&sig_bytes)
)
.is_err());
}
}