1use std::fs;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14use anyhow::{Context, Result};
15use uuid::Uuid;
16
17use crate::BeamPaths;
18
19const TIMESTAMP_SEPARATOR: char = '.';
21
22pub fn now_unix_secs() -> u64 {
24 SystemTime::now()
25 .duration_since(UNIX_EPOCH)
26 .unwrap_or_default()
27 .as_secs()
28}
29
30pub fn generate_api_token() -> String {
32 generate_api_token_at(now_unix_secs())
33}
34
35pub 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
45pub 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
57pub 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
66pub 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
84use hmac::{Hmac, KeyInit, Mac};
92use sha2::{Digest, Sha256};
93
94pub const SIG_TIMESTAMP_HEADER: &str = "x-beam-ts";
96pub const SIG_NONCE_HEADER: &str = "x-beam-nonce";
98pub const SIG_HEADER: &str = "x-beam-sig";
100
101pub const SIG_WINDOW_SECS: u64 = 60;
103
104pub fn generate_sig_nonce() -> String {
106 Uuid::new_v4().simple().to_string()
107}
108
109pub 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
125pub 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 = Hmac::<Sha256>::new_from_slice(key.as_bytes())
136 .expect("hmac accepts keys of any length");
137 mac.update(payload.as_bytes());
138 hex_encode(&mac.finalize().into_bytes())
139}
140
141pub 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 = Hmac::<Sha256>::new_from_slice(key.as_bytes())
156 .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() % 2 != 0 {
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 assert!(parse_api_token(&"a".repeat(64)).is_none());
208 assert!(parse_api_token(&format!("not-a-ts.{}", "a".repeat(64))).is_none());
210 assert!(parse_api_token("123.abc").is_none());
212 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, 1000, "nonce-1", "POST", "/sessions/abc/input", body, &sig
258 ));
259 let other_key = generate_api_token();
261 assert!(!verify_request_signature(
262 &other_key, 1000, "nonce-1", "POST", "/sessions/abc/input", body, &sig
263 ));
264 }
265
266 #[test]
267 fn signature_detects_tampering() {
268 let key = generate_api_token();
269 let body = b"original";
270 let sig = sign_request(&key, 1000, "n", "POST", "/sessions/abc/input", body);
271 assert!(!verify_request_signature(
273 &key, 1000, "n", "POST", "/sessions/abc/input", b"tampered", &sig
274 ));
275 assert!(!verify_request_signature(
276 &key, 1000, "n", "GET", "/sessions/abc/input", body, &sig
277 ));
278 assert!(!verify_request_signature(
279 &key, 1000, "n", "POST", "/sessions/xyz/input", body, &sig
280 ));
281 assert!(!verify_request_signature(
282 &key, 9999, "n", "POST", "/sessions/abc/input", body, &sig
283 ));
284 assert!(!verify_request_signature(
285 &key, 1000, "other", "POST", "/sessions/abc/input", body, &sig
286 ));
287 assert!(!verify_request_signature(
289 &key, 1000, "n", "POST", "/sessions/abc/input", body, "not-hex"
290 ));
291 }
292
293 #[test]
294 fn signature_method_case_is_normalized() {
295 let key = generate_api_token();
296 let sig = sign_request(&key, 1000, "n", "post", "/a", b"");
297 assert!(verify_request_signature(&key, 1000, "n", "POST", "/a", b"", &sig));
298 }
299}