huskies: merge 1173 story Identity-aware fleet checks: cryptographic node identity in upgrade and health probes
This commit is contained in:
@@ -3,10 +3,25 @@
|
||||
//! `GET /identity` returns the node's ID and public key as JSON. No
|
||||
//! authentication is required; only the public half of the keypair is
|
||||
//! disclosed.
|
||||
//!
|
||||
//! `GET /identity?nonce=<hex>` (story 1173) additionally signs the nonce with
|
||||
//! the node's CRDT signing key ([`crate::crdt_state::sign_challenge`]) so a
|
||||
//! caller can verify the response actually came from the sled it expects,
|
||||
//! not just whichever container answered on that URL. The signing call
|
||||
//! acquires the CRDT state mutex exactly once (lock → sign → unlock) with no
|
||||
//! nested CRDT calls while held, avoiding the bug-1170 self-deadlock class.
|
||||
|
||||
use poem::handler;
|
||||
use poem::web::Json;
|
||||
use serde::Serialize;
|
||||
use poem::web::{Json, Query};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Query parameters accepted by `GET /identity`.
|
||||
#[derive(Deserialize)]
|
||||
pub struct IdentityQuery {
|
||||
/// Optional challenge nonce to sign with the node's CRDT signing key.
|
||||
#[serde(default)]
|
||||
pub nonce: Option<String>,
|
||||
}
|
||||
|
||||
/// JSON response body for `GET /identity`.
|
||||
#[derive(Serialize)]
|
||||
@@ -15,22 +30,46 @@ pub struct IdentityResponse {
|
||||
pub node_id: String,
|
||||
/// Lowercase hex-encoding of the 32-byte Ed25519 public key.
|
||||
pub pubkey: String,
|
||||
/// Ed25519 signature (hex) over `nonce`, made with the CRDT signing key.
|
||||
/// `None` when no `nonce` was supplied, or before the CRDT layer has
|
||||
/// initialised (in which case `node_id`/`pubkey` fall back to the
|
||||
/// file-based node identity).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub signature: Option<String>,
|
||||
}
|
||||
|
||||
/// `GET /identity` — return this node's Ed25519 public key.
|
||||
/// `GET /identity` — return this node's Ed25519 public key, optionally
|
||||
/// signing a caller-supplied nonce with the CRDT signing key.
|
||||
///
|
||||
/// Returns `{"node_id": "<64-hex>", "pubkey": "<64-hex>"}`.
|
||||
/// No authentication required; the private key is never exposed.
|
||||
/// Returns `{"node_id": "<64-hex>", "pubkey": "<64-hex>", "signature": "<128-hex>"}`
|
||||
/// when a `nonce` query parameter is given and the CRDT layer is
|
||||
/// initialised. Otherwise omits `signature` and falls back to the
|
||||
/// file-based node identity (legacy shape). No authentication required;
|
||||
/// private keys are never exposed.
|
||||
#[handler]
|
||||
pub fn identity_handler() -> Json<IdentityResponse> {
|
||||
pub fn identity_handler(
|
||||
Query(IdentityQuery { nonce }): Query<IdentityQuery>,
|
||||
) -> Json<IdentityResponse> {
|
||||
if let Some(ref nonce) = nonce
|
||||
&& let Some((pubkey_hex, signature_hex)) = crate::crdt_state::sign_challenge(nonce)
|
||||
{
|
||||
return Json(IdentityResponse {
|
||||
node_id: pubkey_hex.clone(),
|
||||
pubkey: pubkey_hex,
|
||||
signature: Some(signature_hex),
|
||||
});
|
||||
}
|
||||
|
||||
match crate::node_identity::get_identity() {
|
||||
Some(id) => Json(IdentityResponse {
|
||||
node_id: id.node_id.clone(),
|
||||
pubkey: id.pubkey_hex.clone(),
|
||||
signature: None,
|
||||
}),
|
||||
None => Json(IdentityResponse {
|
||||
node_id: "uninitialized".to_string(),
|
||||
pubkey: "uninitialized".to_string(),
|
||||
signature: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -58,5 +97,45 @@ mod tests {
|
||||
assert_eq!(node_id.len(), 64);
|
||||
assert!(node_id.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
assert_eq!(node_id, pubkey);
|
||||
assert!(body.get("signature").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn identity_endpoint_signs_nonce_with_crdt_key_when_initialised() {
|
||||
crate::crdt_state::init_for_test();
|
||||
|
||||
let app = Route::new().at("/identity", get(identity_handler));
|
||||
let cli = TestClient::new(app);
|
||||
let resp = cli
|
||||
.get("/identity")
|
||||
.query("nonce", &"deadbeef")
|
||||
.send()
|
||||
.await;
|
||||
resp.assert_status_is_ok();
|
||||
|
||||
let body: serde_json::Value = resp.json().await.value().deserialize();
|
||||
let node_id = body["node_id"].as_str().unwrap();
|
||||
let pubkey = body["pubkey"].as_str().unwrap();
|
||||
let signature = body["signature"].as_str().unwrap();
|
||||
assert_eq!(node_id, pubkey);
|
||||
assert!(
|
||||
crate::node_identity::verify_message_strict(pubkey, b"deadbeef", signature),
|
||||
"signature must verify against the returned pubkey and nonce"
|
||||
);
|
||||
// The signed node_id must be the CRDT node id, not the file-based one.
|
||||
assert_eq!(node_id, crate::crdt_state::our_node_id().unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn identity_endpoint_without_nonce_omits_signature_even_when_crdt_initialised() {
|
||||
crate::crdt_state::init_for_test();
|
||||
|
||||
let app = Route::new().at("/identity", get(identity_handler));
|
||||
let cli = TestClient::new(app);
|
||||
let resp = cli.get("/identity").send().await;
|
||||
resp.assert_status_is_ok();
|
||||
|
||||
let body: serde_json::Value = resp.json().await.value().deserialize();
|
||||
assert!(body.get("signature").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user