Skip to main content

warrant_core/
elevation.rs

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        // SAFETY: libc call has no preconditions.
218        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}