use crate::session::{self, SessionCrypto, SESSION_READ_DEADLINE};
use crate::transport::{TransportError, TransportResult};
pub enum TransportConnection {
#[cfg(unix)]
Unix(crate::transport::unix::UnixSocketConnection),
#[cfg(windows)]
Windows(crate::transport::windows::WindowsNamedPipeConnection),
}
#[cfg(unix)]
impl From<crate::transport::unix::UnixSocketConnection> for TransportConnection {
fn from(conn: crate::transport::unix::UnixSocketConnection) -> Self {
Self::Unix(conn)
}
}
#[cfg(windows)]
impl From<crate::transport::windows::WindowsNamedPipeConnection> for TransportConnection {
fn from(conn: crate::transport::windows::WindowsNamedPipeConnection) -> Self {
Self::Windows(conn)
}
}
impl TransportConnection {
async fn read_frame(&mut self) -> TransportResult<Vec<u8>> {
match self {
#[cfg(unix)]
TransportConnection::Unix(conn) => conn.read_message().await,
#[cfg(windows)]
TransportConnection::Windows(conn) => conn.read_message().await,
}
}
async fn write_frame(&mut self, data: &[u8]) -> TransportResult<()> {
match self {
#[cfg(unix)]
TransportConnection::Unix(conn) => conn.write_message(data).await,
#[cfg(windows)]
TransportConnection::Windows(conn) => conn.write_message(data).await,
}
}
}
fn plain_ipc_window_enabled() -> bool {
std::env::var("SENTINELPASS_ALLOW_PLAIN_IPC")
.map(|v| v == "1")
.unwrap_or(false)
}
pub enum IpcConnection {
Secured {
conn: Box<TransportConnection>,
crypto: SessionCrypto,
},
Plain { conn: Box<TransportConnection> },
}
impl IpcConnection {
pub async fn connect_client(
mut conn: TransportConnection,
token: &str,
) -> TransportResult<Self> {
let (hello, client_random) = session::new_hello();
let hello_bytes = serde_json::to_vec(&hello)
.map_err(|e| TransportError::Other(format!("hello encode: {e}")))?;
tokio::time::timeout(SESSION_READ_DEADLINE, conn.write_frame(&hello_bytes))
.await
.map_err(|_| TransportError::Timeout)??;
let reply = tokio::time::timeout(SESSION_READ_DEADLINE, conn.read_frame())
.await
.map_err(|_| TransportError::Timeout)??;
let accept = session::parse_accept(&reply)?;
let server_random = session::server_random_of(&accept)?;
let (c2s, s2c) = session::derive_directional_keys(token, &client_random, &server_random)?;
Ok(IpcConnection::Secured {
conn: Box::new(conn),
crypto: SessionCrypto::client(c2s, s2c),
})
}
pub async fn accept_server(
mut conn: TransportConnection,
token: &str,
) -> TransportResult<(Self, Option<Vec<u8>>)> {
let first = tokio::time::timeout(SESSION_READ_DEADLINE, conn.read_frame())
.await
.map_err(|_| TransportError::Timeout)??;
if session::is_session_hello(&first) {
let hello = session::parse_hello(&first)?;
let client_random = session::client_random_of(&hello)?;
let (accept, server_random) = session::new_accept();
let accept_bytes = serde_json::to_vec(&accept)
.map_err(|e| TransportError::Other(format!("accept encode: {e}")))?;
tokio::time::timeout(SESSION_READ_DEADLINE, conn.write_frame(&accept_bytes))
.await
.map_err(|_| TransportError::Timeout)??;
let (c2s, s2c) =
session::derive_directional_keys(token, &client_random, &server_random)?;
Ok((
IpcConnection::Secured {
conn: Box::new(conn),
crypto: SessionCrypto::server(c2s, s2c),
},
None,
))
} else if plain_ipc_window_enabled() {
tracing::warn!(
"accepted LEGACY plaintext IPC connection (no session handshake) via \
SENTINELPASS_ALLOW_PLAIN_IPC=1 — the bearer envelope crosses the \
socket unencrypted; upgrade the client; the plaintext window is \
removed in 1.0"
);
Ok((
IpcConnection::Plain {
conn: Box::new(conn),
},
Some(first),
))
} else {
Err(TransportError::Other(
"refused LEGACY plaintext IPC session (no session handshake): the \
envelope bearer token would cross the socket in cleartext. Upgrade \
the client to a SECURED-session build; operators can temporarily \
re-open the migration window with SENTINELPASS_ALLOW_PLAIN_IPC=1 \
(removed in 1.0)"
.to_string(),
))
}
}
pub async fn send_frame(&mut self, envelope_bytes: &[u8]) -> TransportResult<()> {
match self {
IpcConnection::Secured { conn, crypto } => {
let sealed = crypto.seal(envelope_bytes)?;
tokio::time::timeout(SESSION_READ_DEADLINE, conn.write_frame(&sealed))
.await
.map_err(|_| TransportError::Timeout)??;
Ok(())
}
IpcConnection::Plain { conn } => {
tokio::time::timeout(SESSION_READ_DEADLINE, conn.write_frame(envelope_bytes))
.await
.map_err(|_| TransportError::Timeout)??;
Ok(())
}
}
}
pub async fn recv_frame(&mut self) -> TransportResult<Vec<u8>> {
let raw = tokio::time::timeout(SESSION_READ_DEADLINE, self.read_raw())
.await
.map_err(|_| TransportError::Timeout)??;
match self {
IpcConnection::Secured { crypto, .. } => crypto.open(&raw),
IpcConnection::Plain { .. } => Ok(raw),
}
}
async fn read_raw(&mut self) -> TransportResult<Vec<u8>> {
match self {
IpcConnection::Secured { conn, .. } => conn.read_frame().await,
IpcConnection::Plain { conn } => conn.read_frame().await,
}
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use crate::transport::unix::UnixSocketConnection;
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn set_env(key: &str, value: Option<&str>) {
match value {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}
fn socket_pair() -> (TransportConnection, TransportConnection) {
let (a, b) = tokio::net::UnixStream::pair().unwrap();
(
TransportConnection::Unix(UnixSocketConnection::from_stream(a)),
TransportConnection::Unix(UnixSocketConnection::from_stream(b)),
)
}
const TOKEN: &str = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff";
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn plain_first_frame_is_refused_by_default() {
let _env = ENV_LOCK.lock().unwrap();
set_env("SENTINELPASS_ALLOW_PLAIN_IPC", None);
let (server_conn, mut client) = socket_pair();
client
.write_frame(br#"{"token":"t","message":"CheckVault"}"#)
.await
.unwrap();
let err = match IpcConnection::accept_server(server_conn, TOKEN).await {
Err(err) => err,
Ok(_) => panic!("plaintext session must be refused without the opt-in"),
};
let msg = err.to_string();
assert!(
msg.contains("SENTINELPASS_ALLOW_PLAIN_IPC"),
"refusal must name the escape hatch: {msg}"
);
assert!(
msg.contains("plaintext"),
"refusal must name the policy: {msg}"
);
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn plain_first_frame_refused_for_non_canonical_env_value() {
let _env = ENV_LOCK.lock().unwrap();
set_env("SENTINELPASS_ALLOW_PLAIN_IPC", Some("yes"));
let (server_conn, mut client) = socket_pair();
client
.write_frame(br#"{"token":"t","message":"CheckVault"}"#)
.await
.unwrap();
assert!(IpcConnection::accept_server(server_conn, TOKEN)
.await
.is_err());
set_env("SENTINELPASS_ALLOW_PLAIN_IPC", None);
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn plain_first_frame_is_accepted_with_explicit_opt_in() {
let _env = ENV_LOCK.lock().unwrap();
set_env("SENTINELPASS_ALLOW_PLAIN_IPC", Some("1"));
let (server_conn, mut client) = socket_pair();
let first_frame = br#"{"token":"t","message":"CheckVault"}"#;
client.write_frame(first_frame).await.unwrap();
let (mut ipc, first) = IpcConnection::accept_server(server_conn, TOKEN)
.await
.expect("opted-in plaintext session must be accepted");
assert_eq!(first.as_deref(), Some(&first_frame[..]));
let reply = br#"{"token":"t","message":"VaultStatusResponse"}"#;
ipc.send_frame(reply).await.unwrap();
assert_eq!(client.read_frame().await.unwrap(), reply.to_vec());
set_env("SENTINELPASS_ALLOW_PLAIN_IPC", None);
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn secured_negotiation_still_works_by_default() {
let _env = ENV_LOCK.lock().unwrap();
set_env("SENTINELPASS_ALLOW_PLAIN_IPC", None);
let (server_conn, client_conn) = socket_pair();
let server =
tokio::spawn(async move { IpcConnection::accept_server(server_conn, TOKEN).await });
let client = IpcConnection::connect_client(client_conn, TOKEN)
.await
.expect("secured negotiation must succeed");
let (ipc, first) = server.await.unwrap().unwrap();
assert_eq!(first, None, "the secured path hands back no first frame");
assert!(matches!(ipc, IpcConnection::Secured { .. }));
assert!(matches!(client, IpcConnection::Secured { .. }));
}
}