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