feat(api): sign dashboard callbacks with the agent key

This commit is contained in:
charles-gauthereau
2026-10-20 16:22:58 +02:00
parent 2fc84e7108
commit bb2d79dc65
4 changed files with 148 additions and 5 deletions
+2 -1
View File
@@ -37,7 +37,8 @@ impl Context {
};
let server_url = format!("{}/api", edge_key.server_url);
let api_client = ApiClient::new(server_url);
let api_client = ApiClient::new(server_url)
.with_agent_auth(&edge_key.agent_id, &edge_key.master_key_b64);
Context {
edge_key: edge_key,
+61 -4
View File
@@ -1,14 +1,37 @@
#![allow(dead_code)]
use crate::services::api::ApiError;
use reqwest::{Client, Method};
use base64::{Engine as _, engine::general_purpose};
use hmac::{Hmac, Mac};
use reqwest::header::CONTENT_TYPE;
use reqwest::{Client, Method, RequestBuilder};
use serde::de::DeserializeOwned;
use std::time::Duration;
use sha2::{Digest, Sha256};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
type HmacSha256 = Hmac<Sha256>;
#[derive(Clone, Debug)]
pub struct ApiClient {
base_url: String,
http: Client,
agent_key: Option<Vec<u8>>,
}
/// Per-agent signing key: HMAC-SHA256(master key, "portabase-agent-auth:v1:" + agent id).
pub fn derive_agent_key(master_key: &[u8], agent_id: &str) -> Vec<u8> {
let mut mac = HmacSha256::new_from_slice(master_key).expect("HMAC accepts any key length");
mac.update(format!("portabase-agent-auth:v1:{agent_id}").as_bytes());
mac.finalize().into_bytes().to_vec()
}
/// Lowercase hex HMAC-SHA256(agent key, METHOD \n PATH \n TIMESTAMP \n hex(sha256(body))).
/// Must match the dashboard's `signAgentRequest` (src/utils/status-crypto.ts).
pub fn sign_request(agent_key: &[u8], method: &str, path: &str, timestamp: &str, body: &[u8]) -> String {
let body_hash = hex::encode(Sha256::digest(body));
let mut mac = HmacSha256::new_from_slice(agent_key).expect("HMAC accepts any key length");
mac.update(format!("{method}\n{path}\n{timestamp}\n{body_hash}").as_bytes());
hex::encode(mac.finalize().into_bytes())
}
impl ApiClient {
@@ -21,9 +44,36 @@ impl ApiClient {
Self {
base_url: base_url.into(),
http,
agent_key: None,
}
}
/// Sign every dashboard call with the key derived from the EDGE_KEY master key.
pub fn with_agent_auth(mut self, agent_id: &str, master_key_b64: &str) -> Self {
let master_key = general_purpose::STANDARD
.decode(master_key_b64)
.expect("EDGE_KEY master key is not valid base64");
self.agent_key = Some(derive_agent_key(&master_key, agent_id));
self
}
/// Adds X-Agent-Timestamp / X-Agent-Signature, computed per call. The signed path is the
/// dashboard route ("/api" + path), independent of any prefix in the server URL.
fn sign(&self, request: RequestBuilder, method: &Method, path: &str, body: &[u8]) -> RequestBuilder {
let Some(agent_key) = &self.agent_key else {
return request;
};
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before 1970")
.as_secs()
.to_string();
let signature = sign_request(agent_key, method.as_str(), &format!("/api{path}"), &timestamp, body);
request
.header("X-Agent-Timestamp", timestamp)
.header("X-Agent-Signature", signature)
}
pub async fn request<T: DeserializeOwned>(
&self,
method: Method,
@@ -31,7 +81,8 @@ impl ApiClient {
) -> Result<Option<T>, ApiError> {
let url = format!("{}{}", self.base_url, path);
let res = self.http.request(method, &url).send().await?;
let request = self.sign(self.http.request(method.clone(), &url), &method, path, b"");
let res = request.send().await?;
let status = res.status();
let body = res.text().await.unwrap_or_default();
@@ -57,7 +108,13 @@ impl ApiClient {
{
let url = format!("{}{}", self.base_url, path);
let res = self.http.request(method, &url).json(body).send().await?;
// Serialize once: the signature covers exactly the bytes that are sent.
let bytes = serde_json::to_vec(body)?;
let request = self
.http
.request(method.clone(), &url)
.header(CONTENT_TYPE, "application/json");
let res = self.sign(request, &method, path, &bytes).body(bytes).send().await?;
let status = res.status();
let body_text = res.text().await.unwrap_or_default();
+84
View File
@@ -0,0 +1,84 @@
use crate::services::api::ApiClient;
use crate::services::api::client::{derive_agent_key, sign_request};
use base64::{Engine as _, engine::general_purpose};
use reqwest::Method;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
// Shared test vector, asserted identically by the dashboard (src/utils/status-crypto.ts).
const AGENT_ID: &str = "11111111-1111-4111-8111-111111111111";
fn vector_master_key() -> Vec<u8> {
(0u8..32).collect()
}
#[test]
fn derived_agent_key_matches_the_dashboard_vector() {
let key = derive_agent_key(&vector_master_key(), AGENT_ID);
assert_eq!(
hex::encode(key),
"16bee8dcf9d5f497cdd882ca208ff7f630ca02a5ecfe157f34239838fdcd9006"
);
}
#[test]
fn request_signature_matches_the_dashboard_vector() {
let key = derive_agent_key(&vector_master_key(), AGENT_ID);
let path = format!("/api/agent/{AGENT_ID}/status");
assert_eq!(
sign_request(&key, "POST", &path, "1760968800", br#"{"version":"1.24.0","databases":[]}"#),
"ad1e56a50ac0c4a3f539f3180d2957ebeb9a61bd1df44e57b50f8e59dd269069"
);
assert_eq!(
sign_request(&key, "POST", &path, "1760968800", "{\"name\":\"Base é\"}".as_bytes()),
"d3e6911c9273e0224b8dbae72cabdd3720973f1866c651fb4c99e1ac03e73231"
);
}
#[tokio::test]
async fn every_request_is_signed_with_a_fresh_timestamp() {
let server = MockServer::start().await;
let route = format!("/agent/{AGENT_ID}/status");
Mock::given(method("POST"))
.and(path(route.as_str()))
.respond_with(ResponseTemplate::new(200))
.expect(2)
.mount(&server)
.await;
let client = ApiClient::new(server.uri())
.with_agent_auth(AGENT_ID, &general_purpose::STANDARD.encode(vector_master_key()));
let body = serde_json::json!({"version": "1.24.0", "databases": [{"name": "Base é"}]});
for i in 0..2 {
if i > 0 {
tokio::time::sleep(Duration::from_millis(1100)).await;
}
let _: Option<serde_json::Value> = client
.request_with_body(Method::POST, &route, &body)
.await
.expect("signed request accepted by the mock");
}
let key = derive_agent_key(&vector_master_key(), AGENT_ID);
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
let requests = server.received_requests().await.unwrap();
let mut timestamps = Vec::new();
for req in &requests {
let ts = req.headers.get("x-agent-timestamp").unwrap().to_str().unwrap();
assert!(ts.chars().all(|c| c.is_ascii_digit()), "timestamp {ts}");
assert!(now.abs_diff(ts.parse::<u64>().unwrap()) <= 5);
assert_eq!(req.headers.get("content-type").unwrap(), "application/json");
assert_eq!(
req.headers.get("x-agent-signature").unwrap().to_str().unwrap(),
sign_request(&key, "POST", &format!("/api{route}"), ts, &req.body)
);
timestamps.push(ts.to_string());
}
assert_eq!(requests.len(), 2);
assert_ne!(timestamps[0], timestamps[1], "each call signs with its own timestamp");
}
+1
View File
@@ -1,3 +1,4 @@
mod api_client_tests;
mod api_models_tests;
mod backup_runner_tests;
mod backup_uploader_tests;