Skip to main content

sentinelpass_protocol/
connection.rs

1//! Connection-level session negotiation, deadlines, and framing
2//! (WBS-509/510/511). Wraps a platform transport connection and speaks
3//! either the v1 SECURED session protocol (default) or — only when the
4//! operator explicitly re-opens the migration window with
5//! `SENTINELPASS_ALLOW_PLAIN_IPC=1` — the legacy PLAIN envelope protocol
6//! (WBS-911 F3: previously accepted UNCONDITIONALLY; the documented
7//! "removed in 1.0" claim now holds by default).
8
9use crate::session::{self, SessionCrypto, SESSION_READ_DEADLINE};
10use crate::transport::{TransportError, TransportResult};
11
12/// A connected transport (either platform).
13pub 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
54/// Whether the operator re-opened the legacy PLAIN-session migration
55/// window. Same exact-value pattern as the daemon's other announced legacy
56/// windows (`SENTINELPASS_ALLOW_SELF_ASSERTED_ORIGIN` /
57/// `SENTINELPASS_ALLOW_LEGACY_ORIGINLESS`): only the literal `1` opts in
58/// (WBS-911 F3).
59fn plain_ipc_window_enabled() -> bool {
60    std::env::var("SENTINELPASS_ALLOW_PLAIN_IPC")
61        .map(|v| v == "1")
62        .unwrap_or(false)
63}
64
65/// One negotiated IPC connection.
66pub enum IpcConnection {
67    /// v1 session: every envelope frame is sealed (WBS-509/510).
68    Secured {
69        conn: Box<TransportConnection>,
70        crypto: SessionCrypto,
71    },
72    /// Legacy plaintext envelopes — migration window only, accepted
73    /// server-side ONLY while `SENTINELPASS_ALLOW_PLAIN_IPC=1` is set and
74    /// announced per connection (ADR-007; WBS-911 F3). Refused otherwise.
75    Plain { conn: Box<TransportConnection> },
76}
77
78impl IpcConnection {
79    /// Client-side negotiation (WBS-509): send SessionHello, read
80    /// SessionAccept, derive the directional session keys.
81    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    /// Server-side negotiation: peek the first frame. A SessionHello →
105    /// SECURED (reply SessionAccept, derive keys). Anything else → the
106    /// legacy PLAIN envelope protocol, ONLY while the operator has re-opened
107    /// the migration window with `SENTINELPASS_ALLOW_PLAIN_IPC=1` (WBS-911
108    /// F3: the bearer-token envelope crosses the socket in cleartext, so the
109    /// default is refuse); the first frame is handed back to the caller for
110    /// normal processing.
111    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            // Legacy plaintext client (migration window; announced once per
139            // connection so operators can find stragglers).
140            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    /// Send one envelope frame (sealed in secured mode), write-bounded by
165    /// the session deadline (WBS-511).
166    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    /// Receive one envelope frame (opened in secured mode), bounded by the
185    /// read deadline (WBS-511).
186    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    /// Env is process-global: every env-touching test holds this lock for
210    /// its whole body (same pattern as the daemon gate tests).
211    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    /// A connected loopback pair: `.0` plays the daemon side, `.1` the
221    /// client (an unnamed socket pair — no filesystem, no runtime-dir
222    /// policy involved).
223    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    /// WBS-911 F3 (the fix): a first frame that is not a SessionHello is
234    /// REFUSED by default — the plaintext window is closed, as documented
235    /// ("removed in 1.0").
236    // ENV_LOCK is process-global and must span the whole async body (same
237    // discipline as the daemon gate tests); the guard is never shared
238    // across a real concurrency boundary here.
239    #[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    /// Only the exact value "1" opts in (same discipline as the other
267    /// legacy windows).
268    // ENV_LOCK is process-global and must span the whole async body (same
269    // discipline as the daemon gate tests); the guard is never shared
270    // across a real concurrency boundary here.
271    #[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    /// Opted-in operators get the legacy behavior: Plain session, first
290    /// frame handed back, and the session carries plaintext both ways.
291    // ENV_LOCK is process-global and must span the whole async body (same
292    // discipline as the daemon gate tests); the guard is never shared
293    // across a real concurrency boundary here.
294    #[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        // The Plain session round-trips UNSEALED frames.
310        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    /// The default SECURED path is untouched by the gate: hello in, accept
318    /// out, sealed envelopes in both directions.
319    // ENV_LOCK is process-global and must span the whole async body (same
320    // discipline as the daemon gate tests); the guard is never shared
321    // across a real concurrency boundary here.
322    #[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}