Files
huskies/server/src/service/gateway/identity.rs
T

176 lines
6.5 KiB
Rust
Raw Normal View History

//! Pure identity-probe verification — no I/O (story 1173).
//!
//! [`check_identity`] compares a sled's `/identity` probe response against the
//! `expected_node_id` recorded for that project and reports one of: a first
//! contact (no expected id captured yet), a match, a mismatch, an invalid
//! signature, or a missing signature (a legacy sled predating this story).
//! Callers ([`super::super::super::chat::transport::matrix::sled_upgrade`] and
//! `health`) own all I/O — issuing the HTTP probe and persisting a captured
//! `expected_node_id` back to `projects.toml`.
use serde::Deserialize;
/// Deserialized body of a `GET /identity?nonce=...` response.
#[derive(Debug, Clone, Deserialize)]
pub struct IdentityProbeResponse {
/// The responder's self-reported node ID (hex Ed25519 pubkey).
pub node_id: String,
/// The responder's self-reported Ed25519 public key (hex). Equal to
/// `node_id` on every server built since this field was introduced.
pub pubkey: String,
/// Ed25519 signature (hex) over the probe nonce, made with the CRDT
/// signing key. `None` when the responder predates story 1173 (a legacy
/// sled) — the `/identity` endpoint did not sign nonces before then.
#[serde(default)]
pub signature: Option<String>,
}
/// Outcome of comparing an [`IdentityProbeResponse`] against an
/// `expected_node_id`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IdentityCheck {
/// No `expected_node_id` was recorded yet; the signature verified, so
/// `node_id` should be captured as the expected identity going forward.
FirstContact {
/// The node ID to capture.
node_id: String,
},
/// The signature verified and the responder's node ID matches the
/// expected one.
Match,
/// The signature verified, but the responder's node ID does not match
/// the expected one — a different container answered.
Mismatch {
/// The verified node ID that actually answered.
responder_node_id: String,
},
/// The response included a signature, but it did not verify against the
/// claimed pubkey/nonce — the responder's identity cannot be trusted.
InvalidSignature,
/// The response had no `signature` field — a legacy sled running a
/// pre-story-1173 binary. Identity cannot be verified either way.
MissingSignature,
}
/// Verify `response` against `nonce` and compare the verified node ID to
/// `expected_node_id`.
///
/// A signature verifies when [`crate::node_identity::verify_message_strict`]
/// confirms it was produced by the private key matching `response.pubkey`
/// over `nonce`'s UTF-8 bytes — mirroring how `/identity` signs on the server
/// side via `crdt_state::sign_challenge`.
pub fn check_identity(
expected_node_id: Option<&str>,
nonce: &str,
response: &IdentityProbeResponse,
) -> IdentityCheck {
let Some(signature) = response.signature.as_deref() else {
return IdentityCheck::MissingSignature;
};
if !crate::node_identity::verify_message_strict(&response.pubkey, nonce.as_bytes(), signature) {
return IdentityCheck::InvalidSignature;
}
match expected_node_id {
None => IdentityCheck::FirstContact {
node_id: response.node_id.clone(),
},
Some(expected) if expected == response.node_id => IdentityCheck::Match,
Some(_) => IdentityCheck::Mismatch {
responder_node_id: response.node_id.clone(),
},
}
}
// ── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use bft_json_crdt::keypair::make_keypair;
fn signed_response(nonce: &str) -> (String, IdentityProbeResponse) {
let kp = make_keypair();
let pubkey = crate::node_identity::public_key_hex(&kp);
let sig = crate::node_identity::sign_challenge(&kp, nonce);
(
pubkey.clone(),
IdentityProbeResponse {
node_id: pubkey.clone(),
pubkey,
signature: Some(sig),
},
)
}
#[test]
fn first_contact_when_no_expected_id() {
let nonce = "nonce-1";
let (node_id, resp) = signed_response(nonce);
let result = check_identity(None, nonce, &resp);
assert_eq!(result, IdentityCheck::FirstContact { node_id });
}
#[test]
fn match_when_expected_id_equals_responder() {
let nonce = "nonce-2";
let (node_id, resp) = signed_response(nonce);
let result = check_identity(Some(&node_id), nonce, &resp);
assert_eq!(result, IdentityCheck::Match);
}
#[test]
fn mismatch_when_expected_id_differs() {
let nonce = "nonce-3";
let (node_id, resp) = signed_response(nonce);
let result = check_identity(Some("some-other-node-id"), nonce, &resp);
assert_eq!(
result,
IdentityCheck::Mismatch {
responder_node_id: node_id
}
);
}
#[test]
fn invalid_signature_wrong_nonce() {
let nonce = "nonce-4";
let (_node_id, resp) = signed_response(nonce);
// Verify against a different nonce than the one that was signed.
let result = check_identity(None, "different-nonce", &resp);
assert_eq!(result, IdentityCheck::InvalidSignature);
}
#[test]
fn invalid_signature_wrong_key() {
let nonce = "nonce-5";
let (_node_id, mut resp) = signed_response(nonce);
// Claim a different pubkey than the one that actually signed.
let other_kp = make_keypair();
let other_pubkey = crate::node_identity::public_key_hex(&other_kp);
resp.node_id = other_pubkey.clone();
resp.pubkey = other_pubkey;
let result = check_identity(None, nonce, &resp);
assert_eq!(result, IdentityCheck::InvalidSignature);
}
#[test]
fn missing_signature_is_legacy_sled() {
let resp = IdentityProbeResponse {
node_id: "abc123".to_string(),
pubkey: "abc123".to_string(),
signature: None,
};
let result = check_identity(Some("abc123"), "nonce-6", &resp);
assert_eq!(result, IdentityCheck::MissingSignature);
}
#[test]
fn missing_signature_field_deserializes_from_json_without_it() {
let json = r#"{"node_id":"abc","pubkey":"abc"}"#;
let resp: IdentityProbeResponse = serde_json::from_str(json).unwrap();
assert!(resp.signature.is_none());
}
}