use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use uuid::Uuid;
use crate::BeamPaths;
const TIMESTAMP_SEPARATOR: char = '.';
pub fn now_unix_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
pub fn generate_api_token() -> String {
generate_api_token_at(now_unix_secs())
}
pub fn generate_api_token_at(issued_at_unix: u64) -> String {
let secret = format!(
"{}{}",
Uuid::new_v4().as_simple(),
Uuid::new_v4().as_simple()
);
format!("{issued_at_unix}{TIMESTAMP_SEPARATOR}{secret}")
}
pub fn parse_api_token(token: &str) -> Option<(u64, &str)> {
let (ts, secret) = token.split_once(TIMESTAMP_SEPARATOR)?;
if secret.len() != 64 || !secret.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let issued_at = ts.parse().ok()?;
Some((issued_at, secret))
}
pub fn read_api_token(paths: &BeamPaths) -> Option<String> {
fs::read_to_string(paths.api_token_file())
.ok()
.map(|raw| raw.trim().to_string())
.filter(|token| !token.is_empty())
}
pub fn write_api_token(paths: &BeamPaths, token: &str) -> Result<()> {
let path = paths.api_token_file();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let tmp = path.with_extension(format!("{}.tmp", Uuid::new_v4()));
fs::write(&tmp, format!("{token}\n"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600));
}
fs::rename(&tmp, &path)
.with_context(|| format!("failed to atomically write {}", path.display()))?;
Ok(())
}
use hmac::{Hmac, KeyInit, Mac};
use sha2::{Digest, Sha256};
pub const SIG_TIMESTAMP_HEADER: &str = "x-beam-ts";
pub const SIG_NONCE_HEADER: &str = "x-beam-nonce";
pub const SIG_HEADER: &str = "x-beam-sig";
pub const SIG_WINDOW_SECS: u64 = 60;
pub fn generate_sig_nonce() -> String {
Uuid::new_v4().simple().to_string()
}
pub fn signature_payload(
ts_unix: u64,
nonce: &str,
method: &str,
path_query: &str,
body: &[u8],
) -> String {
let body_hash = hex_encode(&Sha256::digest(body));
format!(
"{ts_unix}\n{nonce}\n{}\n{path_query}\n{body_hash}",
method.to_ascii_uppercase()
)
}
pub fn sign_request(
key: &str,
ts_unix: u64,
nonce: &str,
method: &str,
path_query: &str,
body: &[u8],
) -> String {
let payload = signature_payload(ts_unix, nonce, method, path_query, body);
let mut mac =
Hmac::<Sha256>::new_from_slice(key.as_bytes()).expect("hmac accepts keys of any length");
mac.update(payload.as_bytes());
hex_encode(&mac.finalize().into_bytes())
}
pub fn verify_request_signature(
key: &str,
ts_unix: u64,
nonce: &str,
method: &str,
path_query: &str,
body: &[u8],
presented_sig: &str,
) -> bool {
let Some(sig_bytes) = hex_decode(presented_sig) else {
return false;
};
let payload = signature_payload(ts_unix, nonce, method, path_query, body);
let mut mac =
Hmac::<Sha256>::new_from_slice(key.as_bytes()).expect("hmac accepts keys of any length");
mac.update(payload.as_bytes());
mac.verify_slice(&sig_bytes).is_ok()
}
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn hex_decode(s: &str) -> Option<Vec<u8>> {
if !s.len().is_multiple_of(2) {
return None;
}
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_paths(label: &str) -> BeamPaths {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
BeamPaths::from_root(std::env::temp_dir().join(format!(
"beam-api-token-test-{}-{}-{}",
label,
nanos,
std::process::id()
)))
}
#[test]
fn generate_embeds_timestamp_and_parses_roundtrip() {
let now = now_unix_secs();
let token = generate_api_token();
let (issued_at, secret) = parse_api_token(&token).unwrap();
assert!(issued_at.abs_diff(now) <= 1);
assert_eq!(secret.len(), 64);
assert!(secret.chars().all(|c| c.is_ascii_hexdigit()));
assert_ne!(generate_api_token(), token);
}
#[test]
fn parse_rejects_malformed_tokens() {
assert!(parse_api_token("").is_none());
assert!(parse_api_token(&"a".repeat(64)).is_none());
assert!(parse_api_token(&format!("not-a-ts.{}", "a".repeat(64))).is_none());
assert!(parse_api_token("123.abc").is_none());
assert!(parse_api_token(&format!("123.{}", "g".repeat(64))).is_none());
}
#[test]
fn write_then_read_roundtrip_with_restrict_perms() {
let paths = temp_paths("roundtrip");
let token = generate_api_token();
write_api_token(&paths, &token).unwrap();
assert_eq!(read_api_token(&paths).as_deref(), Some(token.as_str()));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let metadata = fs::metadata(paths.api_token_file()).unwrap();
let mode = metadata.permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "expected 0600 permissions, got {:o}", mode);
}
let _ = fs::remove_dir_all(paths.root());
}
#[test]
fn rewrite_replaces_previous_token() {
let paths = temp_paths("rewrite");
write_api_token(&paths, "first").unwrap();
write_api_token(&paths, "second").unwrap();
assert_eq!(read_api_token(&paths).as_deref(), Some("second"));
let _ = fs::remove_dir_all(paths.root());
}
#[test]
fn read_returns_none_for_missing_or_empty_file() {
let paths = temp_paths("missing");
assert!(read_api_token(&paths).is_none());
fs::create_dir_all(paths.root()).unwrap();
fs::write(paths.api_token_file(), " \n").unwrap();
assert!(read_api_token(&paths).is_none());
let _ = fs::remove_dir_all(paths.root());
}
#[test]
fn signature_roundtrip_and_key_isolation() {
let key = generate_api_token();
let body = br#"{"content":"hello"}"#;
let sig = sign_request(&key, 1000, "nonce-1", "POST", "/sessions/abc/input", body);
assert!(verify_request_signature(
&key,
1000,
"nonce-1",
"POST",
"/sessions/abc/input",
body,
&sig
));
let other_key = generate_api_token();
assert!(!verify_request_signature(
&other_key,
1000,
"nonce-1",
"POST",
"/sessions/abc/input",
body,
&sig
));
}
#[test]
fn signature_detects_tampering() {
let key = generate_api_token();
let body = b"original";
let sig = sign_request(&key, 1000, "n", "POST", "/sessions/abc/input", body);
assert!(!verify_request_signature(
&key,
1000,
"n",
"POST",
"/sessions/abc/input",
b"tampered",
&sig
));
assert!(!verify_request_signature(
&key,
1000,
"n",
"GET",
"/sessions/abc/input",
body,
&sig
));
assert!(!verify_request_signature(
&key,
1000,
"n",
"POST",
"/sessions/xyz/input",
body,
&sig
));
assert!(!verify_request_signature(
&key,
9999,
"n",
"POST",
"/sessions/abc/input",
body,
&sig
));
assert!(!verify_request_signature(
&key,
1000,
"other",
"POST",
"/sessions/abc/input",
body,
&sig
));
assert!(!verify_request_signature(
&key,
1000,
"n",
"POST",
"/sessions/abc/input",
body,
"not-hex"
));
}
#[test]
fn signature_method_case_is_normalized() {
let key = generate_api_token();
let sig = sign_request(&key, 1000, "n", "post", "/a", b"");
assert!(verify_request_signature(
&key, 1000, "n", "POST", "/a", b"", &sig
));
}
}