Skip to main content

actordb_protocol/
lib.rs

1//! Committed Prost bindings and stable limits for peer and application protocols.
2//!
3//! Regenerate with `cargo run -p actordb-protocol --features codegen --bin generate`.
4//! Append `-- --check` to verify schema/binding agreement without writing files.
5pub mod meta {
6    include!("generated/meta.rs");
7}
8#[allow(clippy::large_enum_variant)]
9pub mod client {
10    include!("generated/client.rs");
11}
12
13pub const CLIENT_PROTOCOL_VERSION: u32 = 1;
14pub const CLIENT_CAPABILITY_QUERY: u64 = 1;
15pub const CLIENT_CAPABILITY_SCAN: u64 = 2;
16pub const CLIENT_CAPABILITIES: u64 = CLIENT_CAPABILITY_QUERY | CLIENT_CAPABILITY_SCAN;
17pub const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024;
18pub const MAX_BATCH_ITEMS: usize = 4096;
19pub const MAX_HANDSHAKE_BYTES: usize = 4096;
20
21/// Canonical challenge proof. Client credentials use their own domain and never
22/// authenticate a peer. Both identities, offers, and fresh challenges are bound.
23pub fn client_auth_proof(
24    secret: &[u8],
25    dialer: bool,
26    client: &client::Hello,
27    server: &client::Hello,
28) -> Vec<u8> {
29    use hmac::{KeyInit, Mac};
30    let mut mac =
31        hmac::Hmac::<sha2::Sha256>::new_from_slice(secret).expect("HMAC accepts any key size");
32    mac.update(b"actordb-client-proof-v1\0");
33    mac.update(if dialer { b"client" } else { b"server" });
34    for hello in [client, server] {
35        mac.update(&(hello.identity.len() as u64).to_be_bytes());
36        mac.update(hello.identity.as_bytes());
37        mac.update(&hello.min_version.to_be_bytes());
38        mac.update(&hello.max_version.to_be_bytes());
39        mac.update(&hello.capabilities.to_be_bytes());
40        mac.update(&(hello.challenge.len() as u64).to_be_bytes());
41        mac.update(&hello.challenge);
42    }
43    mac.finalize().into_bytes().to_vec()
44}
45
46pub fn verify_client_auth(
47    secret: Option<&[u8]>,
48    dialer: bool,
49    client: &client::Hello,
50    server: &client::Hello,
51    proof: &[u8],
52) -> bool {
53    match secret {
54        None => proof.is_empty(),
55        Some(secret) => {
56            // Compare using a MAC verification operation, avoiding data-dependent equality.
57            use hmac::{KeyInit, Mac};
58            let expected = client_auth_proof(secret, dialer, client, server);
59            let mut expected_mac = hmac::Hmac::<sha2::Sha256>::new_from_slice(secret)
60                .expect("HMAC accepts any key size");
61            expected_mac.update(&expected);
62            let tag = expected_mac.finalize().into_bytes();
63            let mut supplied_mac = hmac::Hmac::<sha2::Sha256>::new_from_slice(secret)
64                .expect("HMAC accepts any key size");
65            supplied_mac.update(proof);
66            supplied_mac.verify_slice(&tag).is_ok() && proof.len() == 32
67        }
68    }
69}
70
71pub fn valid_client_hello(hello: &client::Hello) -> bool {
72    !hello.identity.is_empty()
73        && hello.identity.len() <= 255
74        && hello.challenge.len() == 32
75        && hello.challenge.iter().any(|byte| *byte != 0)
76        && hello.min_version != 0
77        && hello.min_version <= CLIENT_PROTOCOL_VERSION
78        && hello.max_version >= CLIENT_PROTOCOL_VERSION
79}
80
81/// Allocation-free protobuf preflight. Bounds repeated fields before Prost
82/// materializes empty nested messages. Rejects duplicate envelope/operation
83/// variants rather than accepting protobuf's last-one-wins interpretation.
84pub fn preflight_client_frame(bytes: &[u8]) -> Result<(), &'static str> {
85    #[derive(Clone, Copy, PartialEq, Eq)]
86    enum Context {
87        Frame,
88        Request,
89        BatchGet,
90        BatchPut,
91        Query,
92        Response,
93        QueryResult,
94        Row,
95        Inspection,
96        Health,
97        Placement,
98        Cell,
99    }
100    fn varint(bytes: &[u8], pos: &mut usize) -> Result<u64, &'static str> {
101        let mut result = 0;
102        for shift in (0..70).step_by(7) {
103            let byte = *bytes.get(*pos).ok_or("truncated varint")?;
104            *pos += 1;
105            if shift == 63 && byte > 1 {
106                return Err("overflowing varint");
107            }
108            result |= u64::from(byte & 127) << shift;
109            if byte & 128 == 0 {
110                return Ok(result);
111            }
112        }
113        Err("overflowing varint")
114    }
115    fn walk(bytes: &[u8], context: Context) -> Result<(), &'static str> {
116        use Context::*;
117        let mut pos = 0;
118        let mut counts = [0usize; 10];
119        let mut variants = 0;
120        while pos < bytes.len() {
121            let tag = varint(bytes, &mut pos)?;
122            let field = tag >> 3;
123            if field == 0 {
124                return Err("invalid field");
125            }
126            if (context == Frame && (1..=4).contains(&field))
127                || (context == Request && (2..=9).contains(&field))
128                || (context == Cell && (1..=4).contains(&field))
129            {
130                variants += 1;
131                if variants > 1 || (context != Cell && tag & 7 != 2) {
132                    return Err("duplicate or invalid message");
133                }
134            }
135            let limit = match (context, field) {
136                (BatchGet | BatchPut, 1) | (Query, 2) | (Response, 3 | 4) => Some(MAX_BATCH_ITEMS),
137                (Response, 7) | (QueryResult, 2) | (Inspection, 4 | 5) => Some(100_000),
138                (QueryResult, 1) | (Row, 2) => Some(32),
139                (Health, 3 | 4) | (Placement, 2) => Some(MAX_BATCH_ITEMS),
140                _ => None,
141            };
142            if let Some(limit) = limit {
143                let counter = counts
144                    .get_mut(field as usize)
145                    .ok_or("invalid repeated field")?;
146                *counter += 1;
147                if *counter > limit || tag & 7 != 2 {
148                    return Err("invalid or excessive repeated field");
149                }
150            }
151            if context == Response && [5, 6].contains(&field) {
152                counts[field as usize] += 1;
153                if counts[field as usize] > 1 {
154                    return Err("duplicate structured response");
155                }
156            }
157            match tag & 7 {
158                0 => {
159                    varint(bytes, &mut pos)?;
160                }
161                1 => {
162                    pos = pos.checked_add(8).ok_or("overflow")?;
163                }
164                5 => {
165                    pos = pos.checked_add(4).ok_or("overflow")?;
166                }
167                2 => {
168                    let length =
169                        usize::try_from(varint(bytes, &mut pos)?).map_err(|_| "overflow")?;
170                    let end = pos.checked_add(length).ok_or("overflow")?;
171                    let inner = bytes.get(pos..end).ok_or("truncated field")?;
172                    let nested = match (context, field) {
173                        (Frame, 3) => Some(Request),
174                        (Frame, 4) => Some(Response),
175                        (Request, 5) => Some(BatchGet),
176                        (Request, 6) => Some(BatchPut),
177                        (Request, 7) => Some(Query),
178                        (Query, 2) | (Row, 2) => Some(Cell),
179                        (Response, 5) => Some(QueryResult),
180                        (Response, 6) => Some(Inspection),
181                        (QueryResult, 2) => Some(Row),
182                        (Inspection, 4) => Some(Health),
183                        (Inspection, 5) => Some(Placement),
184                        _ => None,
185                    };
186                    if let Some(nested) = nested {
187                        walk(inner, nested)?;
188                    }
189                    pos = end;
190                }
191                _ => return Err("invalid wire type"),
192            }
193            if pos > bytes.len() {
194                return Err("truncated field");
195            }
196        }
197        if context == Frame && variants != 1 {
198            return Err("missing message");
199        }
200        Ok(())
201    }
202    walk(bytes, Context::Frame)
203}