2026-07-16 13:20:24 +00:00
|
|
|
//! 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(),
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 00:57:45 +00:00
|
|
|
// ── 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<String>,
|
|
|
|
|
/// 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<String>,
|
|
|
|
|
/// 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<String>,
|
|
|
|
|
/// `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<IdentityCheck>,
|
|
|
|
|
) -> 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,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-16 13:20:24 +00:00
|
|
|
// ── 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());
|
|
|
|
|
}
|
2026-07-18 00:57:45 +00:00
|
|
|
|
|
|
|
|
// ── 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"
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-07-16 13:20:24 +00:00
|
|
|
}
|