use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use ed25519_dalek::{Signature, VerifyingKey};
use sha2::{Digest, Sha256};
pub fn join_challenge_canonical(
session_ref: &str,
participant_id: &str,
joining_agent: &str,
nonce: &str,
signed_at: &str,
) -> Vec<u8> {
let d = |s: &str| hex::encode(Sha256::digest(s.as_bytes()));
format!(
"v1|room-join-challenge|{}|{}|{}|{}|{signed_at}",
d(session_ref),
d(participant_id),
d(joining_agent),
d(nonce),
)
.into_bytes()
}
pub fn check_join_challenge(
response: &serde_json::Value,
session_ref: &str,
participant_id: &str,
joining_agent_pubkey_b64: &str,
expected_nonce: &str,
joining_agent_vk: &VerifyingKey,
) -> Result<String, String> {
let nonce = response
.get("nonce")
.and_then(|v| v.as_str())
.ok_or("challenge response carries no nonce")?;
if nonce != expected_nonce {
return Err(
"challenge response nonce does not match the one the host issued -- this answers a DIFFERENT challenge (replay?)"
.into(),
);
}
let signed_at = response
.get("signed_at")
.and_then(|v| v.as_str())
.ok_or("challenge response carries no signed_at")?;
let sig_b64 = response
.get("signature")
.and_then(|v| v.as_str())
.ok_or("challenge response carries no signature")?;
let sig_bytes = URL_SAFE_NO_PAD
.decode(sig_b64)
.map_err(|_| "challenge response signature is not valid base64url".to_string())?;
let sig_arr: [u8; 64] = sig_bytes
.as_slice()
.try_into()
.map_err(|_| "challenge response signature is not 64 bytes".to_string())?;
let canonical = join_challenge_canonical(
session_ref,
participant_id,
joining_agent_pubkey_b64,
expected_nonce,
signed_at,
);
joining_agent_vk
.verify_strict(&canonical, &Signature::from_bytes(&sig_arr))
.map_err(|_| {
"challenge response signature INVALID for the joining agent's key".to_string()
})?;
Ok(signed_at.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::attestation::{Ed25519Signer, Signer};
fn signer() -> Ed25519Signer {
Ed25519Signer::from_bytes("agent", &[9u8; 32]).unwrap()
}
fn vk_of(s: &Ed25519Signer) -> VerifyingKey {
let bytes: [u8; 32] = s.public_key_bytes().try_into().unwrap();
VerifyingKey::from_bytes(&bytes).unwrap()
}
fn signed_response(
signer: &Ed25519Signer,
session_ref: &str,
participant_id: &str,
joining_agent: &str,
nonce: &str,
signed_at: &str,
) -> serde_json::Value {
let canonical =
join_challenge_canonical(session_ref, participant_id, joining_agent, nonce, signed_at);
let sig = signer.sign(&canonical).unwrap();
serde_json::json!({
"nonce": nonce,
"signed_at": signed_at,
"signature": URL_SAFE_NO_PAD.encode(sig),
})
}
#[test]
fn canonical_is_deterministic_and_binds_every_field() {
let base = join_challenge_canonical(
"ssn_1",
"art_p1",
"agentpk",
"nonce1",
"2026-08-06T00:00:00Z",
);
assert_eq!(
base,
join_challenge_canonical(
"ssn_1",
"art_p1",
"agentpk",
"nonce1",
"2026-08-06T00:00:00Z"
),
);
assert_ne!(
base,
join_challenge_canonical(
"ssn_2",
"art_p1",
"agentpk",
"nonce1",
"2026-08-06T00:00:00Z"
),
"session_ref must bind"
);
assert_ne!(
base,
join_challenge_canonical(
"ssn_1",
"art_p2",
"agentpk",
"nonce1",
"2026-08-06T00:00:00Z"
),
"participant_id must bind"
);
assert_ne!(
base,
join_challenge_canonical(
"ssn_1",
"art_p1",
"otherpk",
"nonce1",
"2026-08-06T00:00:00Z"
),
"joining_agent must bind"
);
assert_ne!(
base,
join_challenge_canonical(
"ssn_1",
"art_p1",
"agentpk",
"nonce2",
"2026-08-06T00:00:00Z"
),
"nonce must bind"
);
assert_ne!(
base,
join_challenge_canonical(
"ssn_1",
"art_p1",
"agentpk",
"nonce1",
"2026-08-06T01:00:00Z"
),
"signed_at must bind"
);
}
#[test]
fn canonical_resists_separator_injection() {
let a = join_challenge_canonical("ssn|1", "art|p1", "pk", "n", "2026-08-06T00:00:00Z");
let b = join_challenge_canonical("ssn", "1|art|p1", "pk", "n", "2026-08-06T00:00:00Z");
assert_ne!(a, b);
}
#[test]
fn valid_response_verifies() {
let s = signer();
let vk = vk_of(&s);
let resp = signed_response(
&s,
"ssn_1",
"art_p1",
"agentpk",
"n_abc",
"2026-08-06T00:00:00Z",
);
let signed_at =
check_join_challenge(&resp, "ssn_1", "art_p1", "agentpk", "n_abc", &vk).unwrap();
assert_eq!(signed_at, "2026-08-06T00:00:00Z");
}
#[test]
fn wrong_nonce_is_rejected() {
let s = signer();
let vk = vk_of(&s);
let resp = signed_response(
&s,
"ssn_1",
"art_p1",
"agentpk",
"n_abc",
"2026-08-06T00:00:00Z",
);
let err =
check_join_challenge(&resp, "ssn_1", "art_p1", "agentpk", "n_other", &vk).unwrap_err();
assert!(err.contains("does not match"), "got: {err}");
}
#[test]
fn wrong_signer_is_rejected() {
let s = signer();
let other = Ed25519Signer::from_bytes("imposter", &[42u8; 32]).unwrap();
let vk = vk_of(&other);
let resp = signed_response(
&s,
"ssn_1",
"art_p1",
"agentpk",
"n_abc",
"2026-08-06T00:00:00Z",
);
let err =
check_join_challenge(&resp, "ssn_1", "art_p1", "agentpk", "n_abc", &vk).unwrap_err();
assert!(err.contains("INVALID"), "got: {err}");
}
#[test]
fn replayed_response_for_a_different_participant_is_rejected() {
let s = signer();
let vk = vk_of(&s);
let resp = signed_response(
&s,
"ssn_1",
"art_p1",
"agentpk",
"n_abc",
"2026-08-06T00:00:00Z",
);
let err =
check_join_challenge(&resp, "ssn_1", "art_p2", "agentpk", "n_abc", &vk).unwrap_err();
assert!(err.contains("INVALID"), "got: {err}");
}
}
pub const MIN_NONCE_LEN: usize = 32;
pub fn validate_nonce(nonce: &str) -> Result<(), String> {
if nonce.len() < MIN_NONCE_LEN {
return Err(format!(
"challenge nonce is {} characters; at least {MIN_NONCE_LEN} are required.\n \
A short nonce can be guessed and pre-signed, which makes the liveness \
proof prove nothing.\n Mint one with: treeship session mint-challenge",
nonce.len()
));
}
let distinct: std::collections::HashSet<char> = nonce.chars().collect();
if distinct.len() < 8 {
return Err(format!(
"challenge nonce uses only {} distinct characters; it is long but \
predictable.\n Mint one with: treeship session mint-challenge",
distinct.len()
));
}
Ok(())
}
pub fn mint_nonce() -> String {
use rand::RngCore;
let mut buf = [0u8; 16];
rand::rngs::OsRng.fill_bytes(&mut buf);
hex::encode(buf)
}
#[cfg(test)]
mod nonce_tests {
use super::*;
#[test]
fn minted_nonces_are_128_bit_hex_and_unique() {
let a = mint_nonce();
assert_eq!(a.len(), 32, "expected 32 hex chars (128 bits)");
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
assert!(validate_nonce(&a).is_ok());
let set: std::collections::HashSet<String> = (0..1000).map(|_| mint_nonce()).collect();
assert_eq!(set.len(), 1000, "minted nonces collided");
}
#[test]
fn the_nonce_from_our_help_text_is_rejected() {
assert!(validate_nonce("n_8f2a").is_err());
}
#[test]
fn long_but_predictable_is_rejected() {
assert!(validate_nonce(&"a".repeat(64)).is_err(), "repeated char");
assert!(validate_nonce(&"abab".repeat(16)).is_err(), "tiny alphabet");
}
#[test]
fn a_real_nonce_passes() {
assert!(validate_nonce("9f2c4a1b8e5d7061f3a2c9b4e8d17520").is_ok());
}
}