Skip to main content

treeship_core/statements/
session_liveness.rs

1//! `session-liveness/v1` — durable, portable evidence that a join was
2//! live-challenged, and how long the challenge window actually was.
3//!
4//! # The gap this closes
5//!
6//! v0.24 added a liveness challenge at countersign time: the host mints a
7//! nonce, the joining agent signs it, and the host refuses to countersign
8//! unless the response verifies. That check works. It also left **no trace**.
9//! `check_join_challenge` returned the response's `signed_at` and the call
10//! site discarded it, so a third-party verifier reading the finalized
11//! participant envelope could not tell a live-challenged join from an
12//! unchallenged one. A security control that runs and leaves no evidence is
13//! unverifiable by anyone who was not the host at the time.
14//!
15//! # Why a separate statement instead of a field
16//!
17//! `SessionParticipantStatement::canonical_for_signing` is covered by *two*
18//! signatures — the joining agent's and the host's countersign. Adding a
19//! field changes those bytes and invalidates both, which is why the original
20//! module deferred portability to "a schema-v2 question".
21//!
22//! This sidesteps that. The host signs a separate statement referencing the
23//! participant artifact by id. Additive, backward compatible, and the
24//! participant envelope stays byte-identical.
25//!
26//! # Why the interval, not a boolean
27//!
28//! "Fresh at join" is a checkmark that hides a duration. A nonce answered
29//! four seconds after it was issued and one answered forty minutes later both
30//! pass, and they are not the same evidence: the second leaves a window in
31//! which the agent's sandbox could have gone away, its key rotated, or its
32//! process been replaced between proving liveness and acting.
33//!
34//! So this records both endpoints and lets the reader do the subtraction,
35//! rather than pre-deciding a threshold on their behalf. Same reason
36//! [`crate::verify::anchoring`] reports a span instead of `anchored: true`.
37//!
38//! # What this does NOT prove
39//!
40//! Both timestamps come from the parties' own clocks — `challenge_issued_at`
41//! from the host, `response_signed_at` from the joining agent — so a
42//! *cooperating* pair can report any interval they like. This is evidence
43//! against a stale or replayed join, not against a host and agent colluding.
44//! Bounding that needs an external witness (`docs/specs/time-anchoring.md`).
45//!
46//! It also says nothing about what happened *after* the join. A short
47//! challenge window followed by an action six hours later is a short window
48//! and a long gap; this records the first and the receipt's own timeline
49//! records the second.
50
51use serde::{Deserialize, Serialize};
52
53use crate::attestation::{Signer, SignerError};
54
55use super::{nonce_digest, parse_rfc3339_to_unix};
56
57pub const TYPE_SESSION_LIVENESS: &str = "treeship/session-liveness/v1";
58
59/// Host-signed evidence that a specific join answered a specific challenge,
60/// with both endpoints of the window it took.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct SessionLivenessStatement {
63    #[serde(rename = "type")]
64    pub type_: String,
65
66    /// `session_id` the join belongs to. Must equal the participant
67    /// statement's `session_ref`; a verifier checks this rather than
68    /// trusting the link.
69    pub session_ref: String,
70
71    /// Artifact id of the participant envelope this attests. The link that
72    /// makes the evidence portable: given a participant receipt, a verifier
73    /// can find the liveness statement, and given this, they can find what it
74    /// describes.
75    pub participant_ref: String,
76
77    /// Joining agent's Ed25519 public key, base64url-no-pad. Duplicated from
78    /// the participant statement deliberately: it is bound into these signed
79    /// bytes, so this attestation cannot be re-pointed at a different join by
80    /// editing the reference alone.
81    pub joining_agent: String,
82
83    /// `sha256:<hex>` of the nonce, never the nonce itself. The nonce is
84    /// single-use liveness material; publishing it in a durable artifact
85    /// would hand a replayer the exact string the host was expecting.
86    /// A digest still lets the host's own records be reconciled against this.
87    pub nonce_digest: String,
88
89    /// RFC 3339. When the host minted the nonce.
90    pub challenge_issued_at: String,
91
92    /// RFC 3339. The `signed_at` the joining agent bound into its challenge
93    /// response — the agent's own claim about when it answered.
94    pub response_signed_at: String,
95}
96
97impl SessionLivenessStatement {
98    pub fn new(
99        session_ref: impl Into<String>,
100        participant_ref: impl Into<String>,
101        joining_agent: impl Into<String>,
102        nonce: &str,
103        challenge_issued_at: impl Into<String>,
104        response_signed_at: impl Into<String>,
105    ) -> Self {
106        Self {
107            type_: TYPE_SESSION_LIVENESS.into(),
108            session_ref: session_ref.into(),
109            participant_ref: participant_ref.into(),
110            joining_agent: joining_agent.into(),
111            nonce_digest: nonce_digest(nonce),
112            challenge_issued_at: challenge_issued_at.into(),
113            response_signed_at: response_signed_at.into(),
114        }
115    }
116
117    /// The challenge window in seconds: issue to answer.
118    ///
119    /// `None` when either timestamp will not parse, or when the answer
120    /// predates the challenge. A negative interval is not a small window —
121    /// it means at least one clock is wrong or one timestamp was fabricated,
122    /// and reporting it as a number would launder that into a reassuring
123    /// value. The caller must handle `None` as "cannot be evaluated".
124    pub fn interval_seconds(&self) -> Option<i64> {
125        let issued = parse_rfc3339_to_unix(&self.challenge_issued_at)? as i64;
126        let answered = parse_rfc3339_to_unix(&self.response_signed_at)? as i64;
127        let delta = answered - issued;
128        (delta >= 0).then_some(delta)
129    }
130
131    /// Canonical signing bytes. Same pipe-delimited shape as the other
132    /// session statements, every field positional so none can be omitted.
133    pub fn canonical_for_signing(&self) -> String {
134        format!(
135            "v1|session-liveness|{}|{}|{}|{}|{}|{}",
136            self.session_ref,
137            self.participant_ref,
138            self.joining_agent,
139            self.nonce_digest,
140            self.challenge_issued_at,
141            self.response_signed_at,
142        )
143    }
144
145    /// Signed by the **host**, not the joining agent. The host is the party
146    /// that issued the challenge and observed the answer; the agent cannot
147    /// attest to when it was asked.
148    pub fn sign_as_host(&self, host_signer: &dyn Signer) -> Result<String, SignerError> {
149        use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
150        let sig = host_signer.sign(self.canonical_for_signing().as_bytes())?;
151        Ok(URL_SAFE_NO_PAD.encode(sig))
152    }
153}
154
155/// How a reader should treat a join's liveness evidence.
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157#[serde(rename_all = "kebab-case")]
158pub enum LivenessVerdict {
159    /// A liveness statement exists and its window is computable.
160    Attested { interval_seconds: i64 },
161    /// A statement exists but its timestamps do not yield a usable interval.
162    /// Distinct from `Absent`: something was recorded and it does not make
163    /// sense, which is a stronger signal than nothing being recorded.
164    Malformed { reason: String },
165    /// No liveness statement for this join.
166    ///
167    /// Not the same as "the join was not challenged": every join before this
168    /// statement type existed looks like this, and so does one whose host ran
169    /// the check and did not record it. It means the evidence is unavailable,
170    /// which is what a verifier should say instead of guessing.
171    Absent,
172}
173
174impl LivenessVerdict {
175    pub fn from_statement(stmt: Option<&SessionLivenessStatement>) -> Self {
176        match stmt {
177            None => Self::Absent,
178            Some(s) => match s.interval_seconds() {
179                Some(interval_seconds) => Self::Attested { interval_seconds },
180                None => Self::Malformed {
181                    reason: format!(
182                        "challenge_issued_at {:?} and response_signed_at {:?} do not yield a \
183                         non-negative interval",
184                        s.challenge_issued_at, s.response_signed_at
185                    ),
186                },
187            },
188        }
189    }
190
191    /// One line for humans, phrased so `Absent` cannot be read as a pass.
192    pub fn summary(&self) -> String {
193        match self {
194            Self::Attested { interval_seconds } => {
195                format!("live-challenged, answered in {interval_seconds}s")
196            }
197            Self::Malformed { reason } => format!("liveness evidence unusable: {reason}"),
198            Self::Absent => "no liveness evidence — cannot tell a live-challenged join from an \
199                 unchallenged one"
200                .to_string(),
201        }
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use crate::attestation::Ed25519Signer;
209
210    fn stmt(issued: &str, answered: &str) -> SessionLivenessStatement {
211        SessionLivenessStatement::new(
212            "ssn_abc",
213            "art_0123456789abcdef0123456789abcdef",
214            "Zm9vYmFy",
215            "n_a_real_nonce_value_with_entropy",
216            issued,
217            answered,
218        )
219    }
220
221    #[test]
222    fn interval_is_the_window_from_challenge_to_answer() {
223        let s = stmt("2026-08-13T10:00:00Z", "2026-08-13T10:00:04Z");
224        assert_eq!(s.interval_seconds(), Some(4));
225    }
226
227    /// The case the boolean hid. Both of these pass a fresh-at-join check;
228    /// they are not the same evidence, and the interval is what says so.
229    #[test]
230    fn a_slow_answer_is_reported_not_flattened() {
231        let quick = stmt("2026-08-13T10:00:00Z", "2026-08-13T10:00:04Z");
232        let slow = stmt("2026-08-13T10:00:00Z", "2026-08-13T10:40:00Z");
233
234        assert_eq!(quick.interval_seconds(), Some(4));
235        assert_eq!(slow.interval_seconds(), Some(2400));
236        assert_ne!(
237            LivenessVerdict::from_statement(Some(&quick)),
238            LivenessVerdict::from_statement(Some(&slow)),
239            "a 4s and a 40m challenge window must not produce the same verdict"
240        );
241    }
242
243    /// An answer before its challenge is a broken clock or a fabricated
244    /// timestamp. Returning a negative number would let a caller comparing
245    /// `interval < 30` treat it as the freshest possible join.
246    #[test]
247    fn an_answer_before_its_challenge_is_not_a_small_interval() {
248        let s = stmt("2026-08-13T10:00:00Z", "2026-08-13T09:00:00Z");
249        assert_eq!(s.interval_seconds(), None);
250        assert!(matches!(
251            LivenessVerdict::from_statement(Some(&s)),
252            LivenessVerdict::Malformed { .. }
253        ));
254    }
255
256    #[test]
257    fn absent_evidence_does_not_read_as_a_pass() {
258        let v = LivenessVerdict::from_statement(None);
259        assert_eq!(v, LivenessVerdict::Absent);
260        let s = v.summary();
261        assert!(s.contains("no liveness evidence"), "{s}");
262        assert!(
263            !s.contains("live-challenged,"),
264            "absent must not be phrased like an attestation: {s}"
265        );
266    }
267
268    /// The nonce is single-use liveness material. Publishing it in a durable
269    /// artifact would hand a replayer the string the host was expecting.
270    #[test]
271    fn the_nonce_itself_never_enters_the_statement() {
272        let nonce = "n_super_secret_nonce_material_xyz";
273        let s = SessionLivenessStatement::new(
274            "ssn_abc",
275            "art_x",
276            "pk",
277            nonce,
278            "2026-08-13T10:00:00Z",
279            "2026-08-13T10:00:01Z",
280        );
281        let json = serde_json::to_string(&s).unwrap();
282        assert!(!json.contains(nonce), "raw nonce leaked into the statement");
283        assert!(s.nonce_digest.starts_with("sha256:"));
284        assert!(!s.canonical_for_signing().contains(nonce));
285    }
286
287    /// Every field must be bound, or the attestation could be re-pointed at a
288    /// different join by editing an unsigned reference.
289    #[test]
290    fn every_field_is_bound_into_the_signed_bytes() {
291        let base = stmt("2026-08-13T10:00:00Z", "2026-08-13T10:00:04Z");
292        let canon = base.canonical_for_signing();
293
294        let mut other_session = base.clone();
295        other_session.session_ref = "ssn_other".into();
296        let mut other_participant = base.clone();
297        other_participant.participant_ref = "art_ffffffffffffffffffffffffffffffff".into();
298        let mut other_agent = base.clone();
299        other_agent.joining_agent = "b3RoZXI".into();
300
301        for variant in [other_session, other_participant, other_agent] {
302            assert_ne!(
303                canon,
304                variant.canonical_for_signing(),
305                "a changed field left the signed bytes identical"
306            );
307        }
308    }
309
310    #[test]
311    fn host_signature_verifies_over_the_canonical_bytes() {
312        use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
313        use ed25519_dalek::{Signature, Verifier, VerifyingKey};
314
315        let host = Ed25519Signer::from_bytes("host", &[7u8; 32]).unwrap();
316        let s = stmt("2026-08-13T10:00:00Z", "2026-08-13T10:00:04Z");
317        let sig_b64 = s.sign_as_host(&host).unwrap();
318
319        let sig_bytes: [u8; 64] = URL_SAFE_NO_PAD
320            .decode(&sig_b64)
321            .unwrap()
322            .try_into()
323            .unwrap();
324        let vk_bytes: [u8; 32] = host.public_key_bytes().try_into().unwrap();
325        let vk = VerifyingKey::from_bytes(&vk_bytes).unwrap();
326
327        assert!(vk
328            .verify(
329                s.canonical_for_signing().as_bytes(),
330                &Signature::from_bytes(&sig_bytes)
331            )
332            .is_ok());
333
334        // And must not verify over a tampered statement.
335        let mut tampered = s.clone();
336        tampered.response_signed_at = "2026-08-13T10:00:03Z".into();
337        assert!(vk
338            .verify(
339                tampered.canonical_for_signing().as_bytes(),
340                &Signature::from_bytes(&sig_bytes)
341            )
342            .is_err());
343    }
344}