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}