Skip to main content

beam_core/
api_token.rs

1//! Local API token: generation, reading, and atomic 0600 writes.
2//!
3//! Token format: `<issued-at unix seconds>.<64 lowercase hex chars>`. The
4//! issue timestamp is embedded in the token itself — file mtime is NOT
5//! trusted, because copies, backup restores, and sync tools all rewrite it.
6//! The daemon owns generation and rotation; local clients (beam CLI) only
7//! read the file and send the token verbatim as an `Authorization: Bearer`
8//! credential. Validity is an exact string match against daemon state, so a
9//! tampered timestamp never authenticates.
10
11use std::fs;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14use anyhow::{Context, Result};
15use uuid::Uuid;
16
17use crate::BeamPaths;
18
19/// Separator between the embedded issue timestamp and the random secret.
20const TIMESTAMP_SEPARATOR: char = '.';
21
22/// Current wall-clock time as unix seconds.
23pub fn now_unix_secs() -> u64 {
24    SystemTime::now()
25        .duration_since(UNIX_EPOCH)
26        .unwrap_or_default()
27        .as_secs()
28}
29
30/// Generate a new local API token issued at the current time.
31pub fn generate_api_token() -> String {
32    generate_api_token_at(now_unix_secs())
33}
34
35/// Generate a token for an explicit issue time (tests, clock control).
36pub fn generate_api_token_at(issued_at_unix: u64) -> String {
37    let secret = format!(
38        "{}{}",
39        Uuid::new_v4().as_simple(),
40        Uuid::new_v4().as_simple()
41    );
42    format!("{issued_at_unix}{TIMESTAMP_SEPARATOR}{secret}")
43}
44
45/// Parse a token into (issued_at_unix, secret). Returns `None` for malformed
46/// tokens — including legacy plain-hex tokens without a timestamp, which the
47/// daemon then treats as stale and rotates out.
48pub fn parse_api_token(token: &str) -> Option<(u64, &str)> {
49    let (ts, secret) = token.split_once(TIMESTAMP_SEPARATOR)?;
50    if secret.len() != 64 || !secret.chars().all(|c| c.is_ascii_hexdigit()) {
51        return None;
52    }
53    let issued_at = ts.parse().ok()?;
54    Some((issued_at, secret))
55}
56
57/// Read the current token from disk. Returns `None` when the file is missing,
58/// unreadable, or empty.
59pub fn read_api_token(paths: &BeamPaths) -> Option<String> {
60    fs::read_to_string(paths.api_token_file())
61        .ok()
62        .map(|raw| raw.trim().to_string())
63        .filter(|token| !token.is_empty())
64}
65
66/// Atomically write the token (tmp + rename) with 0600 permissions on Unix.
67pub fn write_api_token(paths: &BeamPaths, token: &str) -> Result<()> {
68    let path = paths.api_token_file();
69    if let Some(parent) = path.parent() {
70        fs::create_dir_all(parent)?;
71    }
72    let tmp = path.with_extension(format!("{}.tmp", Uuid::new_v4()));
73    fs::write(&tmp, format!("{token}\n"))?;
74    #[cfg(unix)]
75    {
76        use std::os::unix::fs::PermissionsExt;
77        let _ = fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600));
78    }
79    fs::rename(&tmp, &path)
80        .with_context(|| format!("failed to atomically write {}", path.display()))?;
81    Ok(())
82}
83
84// ---- HMAC request signing ----
85//
86// The local api token doubles as an HMAC key and never appears on the wire:
87// clients send `x-beam-ts` / `x-beam-nonce` / `x-beam-sig` headers instead.
88// A sniffer on a forwarded link only sees one-time signatures, and the
89// timestamp window plus nonce replay check make them unusable elsewhere.
90
91use hmac::{Hmac, KeyInit, Mac};
92use sha2::{Digest, Sha256};
93
94/// Header carrying the signature timestamp (unix seconds).
95pub const SIG_TIMESTAMP_HEADER: &str = "x-beam-ts";
96/// Header carrying the per-request nonce.
97pub const SIG_NONCE_HEADER: &str = "x-beam-nonce";
98/// Header carrying the hex HMAC-SHA256 signature.
99pub const SIG_HEADER: &str = "x-beam-sig";
100
101/// Maximum accepted clock skew between signer and verifier, in seconds.
102pub const SIG_WINDOW_SECS: u64 = 60;
103
104/// Generate a random per-request signature nonce.
105pub fn generate_sig_nonce() -> String {
106    Uuid::new_v4().simple().to_string()
107}
108
109/// Canonical string that gets HMAC-signed for one request. `path_query` is
110/// the request path with query string (e.g. `/sessions/abc/input?x=1`).
111pub fn signature_payload(
112    ts_unix: u64,
113    nonce: &str,
114    method: &str,
115    path_query: &str,
116    body: &[u8],
117) -> String {
118    let body_hash = hex_encode(&Sha256::digest(body));
119    format!(
120        "{ts_unix}\n{nonce}\n{}\n{path_query}\n{body_hash}",
121        method.to_ascii_uppercase()
122    )
123}
124
125/// Compute the hex HMAC-SHA256 signature for one request.
126pub fn sign_request(
127    key: &str,
128    ts_unix: u64,
129    nonce: &str,
130    method: &str,
131    path_query: &str,
132    body: &[u8],
133) -> String {
134    let payload = signature_payload(ts_unix, nonce, method, path_query, body);
135    let mut mac =
136        Hmac::<Sha256>::new_from_slice(key.as_bytes()).expect("hmac accepts keys of any length");
137    mac.update(payload.as_bytes());
138    hex_encode(&mac.finalize().into_bytes())
139}
140
141/// Constant-time verification of a presented hex signature.
142pub fn verify_request_signature(
143    key: &str,
144    ts_unix: u64,
145    nonce: &str,
146    method: &str,
147    path_query: &str,
148    body: &[u8],
149    presented_sig: &str,
150) -> bool {
151    let Some(sig_bytes) = hex_decode(presented_sig) else {
152        return false;
153    };
154    let payload = signature_payload(ts_unix, nonce, method, path_query, body);
155    let mut mac =
156        Hmac::<Sha256>::new_from_slice(key.as_bytes()).expect("hmac accepts keys of any length");
157    mac.update(payload.as_bytes());
158    mac.verify_slice(&sig_bytes).is_ok()
159}
160
161fn hex_encode(bytes: &[u8]) -> String {
162    bytes.iter().map(|b| format!("{b:02x}")).collect()
163}
164
165fn hex_decode(s: &str) -> Option<Vec<u8>> {
166    if !s.len().is_multiple_of(2) {
167        return None;
168    }
169    (0..s.len())
170        .step_by(2)
171        .map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok())
172        .collect()
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    fn temp_paths(label: &str) -> BeamPaths {
180        let nanos = std::time::SystemTime::now()
181            .duration_since(std::time::UNIX_EPOCH)
182            .unwrap_or_default()
183            .as_nanos();
184        BeamPaths::from_root(std::env::temp_dir().join(format!(
185            "beam-api-token-test-{}-{}-{}",
186            label,
187            nanos,
188            std::process::id()
189        )))
190    }
191
192    #[test]
193    fn generate_embeds_timestamp_and_parses_roundtrip() {
194        let now = now_unix_secs();
195        let token = generate_api_token();
196        let (issued_at, secret) = parse_api_token(&token).unwrap();
197        assert!(issued_at.abs_diff(now) <= 1);
198        assert_eq!(secret.len(), 64);
199        assert!(secret.chars().all(|c| c.is_ascii_hexdigit()));
200        assert_ne!(generate_api_token(), token);
201    }
202
203    #[test]
204    fn parse_rejects_malformed_tokens() {
205        assert!(parse_api_token("").is_none());
206        // Legacy plain-hex token without timestamp.
207        assert!(parse_api_token(&"a".repeat(64)).is_none());
208        // Timestamp that does not parse.
209        assert!(parse_api_token(&format!("not-a-ts.{}", "a".repeat(64))).is_none());
210        // Secret too short.
211        assert!(parse_api_token("123.abc").is_none());
212        // Non-hex secret.
213        assert!(parse_api_token(&format!("123.{}", "g".repeat(64))).is_none());
214    }
215
216    #[test]
217    fn write_then_read_roundtrip_with_restrict_perms() {
218        let paths = temp_paths("roundtrip");
219        let token = generate_api_token();
220        write_api_token(&paths, &token).unwrap();
221        assert_eq!(read_api_token(&paths).as_deref(), Some(token.as_str()));
222        #[cfg(unix)]
223        {
224            use std::os::unix::fs::PermissionsExt;
225            let metadata = fs::metadata(paths.api_token_file()).unwrap();
226            let mode = metadata.permissions().mode() & 0o777;
227            assert_eq!(mode, 0o600, "expected 0600 permissions, got {:o}", mode);
228        }
229        let _ = fs::remove_dir_all(paths.root());
230    }
231
232    #[test]
233    fn rewrite_replaces_previous_token() {
234        let paths = temp_paths("rewrite");
235        write_api_token(&paths, "first").unwrap();
236        write_api_token(&paths, "second").unwrap();
237        assert_eq!(read_api_token(&paths).as_deref(), Some("second"));
238        let _ = fs::remove_dir_all(paths.root());
239    }
240
241    #[test]
242    fn read_returns_none_for_missing_or_empty_file() {
243        let paths = temp_paths("missing");
244        assert!(read_api_token(&paths).is_none());
245        fs::create_dir_all(paths.root()).unwrap();
246        fs::write(paths.api_token_file(), "  \n").unwrap();
247        assert!(read_api_token(&paths).is_none());
248        let _ = fs::remove_dir_all(paths.root());
249    }
250
251    #[test]
252    fn signature_roundtrip_and_key_isolation() {
253        let key = generate_api_token();
254        let body = br#"{"content":"hello"}"#;
255        let sig = sign_request(&key, 1000, "nonce-1", "POST", "/sessions/abc/input", body);
256        assert!(verify_request_signature(
257            &key,
258            1000,
259            "nonce-1",
260            "POST",
261            "/sessions/abc/input",
262            body,
263            &sig
264        ));
265        // Same payload with another key must fail.
266        let other_key = generate_api_token();
267        assert!(!verify_request_signature(
268            &other_key,
269            1000,
270            "nonce-1",
271            "POST",
272            "/sessions/abc/input",
273            body,
274            &sig
275        ));
276    }
277
278    #[test]
279    fn signature_detects_tampering() {
280        let key = generate_api_token();
281        let body = b"original";
282        let sig = sign_request(&key, 1000, "n", "POST", "/sessions/abc/input", body);
283        // Tampered body, method, path, timestamp, or nonce all fail.
284        assert!(!verify_request_signature(
285            &key,
286            1000,
287            "n",
288            "POST",
289            "/sessions/abc/input",
290            b"tampered",
291            &sig
292        ));
293        assert!(!verify_request_signature(
294            &key,
295            1000,
296            "n",
297            "GET",
298            "/sessions/abc/input",
299            body,
300            &sig
301        ));
302        assert!(!verify_request_signature(
303            &key,
304            1000,
305            "n",
306            "POST",
307            "/sessions/xyz/input",
308            body,
309            &sig
310        ));
311        assert!(!verify_request_signature(
312            &key,
313            9999,
314            "n",
315            "POST",
316            "/sessions/abc/input",
317            body,
318            &sig
319        ));
320        assert!(!verify_request_signature(
321            &key,
322            1000,
323            "other",
324            "POST",
325            "/sessions/abc/input",
326            body,
327            &sig
328        ));
329        // Malformed signature fails instead of panicking.
330        assert!(!verify_request_signature(
331            &key,
332            1000,
333            "n",
334            "POST",
335            "/sessions/abc/input",
336            body,
337            "not-hex"
338        ));
339    }
340
341    #[test]
342    fn signature_method_case_is_normalized() {
343        let key = generate_api_token();
344        let sig = sign_request(&key, 1000, "n", "post", "/a", b"");
345        assert!(verify_request_signature(
346            &key, 1000, "n", "POST", "/a", b"", &sig
347        ));
348    }
349}