Skip to main content

cmr_core/
protocol.rs

1//! CMR protocol syntax and validation.
2
3use std::cmp::Ordering;
4use std::fmt::{Display, Formatter};
5
6use hmac::{Hmac, Mac};
7use serde::{Deserialize, Serialize};
8use sha2::Sha256;
9use subtle::ConstantTimeEq;
10use thiserror::Error;
11use time::{Month, OffsetDateTime};
12use url::Url;
13
14/// CMR transport channel.
15#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
16pub enum TransportKind {
17    /// HTTP transport.
18    Http,
19    /// HTTPS transport.
20    Https,
21    /// SMTP transport.
22    Smtp,
23    /// UDP transport.
24    Udp,
25    /// SSH transport.
26    Ssh,
27    /// Other custom transport name.
28    Other(String),
29}
30
31impl TransportKind {
32    /// Returns true if this transport is authenticated/encrypted by design.
33    #[must_use]
34    pub fn is_secure_channel(&self) -> bool {
35        matches!(self, Self::Https | Self::Ssh)
36    }
37}
38
39/// Signature line.
40#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
41pub enum Signature {
42    /// `0\r\n`
43    Unsigned,
44    /// `1` + lower-case HMAC-SHA-256 hex digest.
45    Sha256([u8; 32]),
46}
47
48impl Signature {
49    /// Encodes this signature as a protocol line without trailing CRLF.
50    #[must_use]
51    pub fn line_without_crlf(&self) -> String {
52        match self {
53            Self::Unsigned => "0".to_owned(),
54            Self::Sha256(digest) => {
55                let mut out = String::with_capacity(65);
56                out.push('1');
57                out.push_str(&hex::encode(digest));
58                out
59            }
60        }
61    }
62
63    /// Returns true when this signature cryptographically validates.
64    #[must_use]
65    pub fn verifies(&self, payload_without_signature_line: &[u8], key: Option<&[u8]>) -> bool {
66        match self {
67            Self::Unsigned => true,
68            Self::Sha256(expected) => {
69                let Some(key) = key else {
70                    return false;
71                };
72                let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(key) else {
73                    return false;
74                };
75                mac.update(payload_without_signature_line);
76                let digest = mac.finalize().into_bytes();
77                let mut actual = [0_u8; 32];
78                actual.copy_from_slice(&digest[..32]);
79                bool::from(actual.ct_eq(expected))
80            }
81        }
82    }
83
84    /// Creates a signed signature from key and payload.
85    #[must_use]
86    pub fn sign(payload_without_signature_line: &[u8], key: &[u8]) -> Self {
87        let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC supports all key lengths");
88        mac.update(payload_without_signature_line);
89        let digest = mac.finalize().into_bytes();
90        let mut out = [0_u8; 32];
91        out.copy_from_slice(&digest[..32]);
92        Self::Sha256(out)
93    }
94}
95
96/// Timestamp with unbounded fractional precision.
97#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
98pub struct CmrTimestamp {
99    year: u16,
100    month: u8,
101    day: u8,
102    hour: u8,
103    minute: u8,
104    second: u8,
105    fraction: String,
106}
107
108impl CmrTimestamp {
109    /// Creates a current UTC timestamp with nanosecond precision.
110    #[must_use]
111    pub fn now_utc() -> Self {
112        let now = OffsetDateTime::now_utc();
113        let fraction = format!("{:09}", now.nanosecond())
114            .trim_end_matches('0')
115            .to_owned();
116        Self {
117            year: u16::try_from(now.year()).unwrap_or(0),
118            month: now.month() as u8,
119            day: now.day(),
120            hour: now.hour(),
121            minute: now.minute(),
122            second: now.second(),
123            fraction,
124        }
125    }
126
127    /// Parses CMR timestamp syntax.
128    pub fn parse(input: &str) -> Result<Self, ParseError> {
129        if input.len() < 19 {
130            return Err(ParseError::InvalidTimestamp(input.to_owned()));
131        }
132        let year = parse_dec_u16(&input[0..4], "year", input)?;
133        if input.as_bytes().get(4) != Some(&b'/') {
134            return Err(ParseError::InvalidTimestamp(input.to_owned()));
135        }
136        let month = parse_dec_u8(&input[5..7], "month", input)?;
137        if input.as_bytes().get(7) != Some(&b'/') {
138            return Err(ParseError::InvalidTimestamp(input.to_owned()));
139        }
140        let day = parse_dec_u8(&input[8..10], "day", input)?;
141        if input.as_bytes().get(10) != Some(&b' ') {
142            return Err(ParseError::InvalidTimestamp(input.to_owned()));
143        }
144        let hour = parse_dec_u8(&input[11..13], "hour", input)?;
145        if input.as_bytes().get(13) != Some(&b':') {
146            return Err(ParseError::InvalidTimestamp(input.to_owned()));
147        }
148        let minute = parse_dec_u8(&input[14..16], "minute", input)?;
149        if input.as_bytes().get(16) != Some(&b':') {
150            return Err(ParseError::InvalidTimestamp(input.to_owned()));
151        }
152        let second = parse_dec_u8(&input[17..19], "second", input)?;
153        let fraction = if input.len() == 19 {
154            String::new()
155        } else {
156            if input.as_bytes().get(19) != Some(&b'.') {
157                return Err(ParseError::InvalidTimestamp(input.to_owned()));
158            }
159            let frac = &input[20..];
160            if frac.is_empty() || !frac.bytes().all(|b| b.is_ascii_digit()) {
161                return Err(ParseError::InvalidTimestamp(input.to_owned()));
162            }
163            frac.to_owned()
164        };
165        validate_calendar_parts(year, month, day, hour, minute, second, input)?;
166        Ok(Self {
167            year,
168            month,
169            day,
170            hour,
171            minute,
172            second,
173            fraction,
174        })
175    }
176
177    /// Returns a clone with fractional seconds replaced.
178    #[must_use]
179    pub fn with_fraction(mut self, fraction: String) -> Self {
180        self.fraction = fraction;
181        self
182    }
183
184    /// Returns true when this timestamp is newer than `other`.
185    #[must_use]
186    pub fn is_newer_than(&self, other: &Self) -> bool {
187        self > other
188    }
189}
190
191impl Display for CmrTimestamp {
192    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
193        write!(
194            f,
195            "{:04}/{:02}/{:02} {:02}:{:02}:{:02}",
196            self.year, self.month, self.day, self.hour, self.minute, self.second
197        )?;
198        if !self.fraction.is_empty() {
199            write!(f, ".{}", self.fraction)?;
200        }
201        Ok(())
202    }
203}
204
205impl Ord for CmrTimestamp {
206    fn cmp(&self, other: &Self) -> Ordering {
207        let cmp_tuple = (
208            self.year,
209            self.month,
210            self.day,
211            self.hour,
212            self.minute,
213            self.second,
214        )
215            .cmp(&(
216                other.year,
217                other.month,
218                other.day,
219                other.hour,
220                other.minute,
221                other.second,
222            ));
223        if cmp_tuple != Ordering::Equal {
224            return cmp_tuple;
225        }
226        compare_fractional_decimal(&self.fraction, &other.fraction)
227    }
228}
229
230impl PartialOrd for CmrTimestamp {
231    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
232        Some(self.cmp(other))
233    }
234}
235
236/// Message identifier line (timestamp + sender address).
237#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
238pub struct MessageId {
239    /// Send timestamp.
240    pub timestamp: CmrTimestamp,
241    /// Sender address.
242    pub address: String,
243}
244
245impl MessageId {
246    /// Parses an ID line (without trailing CRLF).
247    pub fn parse(input: &str) -> Result<Self, ParseError> {
248        let split_at = input
249            .char_indices()
250            .skip(19)
251            .find_map(|(idx, ch)| (ch == ' ').then_some(idx))
252            .ok_or_else(|| ParseError::InvalidMessageId(input.to_owned()))?;
253        let ts = &input[..split_at];
254        let address = &input[(split_at + 1)..];
255        if address.is_empty() || address.contains('\r') || address.contains('\n') {
256            return Err(ParseError::InvalidMessageId(input.to_owned()));
257        }
258        if let Some(parsed) = address
259            .contains("://")
260            .then(|| Url::parse(address))
261            .transpose()
262            .map_err(|_| ParseError::InvalidAddress(address.to_owned()))?
263            && parsed.scheme().is_empty()
264        {
265            return Err(ParseError::InvalidAddress(address.to_owned()));
266        }
267        Ok(Self {
268            timestamp: CmrTimestamp::parse(ts)?,
269            address: address.to_owned(),
270        })
271    }
272
273    /// Formats the ID line without trailing CRLF.
274    #[must_use]
275    pub fn line_without_crlf(&self) -> String {
276        format!("{} {}", self.timestamp, self.address)
277    }
278}
279
280impl Display for MessageId {
281    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
282        write!(f, "{} {}", self.timestamp, self.address)
283    }
284}
285
286/// Parsed CMR message.
287#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
288pub struct CmrMessage {
289    /// Signature line.
290    pub signature: Signature,
291    /// Routing header IDs, newest to oldest.
292    pub header: Vec<MessageId>,
293    /// Message body.
294    pub body: Vec<u8>,
295}
296
297impl CmrMessage {
298    /// Returns the immediate sender address.
299    #[must_use]
300    pub fn immediate_sender(&self) -> &str {
301        self.header.first().map_or("", |id| id.address.as_str())
302    }
303
304    /// Returns the origin ID (oldest header entry).
305    #[must_use]
306    pub fn origin_id(&self) -> Option<&MessageId> {
307        self.header.last()
308    }
309
310    /// Returns serialized header + body (without signature line).
311    #[must_use]
312    pub fn payload_without_signature_line(&self) -> Vec<u8> {
313        let mut out = Vec::with_capacity(self.encoded_len().saturating_sub(4));
314        for id in &self.header {
315            out.extend_from_slice(id.line_without_crlf().as_bytes());
316            out.extend_from_slice(b"\r\n");
317        }
318        out.extend_from_slice(b"\r\n");
319        out.extend_from_slice(self.body.len().to_string().as_bytes());
320        out.extend_from_slice(b"\r\n");
321        out.extend_from_slice(&self.body);
322        out
323    }
324
325    /// Encodes the message into protocol wire bytes.
326    #[must_use]
327    pub fn to_bytes(&self) -> Vec<u8> {
328        let mut out = Vec::with_capacity(self.encoded_len());
329        out.extend_from_slice(self.signature.line_without_crlf().as_bytes());
330        out.extend_from_slice(b"\r\n");
331        out.extend_from_slice(&self.payload_without_signature_line());
332        out
333    }
334
335    /// Returns encoded byte length.
336    #[must_use]
337    pub fn encoded_len(&self) -> usize {
338        let header_len: usize = self
339            .header
340            .iter()
341            .map(|id| id.line_without_crlf().len() + 2)
342            .sum();
343        self.signature.line_without_crlf().len()
344            + 2
345            + header_len
346            + 2
347            + self.body.len().to_string().len()
348            + 2
349            + self.body.len()
350    }
351
352    /// Removes signature line.
353    pub fn make_unsigned(&mut self) {
354        self.signature = Signature::Unsigned;
355    }
356
357    /// Signs message with pairwise key.
358    pub fn sign_with_key(&mut self, key: &[u8]) {
359        self.signature = Signature::sign(&self.payload_without_signature_line(), key);
360    }
361
362    /// Prepends a new route hop.
363    pub fn prepend_hop(&mut self, hop: MessageId) {
364        self.header.insert(0, hop);
365    }
366}
367
368/// Parse-time validation context.
369#[derive(Clone, Debug)]
370pub struct ParseContext<'a> {
371    /// "Current time" used for future timestamp checks.
372    pub now: CmrTimestamp,
373    /// Recipient address; if present, this address cannot appear in the header.
374    pub recipient_address: Option<&'a str>,
375    /// Maximum allowed message byte length.
376    pub max_message_bytes: usize,
377    /// Maximum allowed header entries.
378    pub max_header_ids: usize,
379}
380
381impl<'a> ParseContext<'a> {
382    /// Creates a context with secure defaults.
383    #[must_use]
384    pub fn secure(now: CmrTimestamp, recipient_address: Option<&'a str>) -> Self {
385        Self {
386            now,
387            recipient_address,
388            max_message_bytes: 4 * 1024 * 1024,
389            max_header_ids: 1024,
390        }
391    }
392}
393
394/// Protocol parse error.
395#[derive(Debug, Error)]
396pub enum ParseError {
397    /// Message exceeds parser limit.
398    #[error("message exceeds configured size limit")]
399    TooLarge,
400    /// Non-UTF-8 line where text is required.
401    #[error("line is not valid utf-8")]
402    NonUtf8Line,
403    /// Invalid signature line.
404    #[error("invalid signature line")]
405    InvalidSignature,
406    /// Invalid timestamp syntax/value.
407    #[error("invalid timestamp `{0}`")]
408    InvalidTimestamp(String),
409    /// Invalid message ID line.
410    #[error("invalid message id `{0}`")]
411    InvalidMessageId(String),
412    /// Invalid address in message ID.
413    #[error("invalid address `{0}`")]
414    InvalidAddress(String),
415    /// Recipient address appears in header.
416    #[error("recipient address appears in routing header")]
417    RecipientAddressInHeader,
418    /// Header addresses must be unique.
419    #[error("duplicate address in routing header")]
420    DuplicateAddress,
421    /// Header timestamps must strictly descend.
422    #[error("routing header timestamps are not strictly descending")]
423    NonDescendingTimestamps,
424    /// Header timestamp is in the future.
425    #[error("routing header contains future timestamp")]
426    FutureTimestamp,
427    /// Missing header.
428    #[error("routing header is empty")]
429    EmptyHeader,
430    /// Missing CRLF where required.
431    #[error("malformed CRLF sequence")]
432    MissingCrlf,
433    /// Invalid length line.
434    #[error("invalid body length field")]
435    InvalidBodyLength,
436    /// Body length mismatch.
437    #[error("body length mismatch")]
438    BodyLengthMismatch,
439    /// Too many header IDs.
440    #[error("too many header entries")]
441    TooManyHeaderIds,
442}
443
444/// Parses and validates a wire-format CMR message.
445pub fn parse_message(input: &[u8], ctx: &ParseContext<'_>) -> Result<CmrMessage, ParseError> {
446    if input.len() > ctx.max_message_bytes {
447        return Err(ParseError::TooLarge);
448    }
449    let (sig_line, mut rest) = take_crlf_line(input)?;
450    let sig_line = std::str::from_utf8(sig_line).map_err(|_| ParseError::NonUtf8Line)?;
451    let signature = parse_signature_line(sig_line)?;
452
453    let mut header = Vec::new();
454    loop {
455        let (line, r) = take_crlf_line(rest)?;
456        rest = r;
457        if line.is_empty() {
458            break;
459        }
460        if header.len() >= ctx.max_header_ids {
461            return Err(ParseError::TooManyHeaderIds);
462        }
463        let line = std::str::from_utf8(line).map_err(|_| ParseError::NonUtf8Line)?;
464        header.push(MessageId::parse(line)?);
465    }
466    if header.is_empty() {
467        return Err(ParseError::EmptyHeader);
468    }
469    validate_header(&header, ctx)?;
470
471    let (len_line, body_bytes) = take_crlf_line(rest)?;
472    let len_line = std::str::from_utf8(len_line).map_err(|_| ParseError::NonUtf8Line)?;
473    if len_line.is_empty() || !len_line.bytes().all(|b| b.is_ascii_digit()) {
474        return Err(ParseError::InvalidBodyLength);
475    }
476    let body_len = len_line
477        .parse::<usize>()
478        .map_err(|_| ParseError::InvalidBodyLength)?;
479    if body_len > ctx.max_message_bytes {
480        return Err(ParseError::TooLarge);
481    }
482    if body_bytes.len() != body_len {
483        return Err(ParseError::BodyLengthMismatch);
484    }
485
486    Ok(CmrMessage {
487        signature,
488        header,
489        body: body_bytes.to_vec(),
490    })
491}
492
493fn parse_signature_line(line: &str) -> Result<Signature, ParseError> {
494    if line == "0" {
495        return Ok(Signature::Unsigned);
496    }
497    if line.len() == 65 && line.starts_with('1') && is_lower_hex(&line[1..]) {
498        let mut digest = [0_u8; 32];
499        hex::decode_to_slice(&line[1..], &mut digest).map_err(|_| ParseError::InvalidSignature)?;
500        return Ok(Signature::Sha256(digest));
501    }
502    Err(ParseError::InvalidSignature)
503}
504
505fn validate_header(header: &[MessageId], ctx: &ParseContext<'_>) -> Result<(), ParseError> {
506    let mut addresses = std::collections::HashSet::<&str>::with_capacity(header.len());
507    for (idx, id) in header.iter().enumerate() {
508        if ctx
509            .recipient_address
510            .is_some_and(|recipient| same_address_alias(id.address.as_str(), recipient))
511        {
512            return Err(ParseError::RecipientAddressInHeader);
513        }
514        if !addresses.insert(id.address.as_str()) {
515            return Err(ParseError::DuplicateAddress);
516        }
517        if id.timestamp > ctx.now {
518            return Err(ParseError::FutureTimestamp);
519        }
520        if idx > 0 && id.timestamp >= header[idx - 1].timestamp {
521            return Err(ParseError::NonDescendingTimestamps);
522        }
523    }
524    Ok(())
525}
526
527fn same_address_alias(left: &str, right: &str) -> bool {
528    left == right || left.trim_end_matches('/') == right.trim_end_matches('/')
529}
530
531fn take_crlf_line(mut input: &[u8]) -> Result<(&[u8], &[u8]), ParseError> {
532    let mut i = 0;
533    while i + 1 < input.len() {
534        if input[i] == b'\r' {
535            if input[i + 1] != b'\n' {
536                return Err(ParseError::MissingCrlf);
537            }
538            let line = &input[..i];
539            input = &input[(i + 2)..];
540            return Ok((line, input));
541        }
542        i += 1;
543    }
544    Err(ParseError::MissingCrlf)
545}
546
547fn parse_dec_u16(input: &str, _field: &str, full: &str) -> Result<u16, ParseError> {
548    if input.len() != 4 || !input.bytes().all(|b| b.is_ascii_digit()) {
549        return Err(ParseError::InvalidTimestamp(full.to_owned()));
550    }
551    input
552        .parse::<u16>()
553        .map_err(|_| ParseError::InvalidTimestamp(full.to_owned()))
554}
555
556fn parse_dec_u8(input: &str, _field: &str, full: &str) -> Result<u8, ParseError> {
557    if input.len() != 2 || !input.bytes().all(|b| b.is_ascii_digit()) {
558        return Err(ParseError::InvalidTimestamp(full.to_owned()));
559    }
560    input
561        .parse::<u8>()
562        .map_err(|_| ParseError::InvalidTimestamp(full.to_owned()))
563}
564
565fn validate_calendar_parts(
566    year: u16,
567    month: u8,
568    day: u8,
569    hour: u8,
570    minute: u8,
571    second: u8,
572    full: &str,
573) -> Result<(), ParseError> {
574    if hour > 23 || minute > 59 || second > 59 {
575        return Err(ParseError::InvalidTimestamp(full.to_owned()));
576    }
577    let month =
578        Month::try_from(month).map_err(|_| ParseError::InvalidTimestamp(full.to_owned()))?;
579    let date = time::Date::from_calendar_date(i32::from(year), month, day)
580        .map_err(|_| ParseError::InvalidTimestamp(full.to_owned()))?;
581    let _ = date
582        .with_hms(hour, minute, second)
583        .map_err(|_| ParseError::InvalidTimestamp(full.to_owned()))?;
584    Ok(())
585}
586
587fn compare_fractional_decimal(a: &str, b: &str) -> Ordering {
588    let max_len = a.len().max(b.len());
589    for i in 0..max_len {
590        let ad = a.as_bytes().get(i).copied().unwrap_or(b'0');
591        let bd = b.as_bytes().get(i).copied().unwrap_or(b'0');
592        match ad.cmp(&bd) {
593            Ordering::Equal => {}
594            non_eq => return non_eq,
595        }
596    }
597    Ordering::Equal
598}
599
600fn is_lower_hex(s: &str) -> bool {
601    s.bytes()
602        .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608
609    fn ctx<'a>(recipient: Option<&'a str>) -> ParseContext<'a> {
610        ParseContext::secure(
611            CmrTimestamp::parse("2030/01/01 00:00:00").expect("valid time"),
612            recipient,
613        )
614    }
615
616    #[test]
617    fn parse_round_trip_unsigned() {
618        let raw = b"0\r\n2029/12/31 23:59:59 http://alice\r\n\r\n5\r\nhello";
619        let parsed = parse_message(raw, &ctx(Some("http://bob"))).expect("parse");
620        assert_eq!(parsed.signature, Signature::Unsigned);
621        assert_eq!(parsed.header.len(), 1);
622        assert_eq!(parsed.body, b"hello");
623        assert_eq!(parsed.to_bytes(), raw);
624    }
625
626    #[test]
627    fn signed_verification_matches() {
628        let mut m = CmrMessage {
629            signature: Signature::Unsigned,
630            header: vec![MessageId::parse("2029/01/01 00:00:00 http://alice").expect("id")],
631            body: b"abc".to_vec(),
632        };
633        m.sign_with_key(b"secret");
634        let payload = m.payload_without_signature_line();
635        assert!(m.signature.verifies(&payload, Some(b"secret")));
636        assert!(!m.signature.verifies(&payload, Some(b"wrong")));
637    }
638
639    #[test]
640    fn rejects_recipient_in_header() {
641        let raw = b"0\r\n2029/12/31 23:59:59 http://bob\r\n\r\n0\r\n";
642        let err = parse_message(raw, &ctx(Some("http://bob"))).expect_err("must fail");
643        assert!(matches!(err, ParseError::RecipientAddressInHeader));
644    }
645
646    #[test]
647    fn rejects_recipient_in_header_when_only_trailing_slash_differs() {
648        let raw = b"0\r\n2029/12/31 23:59:59 http://bob/\r\n\r\n0\r\n";
649        let err = parse_message(raw, &ctx(Some("http://bob"))).expect_err("must fail");
650        assert!(matches!(err, ParseError::RecipientAddressInHeader));
651    }
652}