1use crate::session::{self, SessionCrypto, SESSION_READ_DEADLINE};
10use crate::transport::{TransportError, TransportResult};
11
12pub enum TransportConnection {
14 #[cfg(unix)]
15 Unix(crate::transport::unix::UnixSocketConnection),
16 #[cfg(windows)]
17 Windows(crate::transport::windows::WindowsNamedPipeConnection),
18}
19
20#[cfg(unix)]
21impl From<crate::transport::unix::UnixSocketConnection> for TransportConnection {
22 fn from(conn: crate::transport::unix::UnixSocketConnection) -> Self {
23 Self::Unix(conn)
24 }
25}
26
27#[cfg(windows)]
28impl From<crate::transport::windows::WindowsNamedPipeConnection> for TransportConnection {
29 fn from(conn: crate::transport::windows::WindowsNamedPipeConnection) -> Self {
30 Self::Windows(conn)
31 }
32}
33
34impl TransportConnection {
35 async fn read_frame(&mut self) -> TransportResult<Vec<u8>> {
36 match self {
37 #[cfg(unix)]
38 TransportConnection::Unix(conn) => conn.read_message().await,
39 #[cfg(windows)]
40 TransportConnection::Windows(conn) => conn.read_message().await,
41 }
42 }
43
44 async fn write_frame(&mut self, data: &[u8]) -> TransportResult<()> {
45 match self {
46 #[cfg(unix)]
47 TransportConnection::Unix(conn) => conn.write_message(data).await,
48 #[cfg(windows)]
49 TransportConnection::Windows(conn) => conn.write_message(data).await,
50 }
51 }
52}
53
54fn plain_ipc_window_enabled() -> bool {
60 std::env::var("SENTINELPASS_ALLOW_PLAIN_IPC")
61 .map(|v| v == "1")
62 .unwrap_or(false)
63}
64
65pub enum IpcConnection {
67 Secured {
69 conn: Box<TransportConnection>,
70 crypto: SessionCrypto,
71 },
72 Plain { conn: Box<TransportConnection> },
76}
77
78impl IpcConnection {
79 pub async fn connect_client(
82 mut conn: TransportConnection,
83 token: &str,
84 ) -> TransportResult<Self> {
85 let (hello, client_random) = session::new_hello();
86 let hello_bytes = serde_json::to_vec(&hello)
87 .map_err(|e| TransportError::Other(format!("hello encode: {e}")))?;
88 tokio::time::timeout(SESSION_READ_DEADLINE, conn.write_frame(&hello_bytes))
89 .await
90 .map_err(|_| TransportError::Timeout)??;
91
92 let reply = tokio::time::timeout(SESSION_READ_DEADLINE, conn.read_frame())
93 .await
94 .map_err(|_| TransportError::Timeout)??;
95 let accept = session::parse_accept(&reply)?;
96 let server_random = session::server_random_of(&accept)?;
97 let (c2s, s2c) = session::derive_directional_keys(token, &client_random, &server_random)?;
98 Ok(IpcConnection::Secured {
99 conn: Box::new(conn),
100 crypto: SessionCrypto::client(c2s, s2c),
101 })
102 }
103
104 pub async fn accept_server(
112 mut conn: TransportConnection,
113 token: &str,
114 ) -> TransportResult<(Self, Option<Vec<u8>>)> {
115 let first = tokio::time::timeout(SESSION_READ_DEADLINE, conn.read_frame())
116 .await
117 .map_err(|_| TransportError::Timeout)??;
118
119 if session::is_session_hello(&first) {
120 let hello = session::parse_hello(&first)?;
121 let client_random = session::client_random_of(&hello)?;
122 let (accept, server_random) = session::new_accept();
123 let accept_bytes = serde_json::to_vec(&accept)
124 .map_err(|e| TransportError::Other(format!("accept encode: {e}")))?;
125 tokio::time::timeout(SESSION_READ_DEADLINE, conn.write_frame(&accept_bytes))
126 .await
127 .map_err(|_| TransportError::Timeout)??;
128 let (c2s, s2c) =
129 session::derive_directional_keys(token, &client_random, &server_random)?;
130 Ok((
131 IpcConnection::Secured {
132 conn: Box::new(conn),
133 crypto: SessionCrypto::server(c2s, s2c),
134 },
135 None,
136 ))
137 } else if plain_ipc_window_enabled() {
138 tracing::warn!(
141 "accepted LEGACY plaintext IPC connection (no session handshake) via \
142 SENTINELPASS_ALLOW_PLAIN_IPC=1 — the bearer envelope crosses the \
143 socket unencrypted; upgrade the client; the plaintext window is \
144 removed in 1.0"
145 );
146 Ok((
147 IpcConnection::Plain {
148 conn: Box::new(conn),
149 },
150 Some(first),
151 ))
152 } else {
153 Err(TransportError::Other(
154 "refused LEGACY plaintext IPC session (no session handshake): the \
155 envelope bearer token would cross the socket in cleartext. Upgrade \
156 the client to a SECURED-session build; operators can temporarily \
157 re-open the migration window with SENTINELPASS_ALLOW_PLAIN_IPC=1 \
158 (removed in 1.0)"
159 .to_string(),
160 ))
161 }
162 }
163
164 pub async fn send_frame(&mut self, envelope_bytes: &[u8]) -> TransportResult<()> {
167 match self {
168 IpcConnection::Secured { conn, crypto } => {
169 let sealed = crypto.seal(envelope_bytes)?;
170 tokio::time::timeout(SESSION_READ_DEADLINE, conn.write_frame(&sealed))
171 .await
172 .map_err(|_| TransportError::Timeout)??;
173 Ok(())
174 }
175 IpcConnection::Plain { conn } => {
176 tokio::time::timeout(SESSION_READ_DEADLINE, conn.write_frame(envelope_bytes))
177 .await
178 .map_err(|_| TransportError::Timeout)??;
179 Ok(())
180 }
181 }
182 }
183
184 pub async fn recv_frame(&mut self) -> TransportResult<Vec<u8>> {
187 let raw = tokio::time::timeout(SESSION_READ_DEADLINE, self.read_raw())
188 .await
189 .map_err(|_| TransportError::Timeout)??;
190 match self {
191 IpcConnection::Secured { crypto, .. } => crypto.open(&raw),
192 IpcConnection::Plain { .. } => Ok(raw),
193 }
194 }
195
196 async fn read_raw(&mut self) -> TransportResult<Vec<u8>> {
197 match self {
198 IpcConnection::Secured { conn, .. } => conn.read_frame().await,
199 IpcConnection::Plain { conn } => conn.read_frame().await,
200 }
201 }
202}
203
204#[cfg(all(test, unix))]
205mod tests {
206 use super::*;
207 use crate::transport::unix::UnixSocketConnection;
208
209 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
212
213 fn set_env(key: &str, value: Option<&str>) {
214 match value {
215 Some(v) => std::env::set_var(key, v),
216 None => std::env::remove_var(key),
217 }
218 }
219
220 fn socket_pair() -> (TransportConnection, TransportConnection) {
224 let (a, b) = tokio::net::UnixStream::pair().unwrap();
225 (
226 TransportConnection::Unix(UnixSocketConnection::from_stream(a)),
227 TransportConnection::Unix(UnixSocketConnection::from_stream(b)),
228 )
229 }
230
231 const TOKEN: &str = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff";
232
233 #[tokio::test]
240 #[allow(clippy::await_holding_lock)]
241 async fn plain_first_frame_is_refused_by_default() {
242 let _env = ENV_LOCK.lock().unwrap();
243 set_env("SENTINELPASS_ALLOW_PLAIN_IPC", None);
244
245 let (server_conn, mut client) = socket_pair();
246 client
247 .write_frame(br#"{"token":"t","message":"CheckVault"}"#)
248 .await
249 .unwrap();
250
251 let err = match IpcConnection::accept_server(server_conn, TOKEN).await {
252 Err(err) => err,
253 Ok(_) => panic!("plaintext session must be refused without the opt-in"),
254 };
255 let msg = err.to_string();
256 assert!(
257 msg.contains("SENTINELPASS_ALLOW_PLAIN_IPC"),
258 "refusal must name the escape hatch: {msg}"
259 );
260 assert!(
261 msg.contains("plaintext"),
262 "refusal must name the policy: {msg}"
263 );
264 }
265
266 #[tokio::test]
272 #[allow(clippy::await_holding_lock)]
273 async fn plain_first_frame_refused_for_non_canonical_env_value() {
274 let _env = ENV_LOCK.lock().unwrap();
275 set_env("SENTINELPASS_ALLOW_PLAIN_IPC", Some("yes"));
276
277 let (server_conn, mut client) = socket_pair();
278 client
279 .write_frame(br#"{"token":"t","message":"CheckVault"}"#)
280 .await
281 .unwrap();
282
283 assert!(IpcConnection::accept_server(server_conn, TOKEN)
284 .await
285 .is_err());
286 set_env("SENTINELPASS_ALLOW_PLAIN_IPC", None);
287 }
288
289 #[tokio::test]
295 #[allow(clippy::await_holding_lock)]
296 async fn plain_first_frame_is_accepted_with_explicit_opt_in() {
297 let _env = ENV_LOCK.lock().unwrap();
298 set_env("SENTINELPASS_ALLOW_PLAIN_IPC", Some("1"));
299
300 let (server_conn, mut client) = socket_pair();
301 let first_frame = br#"{"token":"t","message":"CheckVault"}"#;
302 client.write_frame(first_frame).await.unwrap();
303
304 let (mut ipc, first) = IpcConnection::accept_server(server_conn, TOKEN)
305 .await
306 .expect("opted-in plaintext session must be accepted");
307 assert_eq!(first.as_deref(), Some(&first_frame[..]));
308
309 let reply = br#"{"token":"t","message":"VaultStatusResponse"}"#;
311 ipc.send_frame(reply).await.unwrap();
312 assert_eq!(client.read_frame().await.unwrap(), reply.to_vec());
313
314 set_env("SENTINELPASS_ALLOW_PLAIN_IPC", None);
315 }
316
317 #[tokio::test]
323 #[allow(clippy::await_holding_lock)]
324 async fn secured_negotiation_still_works_by_default() {
325 let _env = ENV_LOCK.lock().unwrap();
326 set_env("SENTINELPASS_ALLOW_PLAIN_IPC", None);
327
328 let (server_conn, client_conn) = socket_pair();
329 let server =
330 tokio::spawn(async move { IpcConnection::accept_server(server_conn, TOKEN).await });
331 let client = IpcConnection::connect_client(client_conn, TOKEN)
332 .await
333 .expect("secured negotiation must succeed");
334
335 let (ipc, first) = server.await.unwrap().unwrap();
336 assert_eq!(first, None, "the secured path hands back no first frame");
337 assert!(matches!(ipc, IpcConnection::Secured { .. }));
338 assert!(matches!(client, IpcConnection::Secured { .. }));
339 }
340}