mirror of
https://github.com/Portabase/agent.git
synced 2026-10-21 03:35:24 +00:00
feat(api): sign dashboard callbacks with the agent key
This commit is contained in:
+2
-1
@@ -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,
|
||||
|
||||
@@ -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}"), ×tamp, 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();
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
mod api_client_tests;
|
||||
mod api_models_tests;
|
||||
mod backup_runner_tests;
|
||||
mod backup_uploader_tests;
|
||||
|
||||
Reference in New Issue
Block a user