Skip to main content

rvoip_auth_core/
sig9421.rs

1//! RFC 9421 HTTP Message Signatures — UCTP inline envelope variant.
2//!
3//! Per [CONVERSATION_PROTOCOL.md §5.5.1], signed UCTP envelopes carry
4//! an inline `signature: { keyid, alg, sig }` object. Verification:
5//!
6//! 1. Parse the envelope as JSON.
7//! 2. Clone it and remove the `signature` field.
8//! 3. Serialize the clone using RFC 8785 JSON Canonical Form.
9//! 4. Verify `signature.sig` over the canonicalized bytes using the
10//!    public key resolved via `signature.keyid`.
11//! 5. Check `envelope.id` is not in the replay cache; add it.
12//! 6. Check `envelope.ts` is within the cache TTL.
13//!
14//! v0 ships [`Sig9421Verifier`] for Ed25519 keys (the recommended
15//! algorithm per §5.5.1). Other algorithms (`ES256`, `PS256`, `RS256`)
16//! follow the same shape — gated behind future enhancements as
17//! deployments need them.
18//!
19//! Replay protection mirrors [`crate::dpop`]'s moka-based JTI cache:
20//! the envelope's `id` is the deduplication key; default TTL is 5
21//! minutes per the spec.
22
23use std::sync::Arc;
24use std::time::Duration;
25
26use base64::Engine;
27use chrono::{DateTime, Utc};
28use moka::future::Cache;
29use ring::signature::{UnparsedPublicKey, ED25519};
30use serde::{Deserialize, Serialize};
31use thiserror::Error;
32
33/// Maximum age of a signed envelope's `ts` field. Envelopes older
34/// than this are rejected as stale per spec §5.5.1. Default matches
35/// the spec's 5-minute window.
36pub const DEFAULT_SIG_REPLAY_TTL: Duration = Duration::from_secs(300);
37
38/// Maximum number of envelope IDs the replay cache holds before
39/// LRU eviction. Mirrors [`crate::dpop::DEFAULT_JTI_CACHE_CAPACITY`].
40pub const DEFAULT_REPLAY_CACHE_CAPACITY: u64 = 100_000;
41
42#[derive(Debug, Error)]
43pub enum Sig9421Error {
44    #[error("envelope missing required `signature` field")]
45    MissingSignature,
46
47    #[error("malformed signature object: {0}")]
48    MalformedSignature(String),
49
50    #[error("unsupported signature algorithm: {0}")]
51    UnsupportedAlgorithm(String),
52
53    #[error("signature keyid `{0}` does not resolve to a registered public key")]
54    UnknownKeyid(String),
55
56    #[error("signature verification failed")]
57    InvalidSignature,
58
59    #[error("envelope replay detected: id `{0}` already seen")]
60    ReplayDetected(String),
61
62    #[error("envelope timestamp `{0}` is older than the replay window")]
63    StaleTimestamp(String),
64
65    #[error("envelope is not a JSON object")]
66    MalformedEnvelope,
67
68    #[error("envelope `id` field missing or not a string")]
69    MissingEnvelopeId,
70
71    #[error("envelope `ts` field missing or not a valid RFC 3339 timestamp")]
72    InvalidEnvelopeTimestamp,
73}
74
75/// Inline `signature` field on a signed envelope. See
76/// CONVERSATION_PROTOCOL.md §5.5.1.
77#[derive(Clone, Debug, Serialize, Deserialize)]
78pub struct EnvelopeSignature {
79    pub keyid: String,
80    /// JWA algorithm name (e.g. `"EdDSA"`, `"ES256"`).
81    pub alg: String,
82    /// Base64url-encoded (no padding) signature bytes.
83    pub sig: String,
84}
85
86/// Trait the verifier uses to look up the public key bytes for a
87/// `keyid`. Production deployments back this with their identity
88/// store; tests typically use [`StaticKeyResolver`].
89pub trait KeyResolver: Send + Sync {
90    /// Returns the raw public key bytes for `keyid`, or `None` if
91    /// the keyid is unknown. For Ed25519 the slice is the 32-byte
92    /// raw public key.
93    fn resolve(&self, keyid: &str) -> Option<Vec<u8>>;
94}
95
96/// In-memory key resolver — useful for tests and static deployments.
97pub struct StaticKeyResolver {
98    keys: std::collections::HashMap<String, Vec<u8>>,
99}
100
101impl StaticKeyResolver {
102    pub fn new() -> Self {
103        Self {
104            keys: std::collections::HashMap::new(),
105        }
106    }
107
108    pub fn insert(&mut self, keyid: impl Into<String>, public_key: Vec<u8>) {
109        self.keys.insert(keyid.into(), public_key);
110    }
111}
112
113impl Default for StaticKeyResolver {
114    fn default() -> Self {
115        Self::new()
116    }
117}
118
119impl KeyResolver for StaticKeyResolver {
120    fn resolve(&self, keyid: &str) -> Option<Vec<u8>> {
121        self.keys.get(keyid).cloned()
122    }
123}
124
125/// Verifier for inline RFC 9421 envelope signatures.
126///
127/// Owns the replay-protection cache, so a single verifier instance
128/// should be shared across all envelopes that should not replay
129/// against one another (typically one per Connection or one per
130/// process, depending on the threat model).
131pub struct Sig9421Verifier {
132    resolver: Arc<dyn KeyResolver>,
133    replay_cache: Cache<String, ()>,
134    ttl: Duration,
135}
136
137impl Sig9421Verifier {
138    pub fn new(resolver: Arc<dyn KeyResolver>) -> Self {
139        Self::with_ttl(resolver, DEFAULT_SIG_REPLAY_TTL)
140    }
141
142    pub fn with_ttl(resolver: Arc<dyn KeyResolver>, ttl: Duration) -> Self {
143        Self {
144            resolver,
145            replay_cache: Cache::builder()
146                .max_capacity(DEFAULT_REPLAY_CACHE_CAPACITY)
147                .time_to_live(ttl)
148                .build(),
149            ttl,
150        }
151    }
152
153    /// Verify an inline-signed envelope. `envelope` is the parsed
154    /// JSON value (as it arrived on the wire — typically via
155    /// `serde_json::from_str`). On success the envelope's id is
156    /// added to the replay cache so subsequent calls with the same
157    /// id are rejected.
158    pub async fn verify(&self, envelope: &serde_json::Value) -> Result<(), Sig9421Error> {
159        let obj = envelope
160            .as_object()
161            .ok_or(Sig9421Error::MalformedEnvelope)?;
162
163        // 1. Pull the signature field.
164        let sig_value = obj.get("signature").ok_or(Sig9421Error::MissingSignature)?;
165        let signature: EnvelopeSignature = serde_json::from_value(sig_value.clone())
166            .map_err(|e| Sig9421Error::MalformedSignature(e.to_string()))?;
167
168        // 2. Pull envelope id + ts for replay / freshness checks.
169        let env_id = obj
170            .get("id")
171            .and_then(|v| v.as_str())
172            .ok_or(Sig9421Error::MissingEnvelopeId)?
173            .to_string();
174        let env_ts_str = obj
175            .get("ts")
176            .and_then(|v| v.as_str())
177            .ok_or(Sig9421Error::InvalidEnvelopeTimestamp)?;
178        let env_ts: DateTime<Utc> = DateTime::parse_from_rfc3339(env_ts_str)
179            .map_err(|_| Sig9421Error::InvalidEnvelopeTimestamp)?
180            .with_timezone(&Utc);
181        let age = Utc::now().signed_duration_since(env_ts);
182        if age > chrono::Duration::from_std(self.ttl).unwrap_or(chrono::Duration::seconds(300)) {
183            return Err(Sig9421Error::StaleTimestamp(env_ts_str.to_string()));
184        }
185
186        // 3. Build the canonicalization base: clone the envelope,
187        // strip `signature`, JCS-serialize.
188        let mut bare = obj.clone();
189        bare.remove("signature");
190        let canonical = jcs_canonicalize(&serde_json::Value::Object(bare));
191
192        // 4. Resolve the signing key and verify.
193        let pubkey = self
194            .resolver
195            .resolve(&signature.keyid)
196            .ok_or_else(|| Sig9421Error::UnknownKeyid(signature.keyid.clone()))?;
197        let sig_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
198            .decode(signature.sig.as_bytes())
199            .map_err(|_| Sig9421Error::InvalidSignature)?;
200
201        match signature.alg.as_str() {
202            "EdDSA" => {
203                let key = UnparsedPublicKey::new(&ED25519, &pubkey);
204                key.verify(canonical.as_bytes(), &sig_bytes)
205                    .map_err(|_| Sig9421Error::InvalidSignature)?;
206            }
207            other => return Err(Sig9421Error::UnsupportedAlgorithm(other.to_string())),
208        }
209
210        // 5. Replay check after signature passes (don't burn cache
211        //    slots on rejected signatures).
212        if self.replay_cache.get(&env_id).await.is_some() {
213            return Err(Sig9421Error::ReplayDetected(env_id));
214        }
215        self.replay_cache.insert(env_id, ()).await;
216
217        Ok(())
218    }
219}
220
221/// RFC 8785 JSON Canonical Form serializer for the envelope shape
222/// (objects of strings/numbers/booleans/null/arrays/sub-objects).
223///
224/// Key properties: object keys sorted by code unit, no insignificant
225/// whitespace, strings escaped per JSON, numbers in the shortest
226/// round-trip form. Our envelope payload is bounded to these JSON
227/// primitives (no exotic types), so this minimal implementation is
228/// sufficient. Production hardening would swap in a fully RFC-8785-
229/// compliant crate (e.g. `serde_jcs`).
230pub fn jcs_canonicalize(value: &serde_json::Value) -> String {
231    let mut out = String::new();
232    jcs_write(value, &mut out);
233    out
234}
235
236fn jcs_write(value: &serde_json::Value, out: &mut String) {
237    match value {
238        serde_json::Value::Null => out.push_str("null"),
239        serde_json::Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
240        serde_json::Value::Number(n) => {
241            // serde_json's Number Display uses the shortest
242            // round-trip representation per the underlying float
243            // formatter, which is consistent with JCS for finite
244            // numbers we'd see in an envelope.
245            out.push_str(&n.to_string());
246        }
247        serde_json::Value::String(s) => {
248            out.push('"');
249            for ch in s.chars() {
250                match ch {
251                    '"' => out.push_str("\\\""),
252                    '\\' => out.push_str("\\\\"),
253                    '\n' => out.push_str("\\n"),
254                    '\r' => out.push_str("\\r"),
255                    '\t' => out.push_str("\\t"),
256                    '\u{08}' => out.push_str("\\b"),
257                    '\u{0c}' => out.push_str("\\f"),
258                    c if (c as u32) < 0x20 => {
259                        out.push_str(&format!("\\u{:04x}", c as u32));
260                    }
261                    c => out.push(c),
262                }
263            }
264            out.push('"');
265        }
266        serde_json::Value::Array(items) => {
267            out.push('[');
268            for (i, item) in items.iter().enumerate() {
269                if i > 0 {
270                    out.push(',');
271                }
272                jcs_write(item, out);
273            }
274            out.push(']');
275        }
276        serde_json::Value::Object(map) => {
277            let mut keys: Vec<&String> = map.keys().collect();
278            keys.sort();
279            out.push('{');
280            for (i, key) in keys.iter().enumerate() {
281                if i > 0 {
282                    out.push(',');
283                }
284                jcs_write(&serde_json::Value::String((*key).clone()), out);
285                out.push(':');
286                jcs_write(&map[*key], out);
287            }
288            out.push('}');
289        }
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use ring::rand::{SecureRandom, SystemRandom};
297    use ring::signature::{Ed25519KeyPair, KeyPair};
298
299    fn signing_keypair() -> (Ed25519KeyPair, Vec<u8>) {
300        let rng = SystemRandom::new();
301        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
302        let kp = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
303        let pub_bytes = kp.public_key().as_ref().to_vec();
304        (kp, pub_bytes)
305    }
306
307    fn build_envelope() -> serde_json::Value {
308        // Use a fresh "now" so the freshness check passes.
309        serde_json::json!({
310            "v": 1,
311            "type": "session.invite",
312            "id": "env_sig_test_1",
313            "ts": Utc::now().to_rfc3339(),
314            "sid": "sess_abc",
315            "cid": "conv_abc",
316            "payload": {
317                "from": "part_alice",
318                "to": ["part_bob"],
319                "medium": "voice",
320            }
321        })
322    }
323
324    fn sign_envelope(envelope: &mut serde_json::Value, keyid: &str, kp: &Ed25519KeyPair) {
325        // Strip any existing signature, canonicalize, sign, re-attach.
326        let obj = envelope.as_object_mut().unwrap();
327        obj.remove("signature");
328        let canonical = jcs_canonicalize(envelope);
329        let sig = kp.sign(canonical.as_bytes());
330        let sig_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sig.as_ref());
331        let obj = envelope.as_object_mut().unwrap();
332        obj.insert(
333            "signature".to_string(),
334            serde_json::json!({
335                "keyid": keyid,
336                "alg": "EdDSA",
337                "sig": sig_b64,
338            }),
339        );
340    }
341
342    #[tokio::test]
343    async fn round_trip_signed_envelope_verifies() {
344        let (kp, pubkey) = signing_keypair();
345        let mut resolver = StaticKeyResolver::new();
346        resolver.insert("key:agent-1", pubkey);
347        let verifier = Sig9421Verifier::new(Arc::new(resolver));
348
349        let mut env = build_envelope();
350        sign_envelope(&mut env, "key:agent-1", &kp);
351
352        verifier.verify(&env).await.expect("verify");
353    }
354
355    #[tokio::test]
356    async fn tampered_payload_fails_verification() {
357        let (kp, pubkey) = signing_keypair();
358        let mut resolver = StaticKeyResolver::new();
359        resolver.insert("key:agent-1", pubkey);
360        let verifier = Sig9421Verifier::new(Arc::new(resolver));
361
362        let mut env = build_envelope();
363        sign_envelope(&mut env, "key:agent-1", &kp);
364
365        // Mutate the payload after signing.
366        env["payload"]["from"] = serde_json::json!("part_mallory");
367
368        let err = verifier.verify(&env).await.unwrap_err();
369        assert!(matches!(err, Sig9421Error::InvalidSignature));
370    }
371
372    #[tokio::test]
373    async fn replay_rejected_on_second_call() {
374        let (kp, pubkey) = signing_keypair();
375        let mut resolver = StaticKeyResolver::new();
376        resolver.insert("key:agent-1", pubkey);
377        let verifier = Sig9421Verifier::new(Arc::new(resolver));
378
379        let mut env = build_envelope();
380        sign_envelope(&mut env, "key:agent-1", &kp);
381
382        verifier.verify(&env).await.expect("first verify");
383        let err = verifier.verify(&env).await.unwrap_err();
384        assert!(matches!(err, Sig9421Error::ReplayDetected(_)));
385    }
386
387    #[tokio::test]
388    async fn unknown_keyid_rejected() {
389        let (kp, _pubkey) = signing_keypair();
390        // Resolver has no keys.
391        let verifier = Sig9421Verifier::new(Arc::new(StaticKeyResolver::new()));
392        let mut env = build_envelope();
393        sign_envelope(&mut env, "key:agent-unknown", &kp);
394
395        let err = verifier.verify(&env).await.unwrap_err();
396        assert!(matches!(err, Sig9421Error::UnknownKeyid(_)));
397    }
398
399    #[tokio::test]
400    async fn cross_key_tampering_rejected() {
401        let (kp_a, pubkey_a) = signing_keypair();
402        let (_kp_b, pubkey_b) = signing_keypair();
403        let mut resolver = StaticKeyResolver::new();
404        // Register pubkey_b under agent-1, but sign with kp_a.
405        resolver.insert("key:agent-1", pubkey_b);
406        let _ = pubkey_a;
407        let verifier = Sig9421Verifier::new(Arc::new(resolver));
408        let mut env = build_envelope();
409        sign_envelope(&mut env, "key:agent-1", &kp_a);
410
411        let err = verifier.verify(&env).await.unwrap_err();
412        assert!(matches!(err, Sig9421Error::InvalidSignature));
413    }
414
415    #[tokio::test]
416    async fn stale_timestamp_rejected() {
417        let (kp, pubkey) = signing_keypair();
418        let mut resolver = StaticKeyResolver::new();
419        resolver.insert("key:agent-1", pubkey);
420        // Very short TTL to make the test deterministic.
421        let verifier = Sig9421Verifier::with_ttl(Arc::new(resolver), Duration::from_secs(2));
422
423        let mut env = build_envelope();
424        // Backdate the envelope by an hour.
425        env["ts"] = serde_json::json!((Utc::now() - chrono::Duration::hours(1)).to_rfc3339());
426        sign_envelope(&mut env, "key:agent-1", &kp);
427
428        let err = verifier.verify(&env).await.unwrap_err();
429        assert!(matches!(err, Sig9421Error::StaleTimestamp(_)));
430    }
431
432    #[tokio::test]
433    async fn missing_signature_field_returns_typed_error() {
434        let verifier = Sig9421Verifier::new(Arc::new(StaticKeyResolver::new()));
435        let env = build_envelope();
436        let err = verifier.verify(&env).await.unwrap_err();
437        assert!(matches!(err, Sig9421Error::MissingSignature));
438    }
439
440    #[test]
441    fn jcs_sorts_object_keys() {
442        let v = serde_json::json!({ "z": 1, "a": 2, "m": 3 });
443        assert_eq!(jcs_canonicalize(&v), r#"{"a":2,"m":3,"z":1}"#);
444    }
445
446    #[test]
447    fn jcs_escapes_strings() {
448        let v = serde_json::json!("a\"b\\c\n");
449        assert_eq!(jcs_canonicalize(&v), r#""a\"b\\c\n""#);
450    }
451
452    #[test]
453    fn jcs_handles_nested() {
454        let v = serde_json::json!({ "b": [1, 2, { "y": "z", "x": "w" }], "a": null });
455        // Sub-object keys also sorted.
456        assert_eq!(
457            jcs_canonicalize(&v),
458            r#"{"a":null,"b":[1,2,{"x":"w","y":"z"}]}"#
459        );
460    }
461
462    // Force the `SecureRandom` trait bound import to be exercised so
463    // we don't get an unused-import warning on it.
464    #[test]
465    fn rng_bound_exists() {
466        let rng = SystemRandom::new();
467        let mut buf = [0u8; 4];
468        rng.fill(&mut buf).unwrap();
469        assert_ne!(buf, [0u8; 4]);
470    }
471}