use sha2::{Digest, Sha256};
use std::collections::VecDeque;
use std::sync::Mutex;
use std::time::{Duration, Instant};
#[derive(Clone, Debug, Default)]
pub struct SignatureSpec {
pub key_id: Option<String>,
pub algorithm: Option<String>,
pub created: Option<u64>,
pub expires: Option<u64>,
pub covered_components: Vec<String>,
}
pub fn canonical_envelope(value: &serde_json::Value) -> Vec<u8> {
let mut out = String::new();
write_canonical(value, &mut out);
out.into_bytes()
}
fn write_canonical(v: &serde_json::Value, out: &mut String) {
match v {
serde_json::Value::Null => out.push_str("null"),
serde_json::Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
serde_json::Value::Number(n) => out.push_str(&n.to_string()),
serde_json::Value::String(s) => {
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => {
out.push_str(&format!("\\u{:04x}", c as u32));
}
c => out.push(c),
}
}
out.push('"');
}
serde_json::Value::Array(arr) => {
out.push('[');
for (i, v) in arr.iter().enumerate() {
if i > 0 {
out.push(',');
}
write_canonical(v, out);
}
out.push(']');
}
serde_json::Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
out.push('{');
for (i, k) in keys.iter().enumerate() {
if i > 0 {
out.push(',');
}
write_canonical(&serde_json::Value::String((*k).clone()), out);
out.push(':');
write_canonical(&map[*k], out);
}
out.push('}');
}
}
}
pub fn parse_signature_input(input: &str) -> SignatureSpec {
let mut spec = SignatureSpec::default();
let body = input
.splitn(2, '=')
.nth(1)
.unwrap_or(input);
for piece in body.split(';') {
let piece = piece.trim();
if piece.starts_with('(') {
let inner = piece.trim_start_matches('(').trim_end_matches(')');
spec.covered_components = inner
.split_whitespace()
.map(|s| s.trim_matches('"').to_string())
.collect();
} else if let Some((k, v)) = piece.split_once('=') {
let v = v.trim_matches('"').trim();
match k.trim() {
"keyid" => spec.key_id = Some(v.to_string()),
"alg" => spec.algorithm = Some(v.to_string()),
"created" => spec.created = v.parse().ok(),
"expires" => spec.expires = v.parse().ok(),
_ => {}
}
}
}
spec
}
pub struct ReplayCache {
seen: Mutex<VecDeque<(String, Instant)>>,
ttl: Duration,
}
impl ReplayCache {
pub fn new(ttl: Duration) -> Self {
Self {
seen: Mutex::new(VecDeque::new()),
ttl,
}
}
pub fn check_and_record(&self, envelope_id: &str) -> std::result::Result<(), &'static str> {
let now = Instant::now();
let mut g = self.seen.lock().expect("replay cache lock poisoned");
while let Some((_, t)) = g.front() {
if now.duration_since(*t) > self.ttl {
g.pop_front();
} else {
break;
}
}
if g.iter().any(|(id, _)| id == envelope_id) {
return Err("replay detected");
}
g.push_back((envelope_id.to_string(), now));
Ok(())
}
}
pub fn body_digest_hex(body: &[u8]) -> String {
let mut h = Sha256::new();
h.update(body);
let d = h.finalize();
let mut s = String::with_capacity(d.len() * 2);
for b in d.iter() {
s.push_str(&format!("{:02x}", b));
}
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn jcs_empty_object_canonicalizes_to_empty() {
let v: serde_json::Value = serde_json::from_str("{}").unwrap();
assert_eq!(canonical_envelope(&v), b"{}");
}
#[test]
fn jcs_empty_array_canonicalizes_to_empty() {
let v: serde_json::Value = serde_json::from_str("[]").unwrap();
assert_eq!(canonical_envelope(&v), b"[]");
}
#[test]
fn jcs_object_keys_sort_lexicographically_codepoint() {
let v: serde_json::Value =
serde_json::from_str(r#"{"ab":1,"Aa":2}"#).unwrap();
let out = String::from_utf8(canonical_envelope(&v)).unwrap();
assert_eq!(out, r#"{"Aa":2,"ab":1}"#);
}
#[test]
fn jcs_nested_objects_sort_recursively() {
let v: serde_json::Value =
serde_json::from_str(r#"{"b":{"d":2,"c":1},"a":[1,2,3]}"#).unwrap();
let out = String::from_utf8(canonical_envelope(&v)).unwrap();
assert_eq!(out, r#"{"a":[1,2,3],"b":{"c":1,"d":2}}"#);
}
#[test]
fn jcs_array_order_preserved() {
let v: serde_json::Value =
serde_json::from_str(r#"[3,1,2,{"y":1,"x":2}]"#).unwrap();
let out = String::from_utf8(canonical_envelope(&v)).unwrap();
assert_eq!(out, r#"[3,1,2,{"x":2,"y":1}]"#);
}
#[test]
fn jcs_string_escapes_match_rfc8785() {
let v = serde_json::Value::String("\"\\\n\r\t".into());
let out = String::from_utf8(canonical_envelope(&v)).unwrap();
assert_eq!(out, r#""\"\\\n\r\t""#);
}
#[test]
fn jcs_unicode_codepoints_above_ascii_pass_through_utf8() {
let v = serde_json::Value::String("é".into());
let out = canonical_envelope(&v);
assert_eq!(out, b"\"\xC3\xA9\"");
}
#[test]
fn jcs_integer_numbers_round_trip() {
let v: serde_json::Value = serde_json::from_str("123").unwrap();
assert_eq!(canonical_envelope(&v), b"123");
let v: serde_json::Value = serde_json::from_str("-7").unwrap();
assert_eq!(canonical_envelope(&v), b"-7");
let v: serde_json::Value = serde_json::from_str("0").unwrap();
assert_eq!(canonical_envelope(&v), b"0");
}
#[test]
fn jcs_bool_and_null() {
assert_eq!(canonical_envelope(&serde_json::Value::Bool(true)), b"true");
assert_eq!(canonical_envelope(&serde_json::Value::Bool(false)), b"false");
assert_eq!(canonical_envelope(&serde_json::Value::Null), b"null");
}
#[test]
fn jcs_realistic_uctp_envelope_round_trips() {
let scrambled = serde_json::json!({
"v": 1,
"ts": "2025-12-01T12:00:00Z",
"id": "env_01",
"type": "session.invite",
"payload": {
"to": ["part_b", "part_a"],
"from": "part_alice",
"medium": "voice",
},
});
let sorted = serde_json::json!({
"id": "env_01",
"payload": {
"from": "part_alice",
"medium": "voice",
"to": ["part_b", "part_a"],
},
"ts": "2025-12-01T12:00:00Z",
"type": "session.invite",
"v": 1,
});
assert_eq!(canonical_envelope(&scrambled), canonical_envelope(&sorted));
}
#[test]
fn parse_signature_input_with_only_components() {
let s = parse_signature_input(r#"sig1=("@method")"#);
assert_eq!(s.covered_components, vec!["@method".to_string()]);
assert_eq!(s.key_id, None);
}
#[test]
fn parse_signature_input_handles_multiple_params() {
let s = parse_signature_input(
r#"sig1=("@method" "content-digest");keyid="k1";alg="ed25519";created=1234;expires=5678"#,
);
assert_eq!(s.key_id.as_deref(), Some("k1"));
assert_eq!(s.algorithm.as_deref(), Some("ed25519"));
assert_eq!(s.created, Some(1234));
assert_eq!(s.expires, Some(5678));
assert_eq!(s.covered_components.len(), 2);
}
#[test]
fn replay_cache_evicts_expired_entries() {
let c = ReplayCache::new(Duration::from_millis(50));
c.check_and_record("e-1").unwrap();
assert!(c.check_and_record("e-1").is_err());
std::thread::sleep(Duration::from_millis(80));
c.check_and_record("e-1").expect("replay TTL must elapse");
}
#[test]
fn replay_cache_handles_interleaved_envelopes() {
let c = ReplayCache::new(Duration::from_secs(60));
c.check_and_record("a").unwrap();
c.check_and_record("b").unwrap();
c.check_and_record("c").unwrap();
assert!(c.check_and_record("b").is_err(), "duplicate b rejected");
assert!(c.check_and_record("a").is_err(), "duplicate a rejected");
c.check_and_record("d").unwrap();
}
#[test]
fn body_digest_hex_known_vectors() {
assert_eq!(
body_digest_hex(b""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
assert_eq!(
body_digest_hex(b"abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
}