//! 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, } /// 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(), }, } } // ── Fleet identity read report (story 1206) ──────────────────────────────── /// Per-sled identity report returned by the `fleet_identity` MCP tool's read /// mode: the recorded pin next to the live, cryptographically-verified /// identity. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] pub struct SledIdentityReport { /// The project/sled name. pub project: String, /// The sled's configured base URL, if any. pub url: Option, /// Whether the `/identity` probe reached the sled at all. pub connected: bool, /// The pinned `expected_node_id` recorded in `projects.toml`, if any. pub expected_pin: Option, /// The node ID from a signature-verified challenge-response, if the /// signature verified. Never populated from the unsigned `node_id` /// display field alone — an invalid or missing signature leaves this /// `None`. pub live_node_id: Option, /// `true` only when the sled is connected, its signature verified, and /// the verified node ID equals `expected_pin`. pub matched: bool, } /// Build a [`SledIdentityReport`] from the outcome of probing one sled. /// /// `check` is `None` when the sled was unreachable (the probe never got a /// response). Otherwise it's the result of running [`check_identity`] against /// whatever response was received. pub fn build_identity_report( project: &str, url: Option<&str>, expected_node_id: Option<&str>, check: Option, ) -> SledIdentityReport { let connected = check.is_some(); let (live_node_id, matched) = match &check { Some(IdentityCheck::Match) => (expected_node_id.map(str::to_string), true), Some(IdentityCheck::Mismatch { responder_node_id }) => { (Some(responder_node_id.clone()), false) } Some(IdentityCheck::FirstContact { node_id }) => (Some(node_id.clone()), false), Some(IdentityCheck::InvalidSignature) | Some(IdentityCheck::MissingSignature) | None => { (None, false) } }; SledIdentityReport { project: project.to_string(), url: url.map(str::to_string), connected, expected_pin: expected_node_id.map(str::to_string), live_node_id, matched, } } // ── 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()); } // ── build_identity_report (story 1206) ───────────────────────────────── #[test] fn report_matching_pin_is_connected_and_matched() { let report = build_identity_report( "myapp", Some("http://sled:3001"), Some("abc123"), Some(IdentityCheck::Match), ); assert!(report.connected); assert!(report.matched); assert_eq!(report.expected_pin.as_deref(), Some("abc123")); assert_eq!(report.live_node_id.as_deref(), Some("abc123")); } #[test] fn report_mismatch_names_the_responder_node_id_and_is_not_matched() { let report = build_identity_report( "myapp", Some("http://sled:3001"), Some("expected-id"), Some(IdentityCheck::Mismatch { responder_node_id: "different-id".to_string(), }), ); assert!(report.connected); assert!(!report.matched); assert_eq!(report.expected_pin.as_deref(), Some("expected-id")); assert_eq!(report.live_node_id.as_deref(), Some("different-id")); } #[test] fn report_no_pin_recorded_is_never_matched_even_on_first_contact() { let report = build_identity_report( "myapp", Some("http://sled:3001"), None, Some(IdentityCheck::FirstContact { node_id: "some-id".to_string(), }), ); assert!(report.connected); assert!(report.expected_pin.is_none()); assert_eq!(report.live_node_id.as_deref(), Some("some-id")); assert!( !report.matched, "no pin recorded yet means there is nothing to match against" ); } #[test] fn report_unreachable_sled_is_not_connected() { let report = build_identity_report("myapp", Some("http://sled:3001"), Some("abc123"), None); assert!(!report.connected); assert!(!report.matched); assert!(report.live_node_id.is_none()); } #[test] fn report_invalid_signature_is_connected_but_no_live_node_id() { let report = build_identity_report( "myapp", Some("http://sled:3001"), Some("abc123"), Some(IdentityCheck::InvalidSignature), ); assert!(report.connected, "the probe did reach the sled"); assert!(!report.matched); assert!( report.live_node_id.is_none(), "an unverified signature must never populate live_node_id" ); } }