Skip to main content

sentinelpass_protocol/
client.rs

1//! IPC client — sends messages to the daemon.
2
3use crate::envelope::{IpcEnvelope, Origin};
4use crate::error::ProtocolError;
5use crate::message::IpcMessage;
6use crate::service::{ServiceOutcome, VaultOp, VaultOpResult};
7use crate::token::load_ipc_token;
8use crate::Result;
9use std::path::PathBuf;
10
11#[cfg(windows)]
12use tracing::debug;
13
14/// IPC client for daemon communication
15pub struct IpcClient {
16    socket_path: PathBuf,
17    auth_token: String,
18    /// Per-client grant token (SENTINELPASS_CLIENT_TOKEN); sent on every
19    /// request so the daemon can enforce token-scoped grants.
20    client_token: Option<String>,
21    /// Provenance label for this process (native host / CLI).
22    origin: Option<Origin>,
23    /// WBS-505: presented installation-capability secret (the native host
24    /// presents its own; a general client has none).
25    capability: Option<String>,
26}
27
28impl IpcClient {
29    /// Create a new IPC client
30    pub fn new(socket_path: PathBuf) -> Result<Self> {
31        let auth_token = load_ipc_token()?;
32        Ok(Self::new_with_token(socket_path, auth_token))
33    }
34
35    /// Create a new IPC client with an explicit auth token.
36    pub fn new_with_token(socket_path: PathBuf, auth_token: String) -> Self {
37        Self {
38            socket_path,
39            auth_token,
40            client_token: None,
41            origin: None,
42            capability: None,
43        }
44    }
45
46    /// CLI client carrying a per-client grant token for external secret access.
47    pub fn new_for_cli(socket_path: PathBuf, client_token: Option<String>) -> Result<Self> {
48        let auth_token = load_ipc_token()?;
49        Ok(Self {
50            socket_path,
51            auth_token,
52            client_token,
53            origin: Some(Origin::Cli),
54            capability: None,
55        })
56    }
57
58    /// Override the per-client grant token and origin label. Intended for
59    /// embedders that construct the daemon token explicitly (tests, hosts).
60    pub fn with_context(mut self, client_token: Option<String>, origin: Option<Origin>) -> Self {
61        self.client_token = client_token;
62        self.origin = origin;
63        self
64    }
65
66    /// Attach an installation-capability secret (WBS-505).
67    pub fn with_capability(mut self, capability: Option<String>) -> Self {
68        self.capability = capability;
69        self
70    }
71
72    /// Browser native-messaging host client. Presents the installation
73    /// capability when it has been provisioned (the daemon mints it on its
74    /// first start; a host running before that has none and the daemon's
75    /// legacy window applies).
76    pub fn new_for_native_host(socket_path: PathBuf) -> Result<Self> {
77        let auth_token = load_ipc_token()?;
78        Ok(Self {
79            socket_path,
80            auth_token,
81            client_token: None,
82            origin: Some(Origin::NativeHost),
83            capability: crate::token::load_native_host_capability(),
84        })
85    }
86
87    /// Send a message and wait for response. The connection negotiates the
88    /// v1 SECURED session (WBS-509/510/511) before the envelope is sent:
89    /// HKDF directional keys over the auth token + session randoms, AAD-
90    /// bound frames, strictly-increasing counters, and bounded reads.
91    pub async fn send(&self, msg: IpcMessage) -> Result<IpcMessage> {
92        let envelope = IpcEnvelope {
93            token: self.auth_token.clone(),
94            client_token: self.client_token.clone(),
95            origin: self.origin,
96            capability: self.capability.clone(),
97            message: msg,
98        };
99        let msg_bytes = serde_json::to_vec(&envelope)
100            .map_err(|e| ProtocolError::Ipc(format!("Failed to serialize message: {}", e)))?;
101
102        // --- platform connect ---------------------------------------------
103        #[cfg(unix)]
104        let transport_conn = {
105            crate::transport::unix::UnixSocketConnection::connect(self.socket_path.clone())
106                .await
107                .map_err(|e| ProtocolError::Ipc(format!("Failed to connect to daemon: {}", e)))?
108        };
109
110        #[cfg(windows)]
111        let transport_conn = {
112            // Named pipes only: the legacy tcp:// loopback branch was
113            // removed in Phase 3 (ADR-007 migration). Honor an explicit
114            // \\\\.\\pipe\\ path (tests, custom deploys); default to the
115            // per-user pipe name otherwise — mirroring the server arm.
116            let stored = self.socket_path.to_string_lossy().to_string();
117            let pipe_name = if stored.starts_with(r"\\.\pipe\") {
118                stored
119            } else {
120                crate::windows_frame::windows_named_pipe_path()
121            };
122            debug!("Connecting to named pipe: {}", pipe_name);
123            crate::transport::windows::connect_named_pipe(&pipe_name, 3000)
124                .await
125                .map_err(|e| {
126                    ProtocolError::Ipc(format!("Failed to connect to named pipe: {}", e))
127                })?
128        };
129
130        // --- session negotiation + exchange ---------------------------------
131        let conn = crate::connection::TransportConnection::from(transport_conn);
132        let mut ipc = crate::connection::IpcConnection::connect_client(conn, &self.auth_token)
133            .await
134            .map_err(|e| ProtocolError::Ipc(format!("Session negotiation failed: {}", e)))?;
135
136        ipc.send_frame(&msg_bytes)
137            .await
138            .map_err(|e| ProtocolError::Ipc(format!("Failed to write message: {}", e)))?;
139
140        let buffer = ipc
141            .recv_frame()
142            .await
143            .map_err(|e| ProtocolError::Ipc(format!("Failed to read response: {}", e)))?;
144
145        serde_json::from_slice::<IpcMessage>(&buffer)
146            .map_err(|e| ProtocolError::Ipc(format!("Failed to parse response: {}", e)))
147    }
148
149    /// One application-service call (WBS-408): send `ServiceCall { op }` and
150    /// unwrap the `ServiceResult` outcome. Typed service errors surface as
151    /// [`ProtocolError::Service`].
152    pub async fn call_service(&self, op: VaultOp) -> Result<VaultOpResult> {
153        let response = self.send(IpcMessage::ServiceCall { op }).await?;
154        match response {
155            IpcMessage::ServiceResult { outcome } => match outcome {
156                ServiceOutcome::Ok { result } => Ok(result),
157                ServiceOutcome::Err { error } => {
158                    Err(ProtocolError::Service(error.code, error.message))
159                }
160            },
161            other => Err(ProtocolError::Ipc(format!(
162                "unexpected daemon response to service call: {}",
163                message_kind(&other)
164            ))),
165        }
166    }
167}
168
169/// Best-effort variant label for an unexpected response (diagnostics only;
170/// never message payloads).
171fn message_kind(msg: &IpcMessage) -> &'static str {
172    match msg {
173        IpcMessage::GetCredentialResponse { .. }
174        | IpcMessage::GetExternalSecretResponse { .. }
175        | IpcMessage::ListDomainCredentialsResponse { .. }
176        | IpcMessage::GetTotpCodeResponse { .. }
177        | IpcMessage::SaveCredentialResponse { .. }
178        | IpcMessage::SaveSecretResponse { .. }
179        | IpcMessage::DeleteSecretResponse { .. } => "browser-surface response",
180        IpcMessage::UnlockVaultResponse { .. } => "unlock response",
181        IpcMessage::VaultStatusResponse { .. } => "vault status",
182        IpcMessage::SyncNowResponse { .. } => "sync-now response",
183        IpcMessage::SyncStatusResponse { .. } => "sync status",
184        _ => "other",
185    }
186}