1use std::path::{Path, PathBuf};
4
5use serde::Deserialize;
6use sha2::{Digest, Sha256};
7
8use crate::errors::{Result, WinxError};
9use crate::tool_policy::{ToolPolicy, ToolProfile};
10
11pub const MIN_HTTP_TOKEN_BYTES: usize = 32;
13
14#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, clap::ValueEnum)]
16pub enum HttpSessionAffinity {
17 #[default]
21 Workspace,
22 Conversation,
27 Thread,
31}
32
33pub fn parse_bool(value: &str) -> Option<bool> {
35 match value.trim().to_ascii_lowercase().as_str() {
36 "1" | "true" | "yes" | "on" => Some(true),
37 "0" | "false" | "no" | "off" | "" => Some(false),
38 _ => None,
39 }
40}
41
42pub fn env_flag(name: &str) -> bool {
44 std::env::var(name).ok().as_deref().and_then(parse_bool).unwrap_or(false)
45}
46
47pub fn env_text(name: &str) -> Option<String> {
49 std::env::var(name).ok().map(|value| value.trim().to_string()).filter(|value| !value.is_empty())
50}
51
52#[derive(Clone)]
54pub struct HttpPrincipal {
55 name: String,
56 id: String,
57 token_digest: [u8; 32],
58 tool_policy: ToolPolicy,
59}
60
61impl std::fmt::Debug for HttpPrincipal {
62 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 formatter
64 .debug_struct("HttpPrincipal")
65 .field("name", &self.name)
66 .field("id", &self.id)
67 .field("tool_policy", &self.tool_policy)
68 .field("token", &"[REDACTED]")
69 .finish_non_exhaustive()
70 }
71}
72
73impl HttpPrincipal {
74 pub fn new(name: impl Into<String>, token: &str, allow_weak_token: bool) -> Result<Self> {
75 Self::new_with_tool_policy(name, token, allow_weak_token, ToolPolicy::default())
76 }
77
78 pub fn new_with_tool_policy(
79 name: impl Into<String>,
80 token: &str,
81 allow_weak_token: bool,
82 tool_policy: ToolPolicy,
83 ) -> Result<Self> {
84 let name = name.into().trim().to_string();
85 if name.is_empty() {
86 return Err(WinxError::ConfigurationError(
87 "HTTP principal name cannot be empty".to_string(),
88 ));
89 }
90 if !name
91 .chars()
92 .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-'))
93 {
94 return Err(WinxError::ConfigurationError(format!(
95 "HTTP principal name {name:?} may contain only ASCII letters, digits, '_' and '-'"
96 )));
97 }
98 validate_http_token(token, allow_weak_token)?;
99 let token_digest: [u8; 32] = Sha256::digest(token.as_bytes()).into();
100 let id_digest = Sha256::digest(name.as_bytes());
101 let id = id_digest[..8].iter().fold(String::with_capacity(16), |mut output, byte| {
102 use std::fmt::Write as _;
103 let _ = write!(output, "{byte:02x}");
104 output
105 });
106 Ok(Self { name, id, token_digest, tool_policy })
107 }
108
109 pub fn name(&self) -> &str {
110 &self.name
111 }
112
113 pub fn id(&self) -> &str {
114 &self.id
115 }
116
117 pub fn session_prefix(&self) -> String {
118 format!("p_{}__", self.id)
119 }
120
121 pub fn task_prefix(&self) -> String {
122 format!("task_{}__", self.id)
123 }
124
125 pub fn matches_token(&self, presented: &str) -> bool {
126 let digest: [u8; 32] = Sha256::digest(presented.as_bytes()).into();
127 constant_time_digest_eq(&self.token_digest, &digest)
128 }
129
130 pub fn tool_policy(&self) -> ToolPolicy {
131 self.tool_policy
132 }
133}
134
135#[derive(Debug, Deserialize)]
136#[serde(deny_unknown_fields)]
137struct PrincipalFile {
138 principals: Vec<PrincipalEntry>,
139}
140
141#[derive(Debug, Deserialize)]
142#[serde(deny_unknown_fields)]
143struct PrincipalEntry {
144 name: String,
145 token_file: Option<PathBuf>,
146 token_env: Option<String>,
147 #[serde(default)]
148 tool_profile: ToolProfile,
149 allowed_tools: Option<Vec<String>>,
150}
151
152pub fn load_http_principals(
154 principal_config: Option<&Path>,
155 command_token: Option<String>,
156 command_token_file: Option<&Path>,
157 allow_weak_token: bool,
158) -> Result<Vec<HttpPrincipal>> {
159 load_http_principals_with_policy(
160 principal_config,
161 command_token,
162 command_token_file,
163 allow_weak_token,
164 ToolPolicy::default(),
165 )
166}
167
168pub fn load_http_principals_with_policy(
171 principal_config: Option<&Path>,
172 command_token: Option<String>,
173 command_token_file: Option<&Path>,
174 allow_weak_token: bool,
175 single_principal_policy: ToolPolicy,
176) -> Result<Vec<HttpPrincipal>> {
177 if principal_config.is_some() && (command_token.is_some() || command_token_file.is_some()) {
178 return Err(WinxError::ConfigurationError(
179 "--principal-config cannot be combined with --token/--token-file".to_string(),
180 ));
181 }
182
183 let principals = if let Some(path) = principal_config {
184 let content = std::fs::read_to_string(path).map_err(|error| {
185 WinxError::ConfigurationError(format!(
186 "cannot read HTTP principal config {}: {error}",
187 path.display()
188 ))
189 })?;
190 let config: PrincipalFile = toml::from_str(&content).map_err(|error| {
191 WinxError::ConfigurationError(format!(
192 "invalid HTTP principal config {}: {error}",
193 path.display()
194 ))
195 })?;
196 if config.principals.is_empty() {
197 return Err(WinxError::ConfigurationError(
198 "HTTP principal config must contain at least one [[principals]] entry".to_string(),
199 ));
200 }
201 config
202 .principals
203 .into_iter()
204 .map(|entry| {
205 let tool_policy =
206 ToolPolicy::resolve(entry.tool_profile, entry.allowed_tools.as_deref())?;
207 let token = match (entry.token_file.as_deref(), entry.token_env.as_deref()) {
208 (Some(path), None) => load_secret_file(path)?,
209 (None, Some(name)) => std::env::var(name).map_err(|error| {
210 WinxError::ConfigurationError(format!(
211 "HTTP principal {} token_env {name:?} is unavailable: {error}",
212 entry.name
213 ))
214 })?,
215 (Some(_), Some(_)) => {
216 return Err(WinxError::ConfigurationError(format!(
217 "HTTP principal {} must set exactly one of token_file or token_env",
218 entry.name
219 )))
220 }
221 (None, None) => {
222 return Err(WinxError::ConfigurationError(format!(
223 "HTTP principal {} must set token_file or token_env",
224 entry.name
225 )))
226 }
227 };
228 HttpPrincipal::new_with_tool_policy(
229 entry.name,
230 token.trim(),
231 allow_weak_token,
232 tool_policy,
233 )
234 })
235 .collect::<Result<Vec<_>>>()?
236 } else {
237 let token = match (command_token, command_token_file) {
238 (Some(_), Some(_)) => {
239 return Err(WinxError::ConfigurationError(
240 "provide only one of --token or --token-file".to_string(),
241 ))
242 }
243 (Some(token), None) => token,
244 (None, Some(path)) => load_secret_file(path)?,
245 (None, None) => env_text("WINX_HTTP_TOKEN").unwrap_or_default(),
246 };
247 vec![HttpPrincipal::new_with_tool_policy(
248 "default",
249 token.trim(),
250 allow_weak_token,
251 single_principal_policy,
252 )?]
253 };
254
255 reject_duplicate_principals(&principals)?;
256 Ok(principals)
257}
258
259pub fn load_secret_file(path: &Path) -> Result<String> {
260 let metadata = std::fs::symlink_metadata(path).map_err(|error| {
261 WinxError::ConfigurationError(format!(
262 "cannot inspect secret file {}: {error}",
263 path.display()
264 ))
265 })?;
266 if metadata.file_type().is_symlink() || !metadata.file_type().is_file() {
267 return Err(WinxError::ConfigurationError(format!(
268 "secret path {} must be a regular, non-symlink file",
269 path.display()
270 )));
271 }
272 #[cfg(unix)]
273 {
274 use std::os::unix::fs::PermissionsExt as _;
275 let mode = metadata.permissions().mode();
276 if mode & 0o077 != 0 {
277 return Err(WinxError::ConfigurationError(format!(
278 "secret file {} is readable or writable by group/others; use chmod 600",
279 path.display()
280 )));
281 }
282 }
283 let value = std::fs::read_to_string(path).map_err(|error| {
284 WinxError::ConfigurationError(format!(
285 "cannot read secret file {}: {error}",
286 path.display()
287 ))
288 })?;
289 let value = value.trim().to_string();
290 if value.is_empty() {
291 return Err(WinxError::ConfigurationError(format!(
292 "secret file {} is empty",
293 path.display()
294 )));
295 }
296 Ok(value)
297}
298
299fn validate_http_token(token: &str, allow_weak_token: bool) -> Result<()> {
300 if token.is_empty() {
301 return Err(WinxError::ConfigurationError(
302 "refusing to start HTTP transport without a token (RCE exposure)".to_string(),
303 ));
304 }
305 if !allow_weak_token && token.len() < MIN_HTTP_TOKEN_BYTES {
306 return Err(WinxError::ConfigurationError(format!(
307 "HTTP token is too short ({} bytes); use at least {MIN_HTTP_TOKEN_BYTES} bytes or pass --allow-weak-token for local development",
308 token.len()
309 )));
310 }
311 Ok(())
312}
313
314fn reject_duplicate_principals(principals: &[HttpPrincipal]) -> Result<()> {
315 for (index, principal) in principals.iter().enumerate() {
316 for other in principals.iter().skip(index + 1) {
317 if principal.name == other.name || principal.id == other.id {
318 return Err(WinxError::ConfigurationError(format!(
319 "duplicate HTTP principal name {:?}",
320 principal.name
321 )));
322 }
323 if constant_time_digest_eq(&principal.token_digest, &other.token_digest) {
324 return Err(WinxError::ConfigurationError(format!(
325 "HTTP principals {:?} and {:?} use the same token",
326 principal.name, other.name
327 )));
328 }
329 }
330 }
331 Ok(())
332}
333
334fn constant_time_digest_eq(left: &[u8; 32], right: &[u8; 32]) -> bool {
335 left.iter().zip(right).fold(0_u8, |difference, (left, right)| difference | (left ^ right)) == 0
336}
337
338#[cfg(test)]
339mod tests {
340 #![allow(clippy::expect_used)]
341
342 #[cfg(unix)]
343 use super::{load_http_principals, load_secret_file};
344 use super::{parse_bool, HttpPrincipal};
345
346 #[test]
347 fn boolean_parser_is_consistent() {
348 assert_eq!(parse_bool(" TRUE "), Some(true));
349 assert_eq!(parse_bool("yes"), Some(true));
350 assert_eq!(parse_bool("off"), Some(false));
351 assert_eq!(parse_bool("false"), Some(false));
352 assert_eq!(parse_bool("maybe"), None);
353 }
354
355 #[test]
356 fn principal_keeps_only_a_digest_and_matches_constant_time() {
357 let token = "0123456789abcdef0123456789abcdef";
358 let principal = HttpPrincipal::new("chatgpt", token, false).expect("valid principal");
359 assert!(principal.matches_token(token));
360 assert!(!principal.matches_token("0123456789abcdef0123456789abcdeg"));
361 assert!(!format!("{principal:?}").contains(token));
362 }
363
364 #[cfg(unix)]
365 #[test]
366 fn secret_file_requires_user_only_permissions() {
367 use std::os::unix::fs::PermissionsExt as _;
368
369 let directory = tempfile::TempDir::new().expect("temp directory");
370 let secret = directory.path().join("token");
371 std::fs::write(&secret, "0123456789abcdef0123456789abcdef\n").expect("write token");
372 std::fs::set_permissions(&secret, std::fs::Permissions::from_mode(0o644))
373 .expect("set broad permissions");
374 assert!(load_secret_file(&secret).is_err());
375
376 std::fs::set_permissions(&secret, std::fs::Permissions::from_mode(0o600))
377 .expect("set private permissions");
378 assert_eq!(
379 load_secret_file(&secret).expect("private token"),
380 "0123456789abcdef0123456789abcdef"
381 );
382 }
383
384 #[cfg(unix)]
385 #[test]
386 fn principal_toml_loads_independent_file_backed_identities() {
387 use std::os::unix::fs::PermissionsExt as _;
388
389 let directory = tempfile::TempDir::new().expect("temp directory");
390 let left = directory.path().join("left-token");
391 let right = directory.path().join("right-token");
392 for (path, value) in [
393 (&left, "left-0123456789abcdef0123456789abcdef"),
394 (&right, "right-0123456789abcdef0123456789abcdef"),
395 ] {
396 std::fs::write(path, value).expect("write token");
397 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
398 .expect("private token permissions");
399 }
400 let config = directory.path().join("principals.toml");
401 std::fs::write(
402 &config,
403 format!(
404 "[[principals]]\nname = \"left\"\ntoken_file = {left:?}\ntool_profile = \"terminal\"\n\n[[principals]]\nname = \"right\"\ntoken_file = {right:?}\nallowed_tools = [\"Initialize\", \"ReadFiles\"]\n"
405 ),
406 )
407 .expect("write principal config");
408
409 let principals =
410 load_http_principals(Some(&config), None, None, false).expect("load principals");
411 assert_eq!(principals.len(), 2);
412 assert!(principals[0].matches_token("left-0123456789abcdef0123456789abcdef"));
413 assert!(principals[1].matches_token("right-0123456789abcdef0123456789abcdef"));
414 assert_ne!(principals[0].session_prefix(), principals[1].session_prefix());
415 assert_eq!(
416 principals[0].tool_policy().names().collect::<Vec<_>>(),
417 vec!["Initialize", "BashCommand"]
418 );
419 assert_eq!(
420 principals[1].tool_policy().names().collect::<Vec<_>>(),
421 vec!["Initialize", "ReadFiles"]
422 );
423 }
424}