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
12const MAGIC: &[u8; 8] = b"LCTXIR02";
13/// Magic/version and big-endian plaintext length, read before any allocation.
14pub const RUNTIME_FRAME_HEADER_BYTES: usize = 12;
15/// Extended nonce; generated with a cryptographic random source by the sender.
16pub const RUNTIME_FRAME_NONCE_BYTES: usize = 24;
17/// Full Poly1305 tag, appended to the ciphertext. Truncation is not accepted.
18pub const RUNTIME_FRAME_TAG_BYTES: usize = 16;
19/// Maximum plaintext bytes, excluding header, nonce and authentication tag.
20pub const MAX_RUNTIME_FRAME_PAYLOAD_BYTES: usize =
21    crate::runtime_exchange::MAX_RUNTIME_EXCHANGE_BYTES;
22
23/// Authenticated associated data separates request and response domains.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum RuntimeFrameDirection {
26    Request,
27    Response,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum RuntimeFrameError {
32    InvalidHeader,
33    InvalidLength,
34    EncryptionFailed,
35    AuthenticationFailed,
36}
37
38impl fmt::Display for RuntimeFrameError {
39    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
40        formatter.write_str(match self {
41            Self::InvalidHeader => "invalid runtime frame header or version",
42            Self::InvalidLength => "invalid runtime frame length",
43            Self::EncryptionFailed => "runtime frame encryption failed",
44            Self::AuthenticationFailed => "runtime frame authentication failed",
45        })
46    }
47}
48
49impl std::error::Error for RuntimeFrameError {}
50
51/// Check the fixed header BEFORE allocating/reading the declared ciphertext.
52pub fn runtime_payload_length(header: &[u8]) -> Result<usize, RuntimeFrameError> {
53    let length_bytes: [u8; 4] = header
54        .strip_prefix(MAGIC)
55        .and_then(|bytes| bytes.try_into().ok())
56        .ok_or(RuntimeFrameError::InvalidHeader)?;
57    let length = usize::try_from(u32::from_be_bytes(length_bytes))
58        .map_err(|_| RuntimeFrameError::InvalidLength)?;
59    validate_length(length)?;
60    Ok(length)
61}
62
63/// Encode version/length, random nonce, ciphertext and its complete tag.
64/// Nonce MUST be fresh cryptographic randomness for every call under this key.
65pub fn encode_runtime_frame(
66    key: &[u8; 32],
67    direction: RuntimeFrameDirection,
68    nonce: &[u8; RUNTIME_FRAME_NONCE_BYTES],
69    payload: &[u8],
70) -> Result<Vec<u8>, RuntimeFrameError> {
71    let aad = associated_data(direction, payload.len())?;
72    let ciphertext = XChaCha20Poly1305::new(key.into())
73        .encrypt(
74            XNonce::from_slice(nonce),
75            Payload {
76                msg: payload,
77                aad: &aad,
78            },
79        )
80        .map_err(|_| RuntimeFrameError::EncryptionFailed)?;
81    let mut frame = Vec::with_capacity(
82        RUNTIME_FRAME_HEADER_BYTES + RUNTIME_FRAME_NONCE_BYTES + ciphertext.len(),
83    );
84    frame.extend_from_slice(&aad[..RUNTIME_FRAME_HEADER_BYTES]);
85    frame.extend_from_slice(nonce);
86    frame.extend_from_slice(&ciphertext);
87    Ok(frame)
88}
89
90/// Authenticate and decrypt before JSON decoding or dispatch. A wrong peer or
91/// active relay sees ciphertext, not the projection. This does not admit a
92/// capability or prevent replay; those require trusted policy and session state.
93pub fn decode_runtime_frame(
94    key: &[u8; 32],
95    direction: RuntimeFrameDirection,
96    frame: &[u8],
97) -> Result<Vec<u8>, RuntimeFrameError> {
98    let overhead = RUNTIME_FRAME_HEADER_BYTES + RUNTIME_FRAME_NONCE_BYTES + RUNTIME_FRAME_TAG_BYTES;
99    if frame.len() < overhead {
100        return Err(RuntimeFrameError::InvalidLength);
101    }
102    let (header, body) = frame.split_at(RUNTIME_FRAME_HEADER_BYTES);
103    let length = runtime_payload_length(header)?;
104    if frame.len() != overhead + length {
105        return Err(RuntimeFrameError::InvalidLength);
106    }
107    let (nonce, ciphertext) = body.split_at(RUNTIME_FRAME_NONCE_BYTES);
108    let aad = associated_data(direction, length)?;
109    XChaCha20Poly1305::new(key.into())
110        .decrypt(
111            XNonce::from_slice(nonce),
112            Payload {
113                msg: ciphertext,
114                aad: &aad,
115            },
116        )
117        .map_err(|_| RuntimeFrameError::AuthenticationFailed)
118}
119
120fn validate_length(length: usize) -> Result<(), RuntimeFrameError> {
121    if length == 0 || length > MAX_RUNTIME_FRAME_PAYLOAD_BYTES {
122        return Err(RuntimeFrameError::InvalidLength);
123    }
124    Ok(())
125}
126
127fn associated_data(
128    direction: RuntimeFrameDirection,
129    length: usize,
130) -> Result<Vec<u8>, RuntimeFrameError> {
131    validate_length(length)?;
132    let length = u32::try_from(length).map_err(|_| RuntimeFrameError::InvalidLength)?;
133    let mut aad = MAGIC.to_vec();
134    aad.extend_from_slice(&length.to_be_bytes());
135    aad.extend_from_slice(match direction {
136        RuntimeFrameDirection::Request => b"request\0",
137        RuntimeFrameDirection::Response => b"response\0",
138    });
139    Ok(aad)
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    // Public synthetic vectors only. Production senders generate fresh randomness.
147    const KEY: [u8; 32] = [0x0b; 32];
148    const NONCE: [u8; 24] = [0x0d; 24];
149
150    #[test]
151    fn round_trip_authenticates_every_byte_and_direction() {
152        let request = RuntimeFrameDirection::Request;
153        let payload = b"bounded projection";
154        let frame = encode_runtime_frame(&KEY, request, &NONCE, payload).unwrap();
155        assert_eq!(runtime_payload_length(&frame[..12]).unwrap(), payload.len());
156        assert_eq!(
157            decode_runtime_frame(&KEY, request, &frame).unwrap(),
158            payload
159        );
160        assert!(!frame.windows(payload.len()).any(|window| window == payload));
161        assert_ne!(
162            frame,
163            encode_runtime_frame(&KEY, request, &[0x0e; 24], payload).unwrap()
164        );
165        assert!(decode_runtime_frame(&KEY, RuntimeFrameDirection::Response, &frame).is_err());
166        assert!(decode_runtime_frame(&[0x0c; 32], request, &frame).is_err());
167        for index in 0..frame.len() {
168            let mut tampered = frame.clone();
169            tampered[index] ^= 1;
170            assert!(
171                decode_runtime_frame(&KEY, request, &tampered).is_err(),
172                "byte {index}"
173            );
174        }
175        for length in 0..frame.len() {
176            assert!(decode_runtime_frame(&KEY, request, &frame[..length]).is_err());
177        }
178        let mut trailing = frame;
179        trailing.push(0);
180        assert!(decode_runtime_frame(&KEY, request, &trailing).is_err());
181    }
182
183    #[test]
184    fn unpublished_plaintext_hmac_predecessor_is_not_a_downgrade_path() {
185        // Keep the independently calculated old HMAC vector as a rejection test.
186        let mut old = b"LCTXIR01".to_vec();
187        old.extend_from_slice(&18_u32.to_be_bytes());
188        old.extend_from_slice(&[
189            0x22, 0x7a, 0xca, 0x2b, 0x41, 0x04, 0xc8, 0x81, 0xa1, 0xd8, 0x67, 0xfa, 0x3d, 0x9b,
190            0x8d, 0xce, 0xdf, 0xa4, 0x8d, 0x1b, 0x61, 0x28, 0x6f, 0xd2, 0xe8, 0x1c, 0xc9, 0x2b,
191            0x3a, 0x92, 0x18, 0x2c,
192        ]);
193        old.extend_from_slice(b"bounded projection");
194        assert!(decode_runtime_frame(&KEY, RuntimeFrameDirection::Request, &old).is_err());
195    }
196
197    #[test]
198    fn validates_header_before_allocation_and_rejects_oversize() {
199        for length in [0_u32, 1_048_577, u32::MAX] {
200            let mut header = MAGIC.to_vec();
201            header.extend_from_slice(&length.to_be_bytes());
202            assert!(runtime_payload_length(&header).is_err());
203        }
204        assert!(runtime_payload_length(b"LCTXIR02").is_err());
205        let direction = RuntimeFrameDirection::Response;
206        assert!(encode_runtime_frame(&KEY, direction, &NONCE, b"").is_err());
207        assert!(encode_runtime_frame(&KEY, direction, &NONCE, &vec![0; 1_048_577]).is_err());
208        let payload = vec![0; MAX_RUNTIME_FRAME_PAYLOAD_BYTES];
209        let frame = encode_runtime_frame(&KEY, direction, &NONCE, &payload).unwrap();
210        assert_eq!(
211            decode_runtime_frame(&KEY, direction, &frame).unwrap(),
212            payload
213        );
214    }
215}