Skip to main content

koi_certmesh/
client.rs

1//! `client_for` — the posture-keyed peer client (ADR-020 §6).
2//!
3//! One call returns a [`PeerClient`] that speaks the right protocol to a
4//! discovered peer: **plain HTTP** to an Open peer, **mTLS** to a secure peer —
5//! keyed off the peer's advertised posture plus the mesh CA pin. The caller never
6//! chooses http/https and never attaches a certificate, so the *same* consumer
7//! code path works against both kinds of peer (the mode-transparency contract,
8//! ADR-020 §2).
9//!
10//! The protocol decision is made loudly, not silently: a peer that requires
11//! authentication while this node is Open, or a peer anchored to a *different*
12//! mesh, returns a descriptive error instead of a connection that mysteriously
13//! fails at the TLS layer (ADR-020 §13: "the category's defining failure is
14//! silence").
15
16use std::sync::Arc;
17
18use koi_common::peer::Peer;
19
20use crate::error::CertmeshError;
21use crate::mtls;
22use crate::{CertmeshCore, Identity};
23
24/// A ready-to-use client to one peer, with the transport already resolved from
25/// the peer's posture (ADR-020 §6).
26///
27/// Built by [`CertmeshCore::client_for`]. `get`/`post_json` dispatch to plain HTTP
28/// or mTLS transparently; [`is_secure`](PeerClient::is_secure) reports which, so a
29/// consumer can *observe* per-connection trust state (the "padlock on the wire")
30/// without choosing it.
31pub struct PeerClient {
32    host: String,
33    port: u16,
34    transport: Transport,
35}
36
37enum Transport {
38    /// Plain HTTP to an Open peer.
39    Plain,
40    /// mTLS to a secure peer, with the client config (our leaf + the pinned CA)
41    /// built once at construction.
42    Mtls(Arc<rustls::ClientConfig>),
43}
44
45impl std::fmt::Debug for PeerClient {
46    /// Reports the dial target and resolved trust state; never the TLS config.
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        f.debug_struct("PeerClient")
49            .field("host", &self.host)
50            .field("port", &self.port)
51            .field("secure", &self.is_secure())
52            .finish()
53    }
54}
55
56impl PeerClient {
57    /// Whether this client speaks mTLS (the peer is secure and we authenticated to
58    /// it) rather than plain HTTP. The observable per-connection trust state.
59    pub fn is_secure(&self) -> bool {
60        matches!(self.transport, Transport::Mtls(_))
61    }
62
63    /// The resolved target host this client dials.
64    pub fn host(&self) -> &str {
65        &self.host
66    }
67
68    /// The resolved target port this client dials.
69    pub fn port(&self) -> u16 {
70        self.port
71    }
72
73    /// GET `path` from the peer, returning `(status, body)`. Plain or mTLS per the
74    /// resolved transport.
75    pub async fn get(&self, path: &str) -> Result<(u16, String), CertmeshError> {
76        match &self.transport {
77            Transport::Plain => {
78                mtls::request_plain(&self.host, self.port, hyper::Method::GET, path, None).await
79            }
80            Transport::Mtls(config) => {
81                mtls::request_tls(
82                    Arc::clone(config),
83                    &self.host,
84                    self.port,
85                    hyper::Method::GET,
86                    path,
87                    None,
88                )
89                .await
90            }
91        }
92    }
93
94    /// POST a JSON `body` to `path`, returning `(status, body)`. Plain or mTLS per
95    /// the resolved transport.
96    pub async fn post_json(&self, path: &str, body: &str) -> Result<(u16, String), CertmeshError> {
97        match &self.transport {
98            Transport::Plain => {
99                mtls::request_plain(&self.host, self.port, hyper::Method::POST, path, Some(body))
100                    .await
101            }
102            Transport::Mtls(config) => {
103                mtls::request_tls(
104                    Arc::clone(config),
105                    &self.host,
106                    self.port,
107                    hyper::Method::POST,
108                    path,
109                    Some(body),
110                )
111                .await
112            }
113        }
114    }
115}
116
117impl CertmeshCore {
118    /// Build a [`PeerClient`] for a discovered [`Peer`] (ADR-020 §6).
119    ///
120    /// Mode-transparent: an Open peer yields a plain-HTTP client; a secure peer
121    /// yields an mTLS client presenting **this node's** identity and pinning the
122    /// mesh CA. The caller writes one code path.
123    ///
124    /// Errors (loudly, not via a silent handshake failure):
125    /// - the peer advertises no dialable address/port;
126    /// - the peer requires authentication but this node is Open (no identity);
127    /// - the peer anchors to a *different* mesh (its `fp=` ≠ our CA fingerprint).
128    pub async fn client_for(&self, peer: &Peer) -> Result<PeerClient, CertmeshError> {
129        let (host, port) = peer.addr().ok_or_else(|| {
130            CertmeshError::Internal(format!(
131                "peer '{}' has no dialable address:port",
132                peer.record.name
133            ))
134        })?;
135        let identity = self.local_identity().await;
136        select_client(peer, identity.as_ref(), host, port)
137    }
138}
139
140/// Resolve the transport for a peer given our (optional) local identity. Pure —
141/// no I/O, no `self` — so the policy is unit-testable without a live CA. Building
142/// the rustls client config is the only non-trivial step (validates our PEMs).
143fn select_client(
144    peer: &Peer,
145    identity: Option<&Identity>,
146    host: String,
147    port: u16,
148) -> Result<PeerClient, CertmeshError> {
149    // Open peer → plain HTTP. No identity required on either side.
150    if !peer.posture.signed {
151        return Ok(PeerClient {
152            host,
153            port,
154            transport: Transport::Plain,
155        });
156    }
157
158    // Secure peer → we must present a client certificate, so we need an identity.
159    let id = identity.ok_or_else(|| {
160        CertmeshError::Internal(format!(
161            "peer '{}' requires authentication but this node is Open (no identity) — \
162             run `koi certmesh join` (or call ensure_identity()) first",
163            peer.record.name
164        ))
165    })?;
166
167    // Same-mesh check: an mTLS handshake can only succeed if the peer anchors to
168    // the CA we trust. Catch the mismatch here with a clear message rather than
169    // letting it surface as an opaque TLS error.
170    if let Some(peer_fp) = peer.fp.as_deref() {
171        if !peer_fp.eq_ignore_ascii_case(&id.ca_fingerprint) {
172            return Err(CertmeshError::Internal(format!(
173                "peer '{}' anchors to a different mesh (peer CA fp {} ≠ our CA fp {}) — \
174                 cannot establish mTLS",
175                peer.record.name, peer_fp, id.ca_fingerprint
176            )));
177        }
178    }
179
180    let config = mtls::build_client_config(&id.cert_pem, &id.key_pem, &id.ca_cert_pem)?;
181    Ok(PeerClient {
182        host,
183        port,
184        transport: Transport::Mtls(Arc::new(config)),
185    })
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use koi_common::posture::Posture;
192    use koi_common::types::ServiceRecord;
193    use rcgen::{BasicConstraints, CertificateParams, DnType, IsCa, KeyPair, SanType};
194    use std::collections::HashMap;
195    use std::net::{IpAddr, Ipv4Addr};
196
197    /// A CA, a CA-signed leaf (our identity cert/key), the CA fp, and a CA-signed
198    /// server cert (for live mTLS round-trips).
199    struct TestId {
200        identity: Identity,
201        ca_fp: String,
202        server_cert_pem: String,
203        server_key_pem: String,
204    }
205
206    fn test_identity() -> TestId {
207        let mut ca_params = CertificateParams::default();
208        ca_params
209            .distinguished_name
210            .push(DnType::CommonName, "Test CA");
211        ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
212        let ca_key = KeyPair::generate().unwrap();
213        let ca_cert = ca_params.self_signed(&ca_key).unwrap();
214        let ca_pem = ca_cert.pem();
215        let ca_fp =
216            koi_crypto::pinning::fingerprint_sha256(pem::parse(&ca_pem).unwrap().contents());
217
218        let mut leaf_params = CertificateParams::new(vec!["me.local".to_string()]).unwrap();
219        leaf_params
220            .subject_alt_names
221            .push(SanType::IpAddress(IpAddr::V4(Ipv4Addr::LOCALHOST)));
222        leaf_params
223            .distinguished_name
224            .push(DnType::CommonName, "me");
225        let leaf_key = KeyPair::generate().unwrap();
226        let leaf_cert = leaf_params.signed_by(&leaf_key, &ca_cert, &ca_key).unwrap();
227
228        // A server cert (SAN localhost + 127.0.0.1) for the listener side.
229        let mut s_params = CertificateParams::new(vec!["localhost".to_string()]).unwrap();
230        s_params
231            .subject_alt_names
232            .push(SanType::IpAddress(IpAddr::V4(Ipv4Addr::LOCALHOST)));
233        s_params
234            .distinguished_name
235            .push(DnType::CommonName, "test-server");
236        let s_key = KeyPair::generate().unwrap();
237        let s_cert = s_params.signed_by(&s_key, &ca_cert, &ca_key).unwrap();
238
239        let identity = Identity {
240            hostname: "me".to_string(),
241            cert_pem: leaf_cert.pem(),
242            key_pem: leaf_key.serialize_pem(),
243            ca_cert_pem: ca_pem,
244            ca_fingerprint: ca_fp.clone(),
245            renewal: crate::RenewalHealth {
246                expires_at: chrono::Utc::now() + chrono::Duration::days(30),
247                next_renewal_at: chrono::Utc::now() + chrono::Duration::days(20),
248                expires_in_days: 30,
249                renew_overdue: false,
250                expired: false,
251            },
252        };
253        TestId {
254            identity,
255            ca_fp,
256            server_cert_pem: s_cert.pem(),
257            server_key_pem: s_key.serialize_pem(),
258        }
259    }
260
261    fn peer_with(posture: Posture, fp: Option<&str>) -> Peer {
262        let mut txt = HashMap::new();
263        if let Some(fp) = fp {
264            txt.insert("fp".to_string(), fp.to_string());
265        }
266        koi_common::peer::stamp(&mut txt, posture, fp, None);
267        Peer::from_record(ServiceRecord {
268            name: "peer-01".to_string(),
269            service_type: "_http._tcp".to_string(),
270            host: Some("peer-01.local".to_string()),
271            ip: Some("127.0.0.1".to_string()),
272            port: Some(8443),
273            txt,
274        })
275    }
276
277    #[test]
278    fn open_peer_yields_plain_client_without_identity() {
279        let peer = peer_with(Posture::OPEN, None);
280        let client = select_client(&peer, None, "127.0.0.1".into(), 8080).unwrap();
281        assert!(!client.is_secure());
282        assert_eq!(client.host(), "127.0.0.1");
283        assert_eq!(client.port(), 8080);
284    }
285
286    #[test]
287    fn open_peer_is_plain_even_when_we_have_identity() {
288        let id = test_identity();
289        let peer = peer_with(Posture::OPEN, None);
290        let client = select_client(&peer, Some(&id.identity), "127.0.0.1".into(), 8080).unwrap();
291        assert!(!client.is_secure(), "an Open peer is dialed in plaintext");
292    }
293
294    #[test]
295    fn secure_peer_without_local_identity_errors_loudly() {
296        let peer = peer_with(Posture::new(true, false), Some("SOMEFP"));
297        let err = select_client(&peer, None, "127.0.0.1".into(), 8443).unwrap_err();
298        let msg = err.to_string();
299        assert!(msg.contains("requires authentication"), "got: {msg}");
300        assert!(
301            msg.contains("ensure_identity") || msg.contains("join"),
302            "got: {msg}"
303        );
304    }
305
306    #[test]
307    fn secure_peer_in_different_mesh_errors_loudly() {
308        let id = test_identity();
309        // Peer advertises a fingerprint that is not our CA.
310        let peer = peer_with(Posture::new(true, false), Some("DIFFERENT-MESH-FP"));
311        let err = select_client(&peer, Some(&id.identity), "127.0.0.1".into(), 8443).unwrap_err();
312        let msg = err.to_string();
313        assert!(msg.contains("different mesh"), "got: {msg}");
314    }
315
316    #[test]
317    fn secure_peer_same_mesh_yields_mtls_client() {
318        let id = test_identity();
319        let peer = peer_with(Posture::new(true, false), Some(&id.ca_fp));
320        let client = select_client(&peer, Some(&id.identity), "127.0.0.1".into(), 8443).unwrap();
321        assert!(client.is_secure(), "same-mesh secure peer → mTLS");
322    }
323
324    #[test]
325    fn secure_peer_fp_match_is_case_insensitive() {
326        let id = test_identity();
327        let upper = id.ca_fp.to_uppercase();
328        // Only meaningful if the fp has hex letters; still must not falsely reject.
329        let peer = peer_with(Posture::new(true, false), Some(&upper));
330        let client = select_client(&peer, Some(&id.identity), "127.0.0.1".into(), 8443);
331        assert!(client.is_ok(), "fp comparison must be case-insensitive");
332    }
333
334    #[test]
335    fn secure_peer_without_advertised_fp_still_builds_mtls() {
336        // No fp= advertised but posture=authenticated → trust our own pin and try.
337        let id = test_identity();
338        let mut txt = HashMap::new();
339        txt.insert("posture".to_string(), "authenticated".to_string());
340        let peer = Peer::from_record(ServiceRecord {
341            name: "peer-02".to_string(),
342            service_type: "_http._tcp".to_string(),
343            host: None,
344            ip: Some("127.0.0.1".to_string()),
345            port: Some(8443),
346            txt,
347        });
348        let client = select_client(&peer, Some(&id.identity), "127.0.0.1".into(), 8443);
349        assert!(client.unwrap().is_secure());
350    }
351
352    // ── live round-trips: the dispatch actually works over the wire ──────
353
354    #[tokio::test]
355    async fn live_mtls_round_trip_surfaces_our_cn() {
356        use crate::http::ClientCn;
357        use axum::extract::Extension;
358        use axum::routing::get as axum_get;
359        use axum::Router;
360        use tokio::net::TcpListener;
361        use tokio_util::sync::CancellationToken;
362
363        let id = test_identity();
364        let server_config = mtls::build_server_config(
365            &id.server_cert_pem,
366            &id.server_key_pem,
367            &id.identity.ca_cert_pem,
368        )
369        .unwrap();
370        let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
371        let addr = listener.local_addr().unwrap();
372        let router = Router::new().route(
373            "/cn",
374            axum_get(|Extension(ClientCn(cn)): Extension<ClientCn>| async move { cn }),
375        );
376        let cancel = CancellationToken::new();
377        let server = tokio::spawn(mtls::serve(router, listener, server_config, cancel.clone()));
378
379        let mut txt = HashMap::new();
380        koi_common::peer::stamp(&mut txt, Posture::new(true, false), Some(&id.ca_fp), None);
381        let peer = Peer::from_record(ServiceRecord {
382            name: "peer-01".into(),
383            service_type: "_http._tcp".into(),
384            host: None,
385            ip: Some("127.0.0.1".into()),
386            port: Some(addr.port()),
387            txt,
388        });
389
390        let client =
391            select_client(&peer, Some(&id.identity), "127.0.0.1".into(), addr.port()).unwrap();
392        assert!(client.is_secure(), "secure peer dialed over mTLS");
393        let (status, body) = client.get("/cn").await.expect("mTLS GET should succeed");
394        assert_eq!(status, 200);
395        assert_eq!(body, "me", "the server authenticated our leaf CN");
396
397        cancel.cancel();
398        let _ = server.await;
399    }
400
401    #[tokio::test]
402    async fn live_plain_round_trip_to_open_peer() {
403        use tokio::io::{AsyncReadExt, AsyncWriteExt};
404        use tokio::net::TcpListener;
405
406        let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
407        let addr = listener.local_addr().unwrap();
408        // A minimal one-shot HTTP/1.1 server (a plain peer); the client makes one
409        // `Connection: close` request, so a single accept suffices.
410        let server = tokio::spawn(async move {
411            if let Ok((mut sock, _)) = listener.accept().await {
412                let mut buf = [0u8; 1024];
413                let _ = sock.read(&mut buf).await;
414                let _ = sock
415                    .write_all(
416                        b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\nConnection: close\r\n\r\npong",
417                    )
418                    .await;
419                let _ = sock.flush().await;
420            }
421        });
422
423        let peer = peer_with(Posture::OPEN, None);
424        let client = select_client(&peer, None, "127.0.0.1".into(), addr.port()).unwrap();
425        assert!(!client.is_secure(), "open peer dialed in plaintext");
426        let (status, body) = client.get("/ping").await.expect("plain GET should succeed");
427        assert_eq!(status, 200);
428        assert_eq!(body, "pong");
429
430        let _ = server.await;
431    }
432}