Skip to main content

openrtc/
application_crypto.rs

1//! Transport-agnostic application payload encryption envelope.
2//!
3//! This mirrors the TypeScript `createSharedCrypto` envelope:
4//! `ORTCE1 || version || nonce || XChaCha20-Poly1305(type_id || payload)`.
5//! It is intentionally separate from raw iroh stream APIs so native callers can
6//! opt into the same app-layer E2EE format before writing application bytes.
7
8use chacha20poly1305::{
9    aead::{Aead, KeyInit},
10    XChaCha20Poly1305, XNonce,
11};
12
13pub const APPLICATION_ENVELOPE_MAGIC: &[u8; 6] = b"ORTCE1";
14pub const APPLICATION_ENVELOPE_VERSION_REPLAY: u8 = 2;
15pub const APPLICATION_REPLAY_SEQUENCE_BYTES: usize = 8;
16pub const APPLICATION_KEY_BYTES: usize = 32;
17pub const APPLICATION_NONCE_BYTES: usize = 24;
18pub const RAW_STREAM_TYPE_ID: u8 = 3;
19pub const DEFAULT_MAX_RAW_FRAME_BYTES: usize = 16 * 1024 * 1024;
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum CryptoError {
23    InvalidKeyLength,
24    InvalidNonceLength,
25    RandomNonceFailed,
26    PlaintextRejected,
27    InvalidEnvelope,
28    DecryptFailed,
29    TypeMismatch,
30}
31
32fn constant_time_prefix_eq(input: &[u8], prefix: &[u8]) -> bool {
33    if input.len() < prefix.len() {
34        return false;
35    }
36    let mut diff = 0u8;
37    for (left, right) in input.iter().zip(prefix.iter()) {
38        diff |= left ^ right;
39    }
40    diff == 0
41}
42
43fn envelope_version(payload: &[u8]) -> Option<u8> {
44    if !constant_time_prefix_eq(payload, APPLICATION_ENVELOPE_MAGIC) {
45        return None;
46    }
47    let version = payload[APPLICATION_ENVELOPE_MAGIC.len()];
48    if version == APPLICATION_ENVELOPE_VERSION_REPLAY {
49        Some(version)
50    } else {
51        None
52    }
53}
54
55pub fn is_encrypted_payload(payload: &[u8]) -> bool {
56    envelope_version(payload).is_some_and(|_| {
57        payload.len()
58            > APPLICATION_ENVELOPE_MAGIC.len()
59                + 1
60                + APPLICATION_REPLAY_SEQUENCE_BYTES
61                + APPLICATION_NONCE_BYTES
62    })
63}
64
65pub fn protect_payload(key: &[u8], type_id: u8, payload: &[u8]) -> Result<Vec<u8>, CryptoError> {
66    let mut nonce = [0u8; APPLICATION_NONCE_BYTES];
67    getrandom::getrandom(&mut nonce).map_err(|_| CryptoError::RandomNonceFailed)?;
68    protect_payload_with_nonce(key, type_id, payload, &nonce)
69}
70
71pub(crate) fn protect_payload_with_nonce(
72    key: &[u8],
73    type_id: u8,
74    payload: &[u8],
75    nonce: &[u8],
76) -> Result<Vec<u8>, CryptoError> {
77    protect_payload_with_nonce_and_sequence(key, type_id, payload, nonce, 0)
78}
79
80pub(crate) fn protect_payload_with_nonce_and_sequence(
81    key: &[u8],
82    type_id: u8,
83    payload: &[u8],
84    nonce: &[u8],
85    sequence: u64,
86) -> Result<Vec<u8>, CryptoError> {
87    if key.len() != APPLICATION_KEY_BYTES {
88        return Err(CryptoError::InvalidKeyLength);
89    }
90    if nonce.len() != APPLICATION_NONCE_BYTES {
91        return Err(CryptoError::InvalidNonceLength);
92    }
93
94    let cipher =
95        XChaCha20Poly1305::new_from_slice(key).map_err(|_| CryptoError::InvalidKeyLength)?;
96    let nonce = XNonce::from_slice(nonce);
97    let mut plaintext = Vec::with_capacity(1 + payload.len());
98    plaintext.push(type_id);
99    plaintext.extend_from_slice(payload);
100    let ciphertext = cipher
101        .encrypt(nonce, plaintext.as_slice())
102        .map_err(|_| CryptoError::DecryptFailed)?;
103
104    let mut envelope = Vec::with_capacity(
105        APPLICATION_ENVELOPE_MAGIC.len()
106            + 1
107            + APPLICATION_REPLAY_SEQUENCE_BYTES
108            + APPLICATION_NONCE_BYTES
109            + ciphertext.len(),
110    );
111    envelope.extend_from_slice(APPLICATION_ENVELOPE_MAGIC);
112    envelope.push(APPLICATION_ENVELOPE_VERSION_REPLAY);
113    envelope.extend_from_slice(&sequence.to_be_bytes());
114    envelope.extend_from_slice(nonce);
115    envelope.extend_from_slice(&ciphertext);
116    Ok(envelope)
117}
118
119pub fn open_payload(
120    key: &[u8],
121    expected_type_id: u8,
122    payload: &[u8],
123    require_encrypted: bool,
124) -> Result<Vec<u8>, CryptoError> {
125    if key.len() != APPLICATION_KEY_BYTES {
126        return Err(CryptoError::InvalidKeyLength);
127    }
128    if !is_encrypted_payload(payload) {
129        return if require_encrypted {
130            Err(CryptoError::PlaintextRejected)
131        } else {
132            Ok(payload.to_vec())
133        };
134    }
135
136    let version = envelope_version(payload).ok_or(CryptoError::InvalidEnvelope)?;
137    if version != APPLICATION_ENVELOPE_VERSION_REPLAY {
138        return Err(CryptoError::InvalidEnvelope);
139    }
140    let nonce_offset = APPLICATION_ENVELOPE_MAGIC.len() + 1 + APPLICATION_REPLAY_SEQUENCE_BYTES;
141    let cipher_offset = nonce_offset + APPLICATION_NONCE_BYTES;
142    if payload.len() <= cipher_offset {
143        return Err(CryptoError::InvalidEnvelope);
144    }
145
146    let cipher =
147        XChaCha20Poly1305::new_from_slice(key).map_err(|_| CryptoError::InvalidKeyLength)?;
148    let nonce = XNonce::from_slice(&payload[nonce_offset..cipher_offset]);
149    let plaintext = cipher
150        .decrypt(nonce, &payload[cipher_offset..])
151        .map_err(|_| CryptoError::DecryptFailed)?;
152    let Some((&type_id, body)) = plaintext.split_first() else {
153        return Err(CryptoError::InvalidEnvelope);
154    };
155    if type_id != expected_type_id {
156        return Err(CryptoError::TypeMismatch);
157    }
158    Ok(body.to_vec())
159}
160
161pub fn protect_frame(key: &[u8], payload: &[u8]) -> Result<Vec<u8>, CryptoError> {
162    let encrypted = protect_payload(key, RAW_STREAM_TYPE_ID, payload)?;
163    Ok(frame_payload(&encrypted))
164}
165
166pub fn protect_with_nonce(
167    key: &[u8],
168    payload: &[u8],
169    nonce: &[u8],
170) -> Result<Vec<u8>, CryptoError> {
171    let encrypted = protect_payload_with_nonce(key, RAW_STREAM_TYPE_ID, payload, nonce)?;
172    Ok(frame_payload(&encrypted))
173}
174
175pub fn frame_payload(payload: &[u8]) -> Vec<u8> {
176    let mut frame = Vec::with_capacity(4 + payload.len());
177    frame.extend_from_slice(&(payload.len() as u32).to_be_bytes());
178    frame.extend_from_slice(payload);
179    frame
180}
181
182#[derive(Debug, Clone, PartialEq, Eq)]
183pub enum CryptoStreamError {
184    InvalidFrameLength,
185    FrameTooLarge,
186    AuthenticationFailed(CryptoError),
187    IncompleteFrame,
188}
189
190#[derive(Debug, Clone)]
191pub struct CryptoFrameDecoder {
192    key: Vec<u8>,
193    max_frame_bytes: usize,
194    buffer: Vec<u8>,
195}
196
197impl CryptoFrameDecoder {
198    pub fn new(key: &[u8]) -> Result<Self, CryptoError> {
199        Self::with_max_frame_bytes(key, DEFAULT_MAX_RAW_FRAME_BYTES)
200    }
201
202    pub fn with_max_frame_bytes(key: &[u8], max_frame_bytes: usize) -> Result<Self, CryptoError> {
203        if key.len() != APPLICATION_KEY_BYTES {
204            return Err(CryptoError::InvalidKeyLength);
205        }
206        Ok(Self {
207            key: key.to_vec(),
208            max_frame_bytes,
209            buffer: Vec::new(),
210        })
211    }
212
213    pub fn push(&mut self, chunk: &[u8]) -> Result<Vec<Vec<u8>>, CryptoStreamError> {
214        self.buffer.extend_from_slice(chunk);
215        let mut opened = Vec::new();
216
217        loop {
218            if self.buffer.len() < 4 {
219                break;
220            }
221            let frame_len = u32::from_be_bytes([
222                self.buffer[0],
223                self.buffer[1],
224                self.buffer[2],
225                self.buffer[3],
226            ]) as usize;
227            if frame_len == 0 {
228                return Err(CryptoStreamError::InvalidFrameLength);
229            }
230            if frame_len > self.max_frame_bytes {
231                return Err(CryptoStreamError::FrameTooLarge);
232            }
233            if self.buffer.len() < 4 + frame_len {
234                break;
235            }
236
237            let encrypted = self.buffer[4..4 + frame_len].to_vec();
238            self.buffer.drain(..4 + frame_len);
239            let payload = open_payload(&self.key, RAW_STREAM_TYPE_ID, &encrypted, true)
240                .map_err(CryptoStreamError::AuthenticationFailed)?;
241            opened.push(payload);
242        }
243
244        Ok(opened)
245    }
246
247    pub fn finish(&self) -> Result<(), CryptoStreamError> {
248        if self.buffer.is_empty() {
249            Ok(())
250        } else {
251            Err(CryptoStreamError::IncompleteFrame)
252        }
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    fn key() -> [u8; APPLICATION_KEY_BYTES] {
261        [0x42; APPLICATION_KEY_BYTES]
262    }
263
264    fn nonce() -> [u8; APPLICATION_NONCE_BYTES] {
265        [0x24; APPLICATION_NONCE_BYTES]
266    }
267
268    #[test]
269    fn application_payload_envelope_round_trips_without_plaintext() {
270        let plaintext = b"native-super-secret-payload";
271        let envelope = protect_payload_with_nonce(&key(), 0, plaintext, &nonce()).unwrap();
272
273        assert!(is_encrypted_payload(&envelope));
274        assert!(!String::from_utf8_lossy(&envelope).contains("native-super-secret-payload"));
275
276        let opened = open_payload(&key(), 0, &envelope, true).unwrap();
277        assert_eq!(opened, plaintext);
278    }
279
280    #[test]
281    fn application_payload_envelope_matches_typescript_xchacha_fixture() {
282        let key = [0x11; APPLICATION_KEY_BYTES];
283        let nonce = [0x22; APPLICATION_NONCE_BYTES];
284        let envelope =
285            protect_payload_with_nonce(&key, 0, b"interop-secret-payload", &nonce).unwrap();
286
287        assert_eq!(
288            hex::encode(envelope),
289            "4f5254434531020000000000000000222222222222222222222222222222222222222222222222830e897839cb8be4c83e71a8cf1cd5cf42f6bab87aff9e8eb208926635a43db794781e4d3047a2",
290        );
291    }
292
293    #[test]
294    fn application_payload_envelope_rejects_tampering() {
295        let plaintext = b"native-super-secret-payload";
296        let mut envelope = protect_payload_with_nonce(&key(), 0, plaintext, &nonce()).unwrap();
297        let last = envelope.len() - 1;
298        envelope[last] ^= 0x55;
299
300        assert_eq!(
301            open_payload(&key(), 0, &envelope, true),
302            Err(CryptoError::DecryptFailed),
303        );
304    }
305
306    #[test]
307    fn application_payload_envelope_rejects_wrong_type() {
308        let envelope = protect_payload_with_nonce(&key(), 1, b"video payload", &nonce()).unwrap();
309
310        assert_eq!(
311            open_payload(&key(), 2, &envelope, true),
312            Err(CryptoError::TypeMismatch),
313        );
314    }
315
316    #[test]
317    fn application_payload_envelope_rejects_foreign_key() {
318        // A payload sealed under one shared secret must not open under another:
319        // this is the core key-isolation guarantee a session must rely on.
320        let attacker_key = [0x11; APPLICATION_KEY_BYTES];
321        let victim_key = [0x22; APPLICATION_KEY_BYTES];
322        let envelope =
323            protect_payload_with_nonce(&attacker_key, 0, b"cross-key-forgery", &nonce()).unwrap();
324
325        assert_eq!(
326            open_payload(&victim_key, 0, &envelope, true),
327            Err(CryptoError::DecryptFailed),
328        );
329    }
330
331    #[test]
332    fn raw_stream_decoder_rejects_foreign_key_frames() {
333        let attacker_key = [0x33; APPLICATION_KEY_BYTES];
334        let victim_key = [0x44; APPLICATION_KEY_BYTES];
335        let frame = protect_with_nonce(&attacker_key, b"foreign raw frame", &nonce()).unwrap();
336        let mut decoder = CryptoFrameDecoder::new(&victim_key).unwrap();
337
338        assert_eq!(
339            decoder.push(&frame),
340            Err(CryptoStreamError::AuthenticationFailed(
341                CryptoError::DecryptFailed
342            )),
343        );
344    }
345
346    #[test]
347    fn application_payload_envelope_rejects_plaintext_when_required() {
348        assert_eq!(
349            open_payload(&key(), 0, b"plaintext-downgrade", true),
350            Err(CryptoError::PlaintextRejected),
351        );
352    }
353
354    #[test]
355    fn application_payload_envelope_can_allow_legacy_plaintext_when_requested() {
356        assert_eq!(
357            open_payload(&key(), 0, b"legacy plaintext", false).unwrap(),
358            b"legacy plaintext",
359        );
360    }
361
362    #[test]
363    fn raw_stream_frame_encrypts_and_decodes_split_frames() {
364        let plaintext = b"native raw stream secret";
365        let frame = protect_with_nonce(&key(), plaintext, &nonce()).unwrap();
366
367        assert!(!String::from_utf8_lossy(&frame).contains("native raw stream secret"));
368
369        let split = frame.len() / 2;
370        let mut decoder = CryptoFrameDecoder::new(&key()).unwrap();
371        assert!(decoder.push(&frame[..split]).unwrap().is_empty());
372        let opened = decoder.push(&frame[split..]).unwrap();
373
374        assert_eq!(opened, vec![plaintext.to_vec()]);
375        assert_eq!(decoder.finish(), Ok(()));
376    }
377
378    #[test]
379    fn raw_stream_decoder_opens_multiple_frames_from_one_chunk() {
380        let first = protect_with_nonce(&key(), b"first chunk", &nonce()).unwrap();
381        let mut second_nonce = nonce();
382        second_nonce[0] ^= 0x01;
383        let second = protect_with_nonce(&key(), b"second chunk", &second_nonce).unwrap();
384        let mut combined = first;
385        combined.extend_from_slice(&second);
386
387        let mut decoder = CryptoFrameDecoder::new(&key()).unwrap();
388        let opened = decoder.push(&combined).unwrap();
389
390        assert_eq!(
391            opened,
392            vec![b"first chunk".to_vec(), b"second chunk".to_vec()]
393        );
394        assert_eq!(decoder.finish(), Ok(()));
395    }
396
397    #[test]
398    fn raw_stream_decoder_rejects_plaintext_downgrade_frame() {
399        let plaintext_frame = frame_payload(b"plaintext raw stream");
400        let mut decoder = CryptoFrameDecoder::new(&key()).unwrap();
401
402        assert_eq!(
403            decoder.push(&plaintext_frame),
404            Err(CryptoStreamError::AuthenticationFailed(
405                CryptoError::PlaintextRejected
406            )),
407        );
408    }
409
410    #[test]
411    fn raw_stream_decoder_rejects_tampered_frame() {
412        let mut frame = protect_with_nonce(&key(), b"tamper me", &nonce()).unwrap();
413        let last = frame.len() - 1;
414        frame[last] ^= 0x7f;
415        let mut decoder = CryptoFrameDecoder::new(&key()).unwrap();
416
417        assert_eq!(
418            decoder.push(&frame),
419            Err(CryptoStreamError::AuthenticationFailed(
420                CryptoError::DecryptFailed
421            )),
422        );
423    }
424
425    #[test]
426    fn raw_stream_decoder_reports_incomplete_frame_on_finish() {
427        let frame = protect_with_nonce(&key(), b"partial", &nonce()).unwrap();
428        let mut decoder = CryptoFrameDecoder::new(&key()).unwrap();
429        assert!(decoder.push(&frame[..frame.len() - 1]).unwrap().is_empty());
430
431        assert_eq!(decoder.finish(), Err(CryptoStreamError::IncompleteFrame),);
432    }
433
434    #[test]
435    fn raw_stream_decoder_rejects_invalid_lengths() {
436        let mut decoder = CryptoFrameDecoder::with_max_frame_bytes(&key(), 8).unwrap();
437
438        assert_eq!(
439            decoder.push(&0u32.to_be_bytes()),
440            Err(CryptoStreamError::InvalidFrameLength),
441        );
442
443        let oversized = 9u32.to_be_bytes();
444        let mut decoder = CryptoFrameDecoder::with_max_frame_bytes(&key(), 8).unwrap();
445        assert_eq!(
446            decoder.push(&oversized),
447            Err(CryptoStreamError::FrameTooLarge),
448        );
449    }
450}