Skip to main content

lean_ctx_protocol/
runtime_frame.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Encrypted, authenticated local-runtime frames; admission/replay are host-owned.
3//! Owners provision a fresh scoped 32-byte key and a fresh random 24-byte nonce
4//! for EVERY frame. Never reuse a nonce with the same key, including on retries.
5
6use chacha20poly1305::{
7    XChaCha20Poly1305, XNonce,
8    aead::{Aead, KeyInit, Payload},
9};
10use std::fmt;
11
12use crate::runtime_exchange::RuntimeExchangeProfile;
13
14const MAGIC: &[u8; 8] = b"LCTXIR02";
15/// Magic/version and big-endian plaintext length, read before any allocation.
16pub const RUNTIME_FRAME_HEADER_BYTES: usize = 12;
17/// Extended nonce; generated with a cryptographic random source by the sender.
18pub const RUNTIME_FRAME_NONCE_BYTES: usize = 24;
19/// Full Poly1305 tag, appended to the ciphertext. Truncation is not accepted.
20pub const RUNTIME_FRAME_TAG_BYTES: usize = 16;
21/// Maximum plaintext bytes, excluding header, nonce and authentication tag.
22pub const MAX_RUNTIME_FRAME_PAYLOAD_BYTES: usize =
23    crate::runtime_exchange::MAX_RUNTIME_EXCHANGE_BYTES;
24
25/// Authenticated associated data separates request and response domains.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum RuntimeFrameDirection {
28    Request,
29    Response,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum RuntimeFrameError {
34    InvalidHeader,
35    InvalidLength,
36    EncryptionFailed,
37    AuthenticationFailed,
38}
39
40impl fmt::Display for RuntimeFrameError {
41    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
42        formatter.write_str(match self {
43            Self::InvalidHeader => "invalid runtime frame header or version",
44            Self::InvalidLength => "invalid runtime frame length",
45            Self::EncryptionFailed => "runtime frame encryption failed",
46            Self::AuthenticationFailed => "runtime frame authentication failed",
47        })
48    }
49}
50
51impl std::error::Error for RuntimeFrameError {}
52
53/// The largest plaintext a frame of `direction` may carry under `profile`.
54const fn payload_limit(profile: RuntimeExchangeProfile, direction: RuntimeFrameDirection) -> usize {
55    match direction {
56        RuntimeFrameDirection::Request => profile.max_request_bytes(),
57        RuntimeFrameDirection::Response => profile.max_response_bytes(),
58    }
59}
60
61/// Check the fixed header BEFORE allocating/reading the declared ciphertext.
62pub fn runtime_payload_length(header: &[u8]) -> Result<usize, RuntimeFrameError> {
63    payload_length(header, MAX_RUNTIME_FRAME_PAYLOAD_BYTES)
64}
65
66/// [`runtime_payload_length`] for a frame of `direction` under a locally
67/// admitted profile. The profile is the receiver's own; the header only
68/// states a length, which is refused here when it exceeds that profile.
69pub fn runtime_payload_length_in(
70    header: &[u8],
71    direction: RuntimeFrameDirection,
72    profile: RuntimeExchangeProfile,
73) -> Result<usize, RuntimeFrameError> {
74    payload_length(header, payload_limit(profile, direction))
75}
76
77fn payload_length(header: &[u8], limit: usize) -> Result<usize, RuntimeFrameError> {
78    let length_bytes: [u8; 4] = header
79        .strip_prefix(MAGIC)
80        .and_then(|bytes| bytes.try_into().ok())
81        .ok_or(RuntimeFrameError::InvalidHeader)?;
82    let length = usize::try_from(u32::from_be_bytes(length_bytes))
83        .map_err(|_| RuntimeFrameError::InvalidLength)?;
84    validate_length(length, limit)?;
85    Ok(length)
86}
87
88/// Encode version/length, random nonce, ciphertext and its complete tag.
89/// Nonce MUST be fresh cryptographic randomness for every call under this key.
90pub fn encode_runtime_frame(
91    key: &[u8; 32],
92    direction: RuntimeFrameDirection,
93    nonce: &[u8; RUNTIME_FRAME_NONCE_BYTES],
94    payload: &[u8],
95) -> Result<Vec<u8>, RuntimeFrameError> {
96    encode(
97        key,
98        direction,
99        nonce,
100        payload,
101        MAX_RUNTIME_FRAME_PAYLOAD_BYTES,
102    )
103}
104
105/// [`encode_runtime_frame`] under a locally admitted profile. Magic, length
106/// field, associated data, cipher, nonce and tag are the same.
107pub fn encode_runtime_frame_in(
108    key: &[u8; 32],
109    direction: RuntimeFrameDirection,
110    nonce: &[u8; RUNTIME_FRAME_NONCE_BYTES],
111    payload: &[u8],
112    profile: RuntimeExchangeProfile,
113) -> Result<Vec<u8>, RuntimeFrameError> {
114    encode(
115        key,
116        direction,
117        nonce,
118        payload,
119        payload_limit(profile, direction),
120    )
121}
122
123fn encode(
124    key: &[u8; 32],
125    direction: RuntimeFrameDirection,
126    nonce: &[u8; RUNTIME_FRAME_NONCE_BYTES],
127    payload: &[u8],
128    limit: usize,
129) -> Result<Vec<u8>, RuntimeFrameError> {
130    let aad = associated_data(direction, payload.len(), limit)?;
131    let ciphertext = XChaCha20Poly1305::new(key.into())
132        .encrypt(
133            XNonce::from_slice(nonce),
134            Payload {
135                msg: payload,
136                aad: &aad,
137            },
138        )
139        .map_err(|_| RuntimeFrameError::EncryptionFailed)?;
140    let mut frame = Vec::with_capacity(
141        RUNTIME_FRAME_HEADER_BYTES + RUNTIME_FRAME_NONCE_BYTES + ciphertext.len(),
142    );
143    frame.extend_from_slice(&aad[..RUNTIME_FRAME_HEADER_BYTES]);
144    frame.extend_from_slice(nonce);
145    frame.extend_from_slice(&ciphertext);
146    Ok(frame)
147}
148
149/// Authenticate and decrypt before JSON decoding or dispatch. A wrong peer or
150/// active relay sees ciphertext, not the projection. This does not admit a
151/// capability or prevent replay; those require trusted policy and session state.
152pub fn decode_runtime_frame(
153    key: &[u8; 32],
154    direction: RuntimeFrameDirection,
155    frame: &[u8],
156) -> Result<Vec<u8>, RuntimeFrameError> {
157    decode(key, direction, frame, MAX_RUNTIME_FRAME_PAYLOAD_BYTES)
158}
159
160/// [`decode_runtime_frame`] under a locally admitted profile.
161pub fn decode_runtime_frame_in(
162    key: &[u8; 32],
163    direction: RuntimeFrameDirection,
164    frame: &[u8],
165    profile: RuntimeExchangeProfile,
166) -> Result<Vec<u8>, RuntimeFrameError> {
167    decode(key, direction, frame, payload_limit(profile, direction))
168}
169
170fn decode(
171    key: &[u8; 32],
172    direction: RuntimeFrameDirection,
173    frame: &[u8],
174    limit: usize,
175) -> Result<Vec<u8>, RuntimeFrameError> {
176    let overhead = RUNTIME_FRAME_HEADER_BYTES + RUNTIME_FRAME_NONCE_BYTES + RUNTIME_FRAME_TAG_BYTES;
177    if frame.len() < overhead {
178        return Err(RuntimeFrameError::InvalidLength);
179    }
180    let (header, body) = frame.split_at(RUNTIME_FRAME_HEADER_BYTES);
181    let length = payload_length(header, limit)?;
182    if frame.len() != overhead + length {
183        return Err(RuntimeFrameError::InvalidLength);
184    }
185    let (nonce, ciphertext) = body.split_at(RUNTIME_FRAME_NONCE_BYTES);
186    let aad = associated_data(direction, length, limit)?;
187    XChaCha20Poly1305::new(key.into())
188        .decrypt(
189            XNonce::from_slice(nonce),
190            Payload {
191                msg: ciphertext,
192                aad: &aad,
193            },
194        )
195        .map_err(|_| RuntimeFrameError::AuthenticationFailed)
196}
197
198fn validate_length(length: usize, limit: usize) -> Result<(), RuntimeFrameError> {
199    if length == 0 || length > limit {
200        return Err(RuntimeFrameError::InvalidLength);
201    }
202    Ok(())
203}
204
205fn associated_data(
206    direction: RuntimeFrameDirection,
207    length: usize,
208    limit: usize,
209) -> Result<Vec<u8>, RuntimeFrameError> {
210    validate_length(length, limit)?;
211    let length = u32::try_from(length).map_err(|_| RuntimeFrameError::InvalidLength)?;
212    let mut aad = MAGIC.to_vec();
213    aad.extend_from_slice(&length.to_be_bytes());
214    aad.extend_from_slice(match direction {
215        RuntimeFrameDirection::Request => b"request\0",
216        RuntimeFrameDirection::Response => b"response\0",
217    });
218    Ok(aad)
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    // Public synthetic vectors only. Production senders generate fresh randomness.
226    const KEY: [u8; 32] = [0x0b; 32];
227    const NONCE: [u8; 24] = [0x0d; 24];
228
229    #[test]
230    fn round_trip_authenticates_every_byte_and_direction() {
231        let request = RuntimeFrameDirection::Request;
232        let payload = b"bounded projection";
233        let frame = encode_runtime_frame(&KEY, request, &NONCE, payload).unwrap();
234        assert_eq!(runtime_payload_length(&frame[..12]).unwrap(), payload.len());
235        assert_eq!(
236            decode_runtime_frame(&KEY, request, &frame).unwrap(),
237            payload
238        );
239        assert!(!frame.windows(payload.len()).any(|window| window == payload));
240        assert_ne!(
241            frame,
242            encode_runtime_frame(&KEY, request, &[0x0e; 24], payload).unwrap()
243        );
244        assert!(decode_runtime_frame(&KEY, RuntimeFrameDirection::Response, &frame).is_err());
245        assert!(decode_runtime_frame(&[0x0c; 32], request, &frame).is_err());
246        for index in 0..frame.len() {
247            let mut tampered = frame.clone();
248            tampered[index] ^= 1;
249            assert!(
250                decode_runtime_frame(&KEY, request, &tampered).is_err(),
251                "byte {index}"
252            );
253        }
254        for length in 0..frame.len() {
255            assert!(decode_runtime_frame(&KEY, request, &frame[..length]).is_err());
256        }
257        let mut trailing = frame;
258        trailing.push(0);
259        assert!(decode_runtime_frame(&KEY, request, &trailing).is_err());
260    }
261
262    #[test]
263    fn unpublished_plaintext_hmac_predecessor_is_not_a_downgrade_path() {
264        // Keep the independently calculated old HMAC vector as a rejection test.
265        let mut old = b"LCTXIR01".to_vec();
266        old.extend_from_slice(&18_u32.to_be_bytes());
267        old.extend_from_slice(&[
268            0x22, 0x7a, 0xca, 0x2b, 0x41, 0x04, 0xc8, 0x81, 0xa1, 0xd8, 0x67, 0xfa, 0x3d, 0x9b,
269            0x8d, 0xce, 0xdf, 0xa4, 0x8d, 0x1b, 0x61, 0x28, 0x6f, 0xd2, 0xe8, 0x1c, 0xc9, 0x2b,
270            0x3a, 0x92, 0x18, 0x2c,
271        ]);
272        old.extend_from_slice(b"bounded projection");
273        assert!(decode_runtime_frame(&KEY, RuntimeFrameDirection::Request, &old).is_err());
274    }
275
276    #[test]
277    fn validates_header_before_allocation_and_rejects_oversize() {
278        for length in [0_u32, 1_048_577, u32::MAX] {
279            let mut header = MAGIC.to_vec();
280            header.extend_from_slice(&length.to_be_bytes());
281            assert!(runtime_payload_length(&header).is_err());
282        }
283        assert!(runtime_payload_length(b"LCTXIR02").is_err());
284        let direction = RuntimeFrameDirection::Response;
285        assert!(encode_runtime_frame(&KEY, direction, &NONCE, b"").is_err());
286        assert!(encode_runtime_frame(&KEY, direction, &NONCE, &vec![0; 1_048_577]).is_err());
287        let payload = vec![0; MAX_RUNTIME_FRAME_PAYLOAD_BYTES];
288        let frame = encode_runtime_frame(&KEY, direction, &NONCE, &payload).unwrap();
289        assert_eq!(
290            decode_runtime_frame(&KEY, direction, &frame).unwrap(),
291            payload
292        );
293    }
294
295    #[test]
296    fn a_profile_bounds_each_direction_at_the_header_and_ordinary_frames_are_unchanged() {
297        use RuntimeFrameDirection::{Request, Response};
298        let ordinary = RuntimeExchangeProfile::ORDINARY;
299        let verifier = RuntimeExchangeProfile::ORG_POLICY_VERIFIER;
300        let header = |length: usize| {
301            let mut header = MAGIC.to_vec();
302            header.extend_from_slice(&u32::try_from(length).unwrap().to_be_bytes());
303            header
304        };
305        // The declared length is refused from the twelve header bytes alone.
306        for (direction, limit) in [
307            (Request, verifier.max_request_bytes()),
308            (Response, verifier.max_response_bytes()),
309        ] {
310            assert_eq!(
311                runtime_payload_length_in(&header(limit), direction, verifier),
312                Ok(limit)
313            );
314            assert_eq!(
315                runtime_payload_length_in(&header(limit + 1), direction, verifier),
316                Err(RuntimeFrameError::InvalidLength)
317            );
318            assert_eq!(
319                runtime_payload_length_in(
320                    &header(MAX_RUNTIME_FRAME_PAYLOAD_BYTES + 1),
321                    direction,
322                    ordinary
323                ),
324                Err(RuntimeFrameError::InvalidLength)
325            );
326        }
327        // A response as long as the widest request is not a response.
328        assert!(verifier.max_request_bytes() > verifier.max_response_bytes());
329        assert!(
330            runtime_payload_length_in(&header(verifier.max_request_bytes()), Response, verifier)
331                .is_err()
332        );
333        assert!(runtime_payload_length(&header(MAX_RUNTIME_FRAME_PAYLOAD_BYTES + 1)).is_err());
334
335        // Same frame format above the ordinary megabyte, only under a profile.
336        let payload = vec![7; MAX_RUNTIME_FRAME_PAYLOAD_BYTES + 1];
337        let frame = encode_runtime_frame_in(&KEY, Request, &NONCE, &payload, verifier).unwrap();
338        assert_eq!(
339            frame.len(),
340            payload.len()
341                + RUNTIME_FRAME_HEADER_BYTES
342                + RUNTIME_FRAME_NONCE_BYTES
343                + RUNTIME_FRAME_TAG_BYTES
344        );
345        assert_eq!(
346            decode_runtime_frame_in(&KEY, Request, &frame, verifier).unwrap(),
347            payload
348        );
349        assert!(decode_runtime_frame_in(&KEY, Response, &frame, verifier).is_err());
350        assert_eq!(
351            decode_runtime_frame(&KEY, Request, &frame),
352            Err(RuntimeFrameError::InvalidLength)
353        );
354        assert!(encode_runtime_frame(&KEY, Request, &NONCE, &payload).is_err());
355    }
356}