Skip to main content

softchat/
noise.rs

1//! Exact deployed Softrelay Noise NK client profile.
2
3use std::fmt;
4use std::sync::{Arc, Mutex, MutexGuard};
5
6use softrelay_noise::{
7    MAX_NOISE_MESSAGE_SIZE as UPSTREAM_MAX_NOISE_MESSAGE_SIZE, NOISE_MAX_CIPHERTEXT,
8    NOISE_MAX_PLAINTEXT, NoiseNkInitiator, NoiseSession,
9};
10
11use crate::SoftchatError;
12
13/// Exact deployed Noise protocol name.
14pub const NOISE_PROTOCOL_NAME: &str = "Noise_NK_25519_ChaChaPoly_SHA256";
15/// Largest logical message accepted by the deployed chunking profile.
16pub const MAX_NOISE_MESSAGE_SIZE: usize = UPSTREAM_MAX_NOISE_MESSAGE_SIZE;
17/// Largest plaintext that fits in one unchunked encrypted frame.
18pub const MAX_NOISE_FRAME_PLAINTEXT: usize = NOISE_MAX_PLAINTEXT;
19/// Largest encrypted WebSocket frame.
20pub const MAX_NOISE_FRAME_CIPHERTEXT: usize = NOISE_MAX_CIPHERTEXT;
21
22/// Single-use client handshake capability.
23///
24/// The responder static key, ephemeral secret, and handshake state never cross
25/// the language boundary. Callers send [`Self::message_one`] over their native
26/// WebSocket and consume the relay response through [`Self::complete`].
27#[cfg_attr(feature = "native-bindings", derive(uniffi::Object))]
28pub struct NoiseClientHandshake {
29    message_one: Vec<u8>,
30    initiator: Mutex<Option<NoiseNkInitiator>>,
31}
32
33impl fmt::Debug for NoiseClientHandshake {
34    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
35        formatter
36            .debug_struct("NoiseClientHandshake")
37            .field("message_one_bytes", &self.message_one.len())
38            .finish_non_exhaustive()
39    }
40}
41
42#[cfg_attr(feature = "native-bindings", uniffi::export)]
43impl NoiseClientHandshake {
44    /// Start one Noise NK handshake for a known relay static public key.
45    ///
46    /// # Errors
47    ///
48    /// Returns a stable redacted error when the key is not exactly 32 bytes or
49    /// the reviewed Softrelay implementation rejects the handshake.
50    #[cfg_attr(feature = "native-bindings", uniffi::constructor)]
51    pub fn new(
52        relay_static_public_key: Vec<u8>,
53        request_chunking: bool,
54    ) -> Result<Self, SoftchatError> {
55        let relay_static_public_key: [u8; 32] = relay_static_public_key
56            .try_into()
57            .map_err(|_| SoftchatError::InvalidNoiseHandshake)?;
58        let (message_one, initiator) =
59            NoiseNkInitiator::initiate(relay_static_public_key, request_chunking)
60                .map_err(|_| SoftchatError::InvalidNoiseHandshake)?;
61        Ok(Self {
62            message_one,
63            initiator: Mutex::new(Some(initiator)),
64        })
65    }
66
67    /// Return the first handshake message to send as one binary WebSocket frame.
68    #[must_use]
69    pub fn message_one(&self) -> Vec<u8> {
70        self.message_one.clone()
71    }
72
73    /// Consume the relay's second handshake message and establish transport.
74    ///
75    /// # Errors
76    ///
77    /// Returns a stable redacted error for malformed/authentication failures or
78    /// when this single-use capability was already consumed.
79    pub fn complete(&self, message_two: Vec<u8>) -> Result<Arc<NoiseTransport>, SoftchatError> {
80        let initiator = lock(&self.initiator)
81            .take()
82            .ok_or(SoftchatError::NoiseHandshakeConsumed)?;
83        let result = initiator
84            .process_message2(&message_two)
85            .map_err(|_| SoftchatError::InvalidNoiseHandshake)?;
86        let chunking_enabled = result.chunking_enabled();
87        Ok(Arc::new(NoiseTransport {
88            session: Mutex::new(Some(NoiseSession::new(result))),
89            chunking_enabled,
90        }))
91    }
92}
93
94/// Established, serialized Noise transport capability.
95///
96/// Native code retains WebSocket, reachability, reconnect, and shutdown
97/// ownership. Each method advances the authenticated nonce exactly once.
98#[cfg_attr(feature = "native-bindings", derive(uniffi::Object))]
99pub struct NoiseTransport {
100    session: Mutex<Option<NoiseSession>>,
101    chunking_enabled: bool,
102}
103
104impl fmt::Debug for NoiseTransport {
105    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
106        formatter
107            .debug_struct("NoiseTransport")
108            .field("chunking_enabled", &self.chunking_enabled)
109            .finish_non_exhaustive()
110    }
111}
112
113#[cfg_attr(feature = "native-bindings", uniffi::export)]
114impl NoiseTransport {
115    /// Whether both peers negotiated the deployed chunking extension.
116    #[must_use]
117    pub const fn chunking_enabled(&self) -> bool {
118        self.chunking_enabled
119    }
120
121    /// Encrypt one logical WebSocket message into one or more ordered frames.
122    ///
123    /// # Errors
124    ///
125    /// Returns a stable redacted error after close, on nonce exhaustion, or
126    /// when the bounded deployed profile rejects the message.
127    pub fn encrypt(&self, plaintext: Vec<u8>) -> Result<Vec<Vec<u8>>, SoftchatError> {
128        let mut guard = lock(&self.session);
129        guard
130            .as_mut()
131            .ok_or(SoftchatError::NoiseSessionClosed)?
132            .encrypt(&plaintext)
133            .map_err(|_| SoftchatError::NoiseTransportFailed)
134    }
135
136    /// Authenticate one encrypted frame.
137    ///
138    /// Returns `None` while an authenticated chunked message remains
139    /// incomplete and `Some` only after the complete logical message exists.
140    ///
141    /// # Errors
142    ///
143    /// Returns a stable redacted error after close or on any authentication,
144    /// ordering, nonce, size, or chunking failure.
145    pub fn decrypt(&self, ciphertext: Vec<u8>) -> Result<Option<Vec<u8>>, SoftchatError> {
146        let mut guard = lock(&self.session);
147        guard
148            .as_mut()
149            .ok_or(SoftchatError::NoiseSessionClosed)?
150            .decrypt(&ciphertext)
151            .map_err(|_| SoftchatError::NoiseTransportFailed)
152    }
153
154    /// Irreversibly zeroize and close this transport capability.
155    pub fn shutdown(&self) {
156        lock(&self.session).take();
157    }
158}
159
160fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
161    match mutex.lock() {
162        Ok(guard) => guard,
163        Err(poisoned) => poisoned.into_inner(),
164    }
165}
166
167#[cfg(test)]
168#[allow(clippy::unwrap_used)]
169mod tests {
170    use softrelay_noise::{NoiseNkResponder, NoiseSession, StaticSecret};
171
172    use super::*;
173
174    #[test]
175    fn exact_nk_profile_round_trips_and_closes_without_exporting_keys() {
176        let responder = NoiseNkResponder::new(StaticSecret::from([0x31; 32]));
177        let public_key = responder.static_public_key().to_vec();
178        let client = NoiseClientHandshake::new(public_key, true).unwrap();
179        let (message_two, server_result) = responder.respond(&client.message_one()).unwrap();
180        let client_transport = client.complete(message_two).unwrap();
181        let mut server_transport = NoiseSession::new(server_result);
182
183        let plaintext = vec![0x42; MAX_NOISE_FRAME_PLAINTEXT + 1_000];
184        let frames = client_transport.encrypt(plaintext.clone()).unwrap();
185        assert!(frames.len() > 1);
186        let mut received = None;
187        for frame in frames {
188            received = server_transport.decrypt(&frame).unwrap();
189        }
190        assert_eq!(received, Some(plaintext));
191        assert!(client_transport.chunking_enabled());
192
193        client_transport.shutdown();
194        assert!(matches!(
195            client_transport.encrypt(vec![1]),
196            Err(SoftchatError::NoiseSessionClosed)
197        ));
198        assert!(matches!(
199            client.complete(vec![0; 49]),
200            Err(SoftchatError::NoiseHandshakeConsumed)
201        ));
202    }
203
204    #[test]
205    fn rejects_bad_static_key_and_authenticated_transport_tampering() {
206        assert!(matches!(
207            NoiseClientHandshake::new(vec![0; 31], false),
208            Err(SoftchatError::InvalidNoiseHandshake)
209        ));
210
211        let responder = NoiseNkResponder::new(StaticSecret::from([0x32; 32]));
212        let client =
213            NoiseClientHandshake::new(responder.static_public_key().to_vec(), false).unwrap();
214        let (message_two, server_result) = responder.respond(&client.message_one()).unwrap();
215        let client_transport = client.complete(message_two).unwrap();
216        let mut server_transport = NoiseSession::new(server_result);
217        let mut frame = client_transport.encrypt(b"authenticated".to_vec()).unwrap()[0].clone();
218        frame[0] ^= 1;
219        assert!(server_transport.decrypt(&frame).is_err());
220    }
221}