loro_protocol/
elo.rs

1//! `%ELO` container and record header parsing (no crypto).
2//! Mirrors the TypeScript implementation in `packages/loro-protocol/src/e2ee.ts`
3//! for container decode and plaintext header parsing. This module is intentionally
4//! crypto-free; consumers can use the parsed `aad` (exact header bytes) and `iv`
5//! with their own AES-GCM bindings if desired.
6//! NOTE: `%ELO` support on the Rust side is work-in-progress; only the container
7//! and header parsing surface is considered stable today.
8
9use crate::bytes::BytesReader;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12#[repr(u8)]
13pub enum EloRecordKind {
14    DeltaSpan = 0x00,
15    Snapshot = 0x01,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct EloDeltaHeader {
20    pub peer_id: Vec<u8>,
21    pub start: u64,
22    pub end: u64,
23    pub key_id: String,
24    pub iv: [u8; 12],
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct EloSnapshotHeader {
29    pub vv: Vec<(Vec<u8>, u64)>,
30    pub key_id: String,
31    pub iv: [u8; 12],
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum EloHeader {
36    Delta(EloDeltaHeader),
37    Snapshot(EloSnapshotHeader),
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct ParsedEloRecord<'a> {
42    pub kind: EloRecordKind,
43    pub header: EloHeader,
44    /// Exact header bytes as encoded on the wire; use as AAD for AES-GCM.
45    pub aad: &'a [u8],
46    /// Ciphertext bytes (ct||tag), as-is from the wire.
47    pub ct: &'a [u8],
48}
49
50/// Decode an ELO container into a list of record byte slices.
51/// The returned slices borrow from `data`.
52pub fn decode_elo_container<'a>(data: &'a [u8]) -> Result<Vec<&'a [u8]>, String> {
53    let mut r = BytesReader::new(data);
54    let n = usize::try_from(r.read_uleb128()?).map_err(|_| "length too large".to_string())?;
55    let mut out: Vec<&[u8]> = Vec::with_capacity(n);
56    for _ in 0..n {
57        out.push(r.read_var_bytes()?);
58    }
59    if r.remaining() != 0 { return Err("ELO container has trailing bytes".into()); }
60    Ok(out)
61}
62
63/// Parse a single ELO record's plaintext header, returning header metadata, AAD
64/// (the exact header bytes), and a view of the ciphertext bytes.
65pub fn parse_elo_record_header<'a>(record: &'a [u8]) -> Result<ParsedEloRecord<'a>, String> {
66    let mut r = BytesReader::new(record);
67    let kind_byte = r.read_byte()?;
68    match kind_byte {
69        0x00 => parse_delta(&mut r, record),
70        0x01 => parse_snapshot(&mut r, record),
71        _ => Err(format!("Unknown ELO record kind: 0x{:02x}", kind_byte)),
72    }
73}
74
75fn parse_delta<'a>(r: &mut BytesReader<'a>, record: &'a [u8]) -> Result<ParsedEloRecord<'a>, String> {
76    let peer_id = r.read_var_bytes()?.to_vec();
77    let start = r.read_uleb128()?;
78    let end = r.read_uleb128()?;
79    let key_id = r.read_var_string()?;
80    let iv_bytes = r.read_var_bytes()?;
81    let aad_len = r.position();
82    let ct = r.read_var_bytes()?;
83    if r.remaining() != 0 { return Err("ELO record trailing bytes".into()); }
84
85    // Invariants
86    if end <= start { return Err("Invalid ELO delta span: end must be > start".into()); }
87    if iv_bytes.len() != 12 { return Err("Invalid ELO delta span: IV must be 12 bytes".into()); }
88    if peer_id.len() > 64 { return Err("Invalid ELO delta span: peerId too long".into()); }
89    if key_id.as_bytes().len() > 64 { return Err("Invalid ELO delta span: keyId too long".into()); }
90
91    let mut iv = [0u8; 12];
92    iv.copy_from_slice(iv_bytes);
93
94    Ok(ParsedEloRecord {
95        kind: EloRecordKind::DeltaSpan,
96        header: EloHeader::Delta(EloDeltaHeader { peer_id, start, end, key_id, iv }),
97        aad: &record[..aad_len],
98        ct,
99    })
100}
101
102fn parse_snapshot<'a>(r: &mut BytesReader<'a>, record: &'a [u8]) -> Result<ParsedEloRecord<'a>, String> {
103    let count = usize::try_from(r.read_uleb128()?).map_err(|_| "vv length too large".to_string())?;
104    let mut vv: Vec<(Vec<u8>, u64)> = Vec::with_capacity(count);
105    for _ in 0..count {
106        let pid = r.read_var_bytes()?.to_vec();
107        let ctr = r.read_uleb128()?;
108        vv.push((pid, ctr));
109    }
110    let key_id = r.read_var_string()?;
111    let iv_bytes = r.read_var_bytes()?;
112    let aad_len = r.position();
113    let ct = r.read_var_bytes()?;
114    if r.remaining() != 0 { return Err("ELO record trailing bytes".into()); }
115
116    if iv_bytes.len() != 12 { return Err("Invalid ELO snapshot: IV must be 12 bytes".into()); }
117    if key_id.as_bytes().len() > 64 { return Err("Invalid ELO snapshot: keyId too long".into()); }
118    // vv should be sorted by peer_id bytes ascending
119    if !is_sorted_by_bytes(&vv) { return Err("Invalid ELO snapshot: vv not sorted by peer id bytes".into()); }
120
121    let mut iv = [0u8; 12];
122    iv.copy_from_slice(iv_bytes);
123
124    Ok(ParsedEloRecord {
125        kind: EloRecordKind::Snapshot,
126        header: EloHeader::Snapshot(EloSnapshotHeader { vv, key_id, iv }),
127        aad: &record[..aad_len],
128        ct,
129    })
130}
131
132fn is_sorted_by_bytes(vv: &[(Vec<u8>, u64)]) -> bool {
133    vv.windows(2).all(|w| {
134        let a = &w[0].0;
135        let b = &w[1].0;
136        compare_bytes(a, b) <= 0
137    })
138}
139
140fn compare_bytes(a: &[u8], b: &[u8]) -> i32 {
141    let n = a.len().min(b.len());
142    for i in 0..n {
143        let da = a[i];
144        let db = b[i];
145        if da != db { return (da as i32) - (db as i32); }
146    }
147    (a.len() as i32) - (b.len() as i32)
148}