1use std::path::PathBuf;
2use std::time::{Duration, SystemTime, UNIX_EPOCH};
3
4use hmac::{Hmac, Mac};
5use rand::RngCore;
6use serde::{Deserialize, Serialize};
7use sha2::Sha256;
8
9use crate::error::{Error, Result};
10use crate::paths::ToolPaths;
11use crate::store::{
12 set_owner_dir_permissions, set_private_file_permissions, verify_owner_dir_permissions,
13 verify_private_file_permissions, write_text_atomic,
14};
15
16type HmacSha256 = Hmac<Sha256>;
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19struct SessionToken {
20 uid: u32,
21 created_at_epoch_secs: u64,
22 expires_at_epoch_secs: u64,
23 mac: Option<String>,
24}
25
26#[derive(Debug, Clone, Copy)]
27pub struct ElevationOptions {
28 pub duration: Duration,
29}
30
31impl Default for ElevationOptions {
32 fn default() -> Self {
33 Self {
34 duration: Duration::from_secs(30 * 60),
35 }
36 }
37}
38
39pub fn create_elevation_session(
40 paths: &ToolPaths,
41 uid: u32,
42 options: ElevationOptions,
43) -> Result<()> {
44 let host_secret = load_or_create_host_secret(paths)?;
45 let now = now_epoch_secs();
46 let duration_secs = options.duration.as_secs();
47 let expires_at_epoch_secs = now.saturating_add(duration_secs);
48 let token = SessionToken {
49 uid,
50 created_at_epoch_secs: now,
51 expires_at_epoch_secs,
52 mac: Some(compute_mac_hex(&host_secret, uid, expires_at_epoch_secs)?),
53 };
54 let path = session_token_path(paths, uid);
55 if let Some(parent) = path.parent() {
56 std::fs::create_dir_all(parent)?;
57 set_owner_dir_permissions(parent)?;
58 verify_owner_dir_permissions(parent)?;
59 }
60 let text =
61 serde_json::to_string_pretty(&token).map_err(|_| std::io::Error::other("json encode"))?;
62 write_text_atomic(&path, &text)?;
63 set_private_file_permissions(&path)?;
64 verify_private_file_permissions(&path)?;
65 Ok(())
66}
67
68pub fn clear_elevation_session(paths: &ToolPaths, uid: u32) -> Result<()> {
69 let path = session_token_path(paths, uid);
70 if path.exists() {
71 std::fs::remove_file(path)?;
72 }
73 Ok(())
74}
75
76pub fn is_elevated(paths: &ToolPaths, uid: u32) -> Result<bool> {
77 let path = session_token_path(paths, uid);
78 if !path.exists() {
79 return Ok(false);
80 }
81 let Some(host_secret) = load_host_secret_if_exists(paths)? else {
82 return Ok(false);
83 };
84 verify_private_file_permissions(&path)?;
85 let token = match read_session_token(&path) {
86 Ok(token) => token,
87 Err(err) => {
88 eprintln!(
89 "warrant-core warning: failed to parse elevation token at {}: {err}",
90 path.display()
91 );
92 return Ok(false);
93 }
94 };
95 if token.uid != uid {
96 return Ok(false);
97 }
98 let Some(mac_hex) = token.mac.as_deref() else {
99 eprintln!(
100 "warrant-core warning: elevation token at {} is missing mac",
101 path.display()
102 );
103 return Ok(false);
104 };
105 if !verify_mac_hex(&host_secret, token.uid, token.expires_at_epoch_secs, mac_hex)? {
106 eprintln!(
107 "warrant-core warning: elevation token at {} has invalid mac",
108 path.display()
109 );
110 return Ok(false);
111 }
112 Ok(now_epoch_secs() <= token.expires_at_epoch_secs)
113}
114
115fn read_session_token(path: &PathBuf) -> Result<SessionToken> {
116 let raw = std::fs::read_to_string(path)?;
117 let token: SessionToken =
118 serde_json::from_str(&raw).map_err(|_| std::io::Error::other("json decode"))?;
119 Ok(token)
120}
121
122fn session_token_path(paths: &ToolPaths, uid: u32) -> PathBuf {
123 paths.session_dir_path.join(format!("session-{uid}"))
124}
125
126fn host_secret_path(paths: &ToolPaths) -> &PathBuf {
127 &paths.host_secret_path
128}
129
130fn load_or_create_host_secret(paths: &ToolPaths) -> Result<Vec<u8>> {
131 if let Some(secret) = load_host_secret_if_exists(paths)? {
132 return Ok(secret);
133 }
134 if !is_root_user() {
135 return Err(Error::HostSecretMissing);
136 }
137 let path = host_secret_path(paths);
138 if let Some(parent) = path.parent() {
139 std::fs::create_dir_all(parent)?;
140 set_owner_dir_permissions(parent)?;
141 verify_owner_dir_permissions(parent)?;
142 }
143 let mut secret = [0_u8; 32];
144 rand::rngs::OsRng.fill_bytes(&mut secret);
145 let encoded = hex_encode(&secret);
146 write_text_atomic(path, &encoded)?;
147 set_private_file_permissions(path)?;
148 verify_private_file_permissions(path)?;
149 Ok(secret.to_vec())
150}
151
152fn load_host_secret_if_exists(paths: &ToolPaths) -> Result<Option<Vec<u8>>> {
153 let path = host_secret_path(paths);
154 if !path.exists() {
155 return Ok(None);
156 }
157 verify_private_file_permissions(path)?;
158 let text = std::fs::read_to_string(path)?;
159 let decoded =
160 hex_decode(text.trim()).map_err(|_| std::io::Error::other("invalid host secret"))?;
161 if decoded.len() != 32 {
162 return Err(std::io::Error::other("invalid host secret length").into());
163 }
164 Ok(Some(decoded))
165}
166
167fn compute_mac_hex(secret: &[u8], uid: u32, expires_at_epoch_secs: u64) -> Result<String> {
168 let payload = format!("{uid}:{expires_at_epoch_secs}");
169 let mut mac = HmacSha256::new_from_slice(secret)
170 .map_err(|_| std::io::Error::other("invalid hmac key"))?;
171 mac.update(payload.as_bytes());
172 Ok(hex_encode(&mac.finalize().into_bytes()))
173}
174
175fn verify_mac_hex(secret: &[u8], uid: u32, expires_at_epoch_secs: u64, mac_hex: &str) -> Result<bool> {
176 let payload = format!("{uid}:{expires_at_epoch_secs}");
177 let provided = match hex_decode(mac_hex) {
178 Ok(bytes) => bytes,
179 Err(_) => return Ok(false),
180 };
181 let mut mac = HmacSha256::new_from_slice(secret)
182 .map_err(|_| std::io::Error::other("invalid hmac key"))?;
183 mac.update(payload.as_bytes());
184 Ok(mac.verify_slice(&provided).is_ok())
185}
186
187fn hex_encode(bytes: &[u8]) -> String {
188 bytes.iter().map(|b| format!("{b:02x}")).collect()
189}
190
191fn hex_decode(text: &str) -> std::result::Result<Vec<u8>, ()> {
192 if !text.len().is_multiple_of(2) {
193 return Err(());
194 }
195 let mut out = Vec::with_capacity(text.len() / 2);
196 let chars = text.as_bytes().chunks_exact(2);
197 for chunk in chars {
198 let hi = hex_nibble(chunk[0]).ok_or(())?;
199 let lo = hex_nibble(chunk[1]).ok_or(())?;
200 out.push((hi << 4) | lo);
201 }
202 Ok(out)
203}
204
205fn hex_nibble(byte: u8) -> Option<u8> {
206 match byte {
207 b'0'..=b'9' => Some(byte - b'0'),
208 b'a'..=b'f' => Some(byte - b'a' + 10),
209 b'A'..=b'F' => Some(byte - b'A' + 10),
210 _ => None,
211 }
212}
213
214fn is_root_user() -> bool {
215 #[cfg(unix)]
216 {
217 unsafe { libc::geteuid() == 0 }
219 }
220 #[cfg(not(unix))]
221 {
222 false
223 }
224}
225
226fn now_epoch_secs() -> u64 {
227 SystemTime::now()
228 .duration_since(UNIX_EPOCH)
229 .unwrap_or(Duration::from_secs(0))
230 .as_secs()
231}
232
233#[cfg(test)]
234mod tests {
235 use std::time::Duration;
236
237 use tempfile::TempDir;
238
239 use crate::paths::ToolPaths;
240
241 use super::{ElevationOptions, clear_elevation_session, create_elevation_session, is_elevated};
242
243 fn temp_paths(base: &std::path::Path) -> ToolPaths {
244 ToolPaths {
245 tool_id: crate::paths::ToolId::parse("demo").expect("tool"),
246 installed_warrant_path: base.join("etc").join("warrant.toml"),
247 version_state_path: base.join("etc").join("signing").join("version"),
248 signing_private_key_path: base.join("etc").join("signing").join("private.key"),
249 signing_public_key_path: base.join("etc").join("signing").join("public.key"),
250 host_secret_path: base.join("etc").join("host.key"),
251 session_dir_path: base.join("run").join("demo"),
252 }
253 }
254
255 fn write_host_key(paths: &ToolPaths, hex_byte: &str) {
256 super::write_text_atomic(&paths.host_secret_path, &hex_byte.repeat(32)).expect("host key");
257 super::set_private_file_permissions(&paths.host_secret_path).expect("chmod");
258 }
259
260 #[test]
261 fn create_and_clear_session() {
262 let dir = TempDir::new().expect("tempdir");
263 let paths = temp_paths(dir.path());
264 let uid = 1000;
265 write_host_key(&paths, "11");
266
267 assert!(!is_elevated(&paths, uid).expect("not elevated"));
268 create_elevation_session(
269 &paths,
270 uid,
271 ElevationOptions {
272 duration: Duration::from_secs(120),
273 },
274 )
275 .expect("create session");
276 assert!(is_elevated(&paths, uid).expect("elevated"));
277
278 clear_elevation_session(&paths, uid).expect("clear");
279 assert!(!is_elevated(&paths, uid).expect("not elevated"));
280 }
281
282 #[test]
283 fn expired_session_is_not_elevated() {
284 let dir = TempDir::new().expect("tempdir");
285 let paths = temp_paths(dir.path());
286 let uid = 1000;
287 write_host_key(&paths, "22");
288 create_elevation_session(
289 &paths,
290 uid,
291 ElevationOptions {
292 duration: Duration::from_secs(120),
293 },
294 )
295 .expect("create session");
296
297 let session_path = super::session_token_path(&paths, uid);
298 std::fs::write(
299 session_path,
300 r#"{"uid":1000,"created_at_epoch_secs":0,"expires_at_epoch_secs":0,"mac":"00"}"#,
301 )
302 .expect("rewrite token as expired");
303
304 assert!(!is_elevated(&paths, uid).expect("expired session should not elevate"));
305 }
306
307 #[test]
308 fn corrupt_session_token_is_not_elevated() {
309 let dir = TempDir::new().expect("tempdir");
310 let paths = temp_paths(dir.path());
311 let uid = 1000;
312 write_host_key(&paths, "33");
313 create_elevation_session(
314 &paths,
315 uid,
316 ElevationOptions {
317 duration: Duration::from_secs(120),
318 },
319 )
320 .expect("create session");
321
322 let session_path = super::session_token_path(&paths, uid);
323 std::fs::write(session_path, "{not-json").expect("corrupt token");
324 assert!(!is_elevated(&paths, uid).expect("corrupt session should not elevate"));
325 }
326
327 #[test]
328 fn created_session_token_contains_mac() {
329 let dir = TempDir::new().expect("tempdir");
330 let paths = temp_paths(dir.path());
331 let uid = 1000;
332 write_host_key(&paths, "44");
333
334 create_elevation_session(
335 &paths,
336 uid,
337 ElevationOptions {
338 duration: Duration::from_secs(120),
339 },
340 )
341 .expect("create session");
342
343 let text =
344 std::fs::read_to_string(super::session_token_path(&paths, uid)).expect("read token");
345 assert!(text.contains("\"mac\""));
346 }
347
348 #[test]
349 fn tampered_mac_is_not_elevated() {
350 let dir = TempDir::new().expect("tempdir");
351 let paths = temp_paths(dir.path());
352 let uid = 1000;
353 write_host_key(&paths, "55");
354 create_elevation_session(
355 &paths,
356 uid,
357 ElevationOptions {
358 duration: Duration::from_secs(120),
359 },
360 )
361 .expect("create session");
362
363 let session_path = super::session_token_path(&paths, uid);
364 let text = std::fs::read_to_string(&session_path).expect("read token");
365 let tampered = text.replace("\"mac\": \"", "\"mac\": \"deadbeef");
366 std::fs::write(session_path, tampered).expect("tamper");
367 assert!(!is_elevated(&paths, uid).expect("tampered mac should fail"));
368 }
369
370 #[test]
371 fn tampered_expiry_is_not_elevated() {
372 let dir = TempDir::new().expect("tempdir");
373 let paths = temp_paths(dir.path());
374 let uid = 1000;
375 write_host_key(&paths, "66");
376 create_elevation_session(
377 &paths,
378 uid,
379 ElevationOptions {
380 duration: Duration::from_secs(120),
381 },
382 )
383 .expect("create session");
384
385 let session_path = super::session_token_path(&paths, uid);
386 let mut token: serde_json::Value = serde_json::from_str(
387 &std::fs::read_to_string(&session_path).expect("read token"),
388 )
389 .expect("json");
390 token["expires_at_epoch_secs"] = serde_json::Value::from(4_000_000_000_u64);
391 std::fs::write(
392 session_path,
393 serde_json::to_string_pretty(&token).expect("serialize"),
394 )
395 .expect("tamper");
396 assert!(!is_elevated(&paths, uid).expect("tampered expiry should fail"));
397 }
398
399 #[test]
400 fn missing_mac_is_not_elevated() {
401 let dir = TempDir::new().expect("tempdir");
402 let paths = temp_paths(dir.path());
403 let uid = 1000;
404 write_host_key(&paths, "77");
405 create_elevation_session(
406 &paths,
407 uid,
408 ElevationOptions {
409 duration: Duration::from_secs(120),
410 },
411 )
412 .expect("create session");
413
414 let session_path = super::session_token_path(&paths, uid);
415 let mut token: serde_json::Value = serde_json::from_str(
416 &std::fs::read_to_string(&session_path).expect("read token"),
417 )
418 .expect("json");
419 token.as_object_mut().expect("object").remove("mac");
420 std::fs::write(
421 session_path,
422 serde_json::to_string_pretty(&token).expect("serialize"),
423 )
424 .expect("rewrite");
425 assert!(!is_elevated(&paths, uid).expect("missing mac should fail"));
426 }
427}