Implement signed API communication to improve security
build / build-linux-amd64 (push) Successful in 1m50s

This commit is contained in:
2026-05-22 12:50:42 +02:00
parent 21b25bcc1b
commit 475da0e950
12 changed files with 906 additions and 24 deletions
+42 -10
View File
@@ -4,15 +4,18 @@
//! every time the service starts and posts it here so the admin UI can
//! surface it for support staff.
//!
//! Auth model mirrors `/api/sysinfo`: the request must carry the agent's
//! `(id, uuid)` and that pair must already correspond to a registered
//! peer in `peer`. There's no shared secret beyond that — same trust
//! boundary the existing sysinfo endpoint already operates under.
//! Auth: same per-peer signed-API gate as `/api/sysinfo` and
//! `/api/heartbeat` — see [`crate::api::device_auth`]. Managed peers
//! (`peer.managed = 1`) must carry a valid Ed25519 signature; stock
//! clients keep posting unsigned and the first valid signature TOFU-
//! promotes the peer.
use crate::api::device_auth::{self, AuthOutcome};
use crate::api::error::ApiError;
use crate::api::state::AppState;
use axum::body::Bytes;
use axum::extract::Extension;
use axum::Json;
use axum::http::HeaderMap;
use serde_json::Value;
use std::sync::Arc;
@@ -20,9 +23,21 @@ use std::sync::Arc;
/// Response (bare string, like sysinfo): `"OK"` or `"ID_NOT_FOUND"`.
pub async fn unattended_password(
Extension(state): Extension<Arc<AppState>>,
Json(payload): Json<Value>,
headers: HeaderMap,
body: Bytes,
) -> Result<String, ApiError> {
let id = payload
let outcome = device_auth::verify(
&state,
"POST",
"/api/unattended-password",
&headers,
&body,
)
.await?;
let payload: Value = serde_json::from_slice(&body)
.map_err(|_| ApiError::BadRequest("invalid json".into()))?;
let body_id = payload
.get("id")
.and_then(|v| v.as_str())
.unwrap_or_default();
@@ -34,15 +49,32 @@ pub async fn unattended_password(
.get("password")
.and_then(|v| v.as_str())
.unwrap_or_default();
if id.is_empty() || uuid.is_empty() || password.is_empty() {
if body_id.is_empty() || uuid.is_empty() || password.is_empty() {
return Err(ApiError::BadRequest(
"id, uuid, and password are required".into(),
));
}
// Bind the trusted identity to the body. For signed requests the body
// id must match the header id, or the agent is trying to overwrite
// someone else's displayed password. For unsigned requests we just
// need to ensure the peer isn't already locked down as managed.
let id = match outcome {
AuthOutcome::Verified { id: signed_id } => {
if body_id != signed_id {
return Err(ApiError::Unauthorized);
}
signed_id
}
AuthOutcome::LegacyUnsigned => {
device_auth::enforce_managed_for_id(&state, body_id).await?;
body_id.to_string()
}
};
let peer = state
.db
.get_peer(id)
.get_peer(&id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if peer.is_none() {
@@ -51,7 +83,7 @@ pub async fn unattended_password(
state
.db
.set_unattended_password(id, uuid, password)
.set_unattended_password(&id, uuid, password)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok("OK".to_string())