Skip to main content

canton_core/
config.rs

1//! Shared client connection configuration (the Option-B connection kernel).
2//!
3//! Endpoint, authentication, TLS, and retry live here so every gRPC client in
4//! the SDK (`canton-ledger`, `canton-admin`) builds its channel the same way.
5//! Authentication is decoupled from any concrete provider via the
6//! [`TokenSource`] trait — `canton-auth`'s token provider implements it, which
7//! keeps `canton-core` free of a `canton-auth` dependency (that would be a
8//! cycle) while letting [`Config`] carry any token source.
9
10use std::fmt;
11use std::future::Future;
12use std::pin::Pin;
13use std::sync::Arc;
14use std::time::Duration;
15
16use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity};
17
18use crate::retry::RetryConfig;
19use crate::{Error, Result};
20
21/// A source of bearer tokens for request authentication.
22///
23/// Implemented by `canton_auth::TokenProvider` (OIDC client-credentials with
24/// caching + refresh). Object-safe by design: [`Auth::Dynamic`] stores it as an
25/// `Arc<dyn TokenSource>`.
26pub trait TokenSource: Send + Sync + fmt::Debug {
27    /// Resolve the current bearer token (fetching/refreshing as needed), or
28    /// `None` for an unauthenticated call.
29    fn fetch_bearer(&self) -> Pin<Box<dyn Future<Output = Result<Option<String>>> + Send + '_>>;
30}
31
32/// How the client authenticates each request.
33///
34/// `#[non_exhaustive]` so new auth modes can be added without a breaking change;
35/// construct via [`Config::with_token`] / [`Config::with_oidc`] (or match with a
36/// wildcard arm).
37#[derive(Clone)]
38#[non_exhaustive]
39pub enum Auth {
40    /// No authentication (unauthenticated endpoints, or shared-secret off).
41    None,
42    /// A fixed bearer token supplied by the caller.
43    Static(String),
44    /// A dynamic token source (e.g. OIDC client-credentials with auto-refresh).
45    Dynamic(Arc<dyn TokenSource>),
46}
47
48impl Auth {
49    /// Resolve the current bearer token, if any.
50    ///
51    /// # Errors
52    /// Propagates any error from the underlying [`TokenSource`].
53    pub async fn bearer(&self) -> Result<Option<String>> {
54        match self {
55            Auth::None => Ok(None),
56            Auth::Static(token) => Ok(Some(token.clone())),
57            Auth::Dynamic(source) => source.fetch_bearer().await,
58        }
59    }
60}
61
62impl fmt::Debug for Auth {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        match self {
65            Auth::None => f.write_str("None"),
66            Auth::Static(_) => f.write_str("Static(<redacted>)"),
67            Auth::Dynamic(source) => write!(f, "Dynamic({source:?})"),
68        }
69    }
70}
71
72/// TLS settings for the gRPC channel.
73///
74/// An empty `TlsConfig` (from [`TlsConfig::new`]) enables server-side TLS using
75/// the platform's native root certificates. Add a custom CA for private/self-
76/// signed servers, a domain-name override for SNI/verification, and a client
77/// identity for mutual TLS. `#[non_exhaustive]`.
78#[derive(Clone, Default)]
79#[non_exhaustive]
80pub struct TlsConfig {
81    /// Custom CA certificate chain (PEM). When set, replaces the native roots
82    /// (use for self-signed / private CAs).
83    pub ca_certificate_pem: Option<Vec<u8>>,
84    /// Domain name to verify the server certificate against (SNI). Defaults to
85    /// the endpoint host.
86    pub domain_name: Option<String>,
87    /// Client identity `(certificate_pem, private_key_pem)` for mutual TLS.
88    pub client_identity_pem: Option<(Vec<u8>, Vec<u8>)>,
89}
90
91/// Hand-written so PEM bytes never reach a log. The derived `Debug` printed
92/// `client_identity_pem` in full, which is the mutual-TLS **private key** — one
93/// `tracing` field capturing a `Config` was enough to put it in a log
94/// aggregator. Presence and length are what a reader debugging a handshake
95/// actually needs.
96impl std::fmt::Debug for TlsConfig {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        fn pem(bytes: Option<&Vec<u8>>) -> String {
99            bytes.map_or_else(|| "None".to_string(), |b| format!("<{} bytes>", b.len()))
100        }
101        f.debug_struct("TlsConfig")
102            .field("ca_certificate_pem", &pem(self.ca_certificate_pem.as_ref()))
103            .field("domain_name", &self.domain_name)
104            .field(
105                "client_identity_pem",
106                &self.client_identity_pem.as_ref().map_or_else(
107                    || "None".to_string(),
108                    |(cert, key)| {
109                        format!(
110                            "Some((<{} bytes>, <{} bytes, redacted>))",
111                            cert.len(),
112                            key.len()
113                        )
114                    },
115                ),
116            )
117            .finish()
118    }
119}
120
121impl TlsConfig {
122    /// Server-side TLS using the platform's native root certificates.
123    #[must_use]
124    pub fn new() -> Self {
125        Self::default()
126    }
127
128    /// Trust this PEM CA certificate (chain) instead of the native roots.
129    #[must_use]
130    pub fn with_ca_certificate(mut self, ca_pem: impl Into<Vec<u8>>) -> Self {
131        self.ca_certificate_pem = Some(ca_pem.into());
132        self
133    }
134
135    /// Override the domain name the server certificate is verified against.
136    #[must_use]
137    pub fn with_domain_name(mut self, domain: impl Into<String>) -> Self {
138        self.domain_name = Some(domain.into());
139        self
140    }
141
142    /// Present a client identity (mutual TLS): `(certificate_pem, key_pem)`.
143    #[must_use]
144    pub fn with_client_identity(
145        mut self,
146        certificate_pem: impl Into<Vec<u8>>,
147        private_key_pem: impl Into<Vec<u8>>,
148    ) -> Self {
149        self.client_identity_pem = Some((certificate_pem.into(), private_key_pem.into()));
150        self
151    }
152}
153
154/// Build a `ClientTlsConfig` from a [`TlsConfig`] (or native roots when TLS is
155/// implicit for an `https` endpoint).
156fn build_tls(tls: Option<&TlsConfig>) -> ClientTlsConfig {
157    let mut config = ClientTlsConfig::new();
158    match tls.and_then(|t| t.ca_certificate_pem.as_ref()) {
159        Some(ca) => config = config.ca_certificate(Certificate::from_pem(ca.clone())),
160        None => config = config.with_native_roots(),
161    }
162    if let Some(domain) = tls.and_then(|t| t.domain_name.as_ref()) {
163        config = config.domain_name(domain.clone());
164    }
165    if let Some((cert, key)) = tls.and_then(|t| t.client_identity_pem.as_ref()) {
166        config = config.identity(Identity::from_pem(cert.clone(), key.clone()));
167    }
168    config
169}
170
171/// Replace the userinfo of a URL with `***`, so a connection string can be put
172/// in an error message or a log line without carrying credentials with it.
173///
174/// `https://alice:s3cret@host:3901/x` becomes `https://***@host:3901/x`; a URL
175/// without userinfo is returned untouched, and so is anything that does not
176/// parse as one. Deliberately not a real URL parse: this is used on the failure
177/// path of *invalid* URIs, where a parser would have nothing to work with.
178#[must_use]
179pub fn redact_url(url: &str) -> std::borrow::Cow<'_, str> {
180    let Some(scheme_end) = url.find("://") else {
181        return std::borrow::Cow::Borrowed(url);
182    };
183    let authority_start = scheme_end + 3;
184    let authority_end = url[authority_start..]
185        .find(['/', '?', '#'])
186        .map_or(url.len(), |i| authority_start + i);
187    let authority = &url[authority_start..authority_end];
188    let Some(at) = authority.rfind('@') else {
189        return std::borrow::Cow::Borrowed(url);
190    };
191    std::borrow::Cow::Owned(format!(
192        "{}***{}",
193        &url[..authority_start],
194        &url[authority_start + at..]
195    ))
196}
197
198/// Configuration for an SDK gRPC client (shared by `canton-ledger` and
199/// `canton-admin`).
200#[derive(Clone)]
201pub struct Config {
202    endpoint: String,
203    auth: Auth,
204    retry: Option<RetryConfig>,
205    tls: Option<TlsConfig>,
206    timeout: Option<Duration>,
207    max_decoding_message_size: usize,
208}
209
210/// The largest gRPC response this SDK will decode, unless configured otherwise.
211///
212/// `tonic` defaults to 4 MiB, which a Ledger API client meets in ordinary use:
213/// one `GetActiveContractsPageResponse` carries a whole page, the participant
214/// permits page sizes up to 10 000, and a page of that size on a modest ledger
215/// is several times over the limit. It surfaces as a client-side
216/// `OUT_OF_RANGE` — "decoded message length too large" — which reads like a
217/// server fault and is not retriable, so the caller has nothing useful to do
218/// with it.
219///
220/// 128 MiB is a bound that a real page, transaction or created-event blob
221/// stays under while still refusing a response large enough to be a memory
222/// problem.
223pub const DEFAULT_MAX_DECODING_MESSAGE_SIZE: usize = 128 * 1024 * 1024;
224
225/// The most pages a paginated listing (`list_known_parties` on either
226/// transport) will follow before it is reported as not ending. At the
227/// participant's default page size that is far more parties than any
228/// participant hosts; a server that keeps minting page tokens is either
229/// broken or hostile, and either way the caller must not wait on it forever.
230pub const MAX_LIST_PAGES: usize = 10_000;
231
232/// Hand-written so the endpoint's userinfo is redacted. `auth` and `tls` redact
233/// themselves; the endpoint was the remaining way a credential reached a log
234/// through a `Config`, and a `Config` is exactly the thing an application
235/// attaches to a tracing span while debugging a connection.
236impl std::fmt::Debug for Config {
237    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
238        f.debug_struct("Config")
239            .field("endpoint", &redact_url(&self.endpoint))
240            .field("auth", &self.auth)
241            .field("retry", &self.retry)
242            .field("tls", &self.tls)
243            .field("timeout", &self.timeout)
244            .field("max_decoding_message_size", &self.max_decoding_message_size)
245            .finish()
246    }
247}
248
249impl Config {
250    /// Create a configuration targeting `endpoint`, with no authentication and
251    /// no retrying.
252    #[must_use]
253    pub fn new(endpoint: impl Into<String>) -> Self {
254        Self {
255            endpoint: endpoint.into(),
256            auth: Auth::None,
257            retry: None,
258            tls: None,
259            timeout: None,
260            max_decoding_message_size: DEFAULT_MAX_DECODING_MESSAGE_SIZE,
261        }
262    }
263
264    /// Authenticate with a fixed bearer token.
265    #[must_use]
266    pub fn with_token(mut self, token: impl Into<String>) -> Self {
267        self.auth = Auth::Static(token.into());
268        self
269    }
270
271    /// Authenticate with a dynamic token source (e.g. an OIDC provider with
272    /// client-credentials and auto-refresh).
273    #[must_use]
274    pub fn with_oidc<T: TokenSource + 'static>(mut self, provider: T) -> Self {
275        self.auth = Auth::Dynamic(Arc::new(provider));
276        self
277    }
278
279    /// Enable retrying of unary calls on retriable errors, per `retry`.
280    #[must_use]
281    pub fn with_retry(mut self, retry: RetryConfig) -> Self {
282        self.retry = Some(retry);
283        self
284    }
285
286    /// Use TLS for the gRPC channel (server-side and, if configured, mutual).
287    #[must_use]
288    pub fn with_tls(mut self, tls: TlsConfig) -> Self {
289        self.tls = Some(tls);
290        self
291    }
292
293    /// Set the per-request timeout on the gRPC channel (default 30s). Long
294    /// `submitAndWait` calls under load may need a higher bound.
295    #[must_use]
296    pub fn with_timeout(mut self, timeout: Duration) -> Self {
297        self.timeout = Some(timeout);
298        self
299    }
300
301    /// The largest gRPC response to decode, in bytes
302    /// (default [`DEFAULT_MAX_DECODING_MESSAGE_SIZE`], 128 MiB).
303    ///
304    /// Raise it for a participant that returns very large ACS pages or
305    /// transactions; lower it to bound the memory a single response can claim.
306    #[must_use]
307    pub fn with_max_decoding_message_size(mut self, bytes: usize) -> Self {
308        self.max_decoding_message_size = bytes;
309        self
310    }
311
312    /// The largest gRPC response this configuration will decode, in bytes.
313    #[must_use]
314    pub fn max_decoding_message_size(&self) -> usize {
315        self.max_decoding_message_size
316    }
317
318    /// The gRPC endpoint of the target service, e.g. `http://localhost:3901`.
319    #[must_use]
320    pub fn endpoint(&self) -> &str {
321        &self.endpoint
322    }
323
324    /// The configured authentication mode.
325    #[must_use]
326    pub fn auth(&self) -> &Auth {
327        &self.auth
328    }
329
330    /// The configured retry policy, if any.
331    #[must_use]
332    pub fn retry(&self) -> Option<&RetryConfig> {
333        self.retry.as_ref()
334    }
335
336    /// Build a lazily-connected gRPC [`Channel`] for this configuration.
337    ///
338    /// Returns immediately; the TCP/TLS handshake happens on the first RPC. TLS
339    /// is applied when [`Config::with_tls`] was set or the endpoint is `https`;
340    /// a `with_tls` endpoint given as `http://` is normalised to `https://` so
341    /// TLS is never silently downgraded (see the private `resolve_endpoint`).
342    ///
343    /// # Errors
344    /// Returns [`Error::InvalidRequest`] if the endpoint URI or the TLS
345    /// configuration is invalid.
346    pub fn connect_channel(&self) -> Result<Channel> {
347        let (uri, want_tls) = resolve_endpoint(&self.endpoint, self.tls.is_some());
348        let mut endpoint = Endpoint::from_shared(uri.clone())
349            .map_err(|e| {
350                Error::InvalidRequest(format!("invalid endpoint uri {}: {e}", redact_url(&uri)))
351            })?
352            .timeout(self.timeout.unwrap_or(Duration::from_secs(30)))
353            .connect_timeout(Duration::from_secs(10))
354            .http2_keep_alive_interval(Duration::from_secs(30))
355            .keep_alive_timeout(Duration::from_secs(20))
356            .keep_alive_while_idle(true)
357            .tcp_keepalive(Some(Duration::from_secs(60)))
358            .tcp_nodelay(true);
359
360        if want_tls {
361            endpoint = endpoint
362                .tls_config(build_tls(self.tls.as_ref()))
363                .map_err(|e| {
364                    Error::InvalidRequest(format!("invalid TLS config: {}", crate::chain(&e)))
365                })?;
366        }
367
368        Ok(endpoint.connect_lazy())
369    }
370}
371
372/// Resolve the effective endpoint URI and whether TLS should be attached.
373///
374/// `tonic` gates the TLS handshake on the URI **scheme**, not on the presence of
375/// a `tls_config` — so attaching TLS to an `http://` endpoint is silently ignored
376/// and the connection runs in plaintext (no encryption, no server-cert
377/// verification, no client cert). When TLS is configured — or the endpoint is
378/// already `https` — the scheme is normalised to `https` so TLS is actually
379/// applied. Scheme detection is case-insensitive (tonic lowercases the parsed
380/// scheme, so `HTTPS://…` must be treated as `https`).
381fn resolve_endpoint(endpoint: &str, tls_configured: bool) -> (String, bool) {
382    // A bare `host:port` is what a gRPC client dials, so it is what tooling
383    // hands out — canton-devkit's `CANTON_GRPC_LEDGER_API_URL` is exactly this
384    // shape. `Endpoint::from_shared` needs a scheme, and without one the
385    // failure arrives at connect time as an unexplained transport error, so
386    // supply the scheme the rest of this function would have chosen anyway.
387    if !endpoint.contains("://") {
388        let scheme = if tls_configured { "https" } else { "http" };
389        return (format!("{scheme}://{endpoint}"), tls_configured);
390    }
391
392    let is_https = endpoint
393        .get(..8)
394        .is_some_and(|s| s.eq_ignore_ascii_case("https://"));
395    let is_http = endpoint
396        .get(..7)
397        .is_some_and(|s| s.eq_ignore_ascii_case("http://"));
398    let want_tls = tls_configured || is_https;
399    if want_tls && is_http {
400        (format!("https://{}", &endpoint[7..]), true)
401    } else {
402        (endpoint.to_string(), want_tls)
403    }
404}
405
406#[cfg(test)]
407mod decode_limit_tests {
408    use super::*;
409
410    #[test]
411    fn the_default_is_far_above_what_a_real_page_needs() {
412        // tonic's own default is 4 MiB. A `GetActiveContractsPageResponse`
413        // carries a whole page in one message and the participant allows page
414        // sizes up to 10 000, so the default is reachable in ordinary use —
415        // measured against a live 3.5.7 participant, 627 trivial contracts with
416        // created-event blobs already fill a quarter of it.
417        assert_eq!(DEFAULT_MAX_DECODING_MESSAGE_SIZE, 128 * 1024 * 1024);
418        const { assert!(DEFAULT_MAX_DECODING_MESSAGE_SIZE > 4 * 1024 * 1024) };
419        assert_eq!(
420            Config::new("http://localhost:3901").max_decoding_message_size(),
421            DEFAULT_MAX_DECODING_MESSAGE_SIZE
422        );
423    }
424
425    #[test]
426    fn the_limit_is_configurable_in_both_directions() {
427        // Raise it for a participant returning very large pages…
428        let big = Config::new("http://x").with_max_decoding_message_size(512 * 1024 * 1024);
429        assert_eq!(big.max_decoding_message_size(), 512 * 1024 * 1024);
430        // …or lower it to bound what one response can claim.
431        let small = Config::new("http://x").with_max_decoding_message_size(1024);
432        assert_eq!(small.max_decoding_message_size(), 1024);
433    }
434}
435
436#[cfg(test)]
437#[allow(clippy::unwrap_used, clippy::expect_used)]
438mod redaction_tests {
439    use super::*;
440
441    const KEY: &[u8] = b"-----BEGIN PRIVATE KEY-----SUPERSECRET-----END PRIVATE KEY-----";
442
443    /// The mutual-TLS private key must never reach a log. The derived `Debug`
444    /// printed it byte by byte, and a `Config` is exactly what an application
445    /// attaches to a span while debugging a handshake.
446    #[test]
447    fn debug_never_prints_key_material() {
448        let tls = TlsConfig::new().with_client_identity(b"certbytes".to_vec(), KEY.to_vec());
449        let rendered = format!("{tls:?}");
450
451        // 'S','U','P' as Debug-formatted bytes, which is how the leak looked.
452        assert!(
453            !rendered.contains("83, 85, 80"),
454            "key bytes leaked: {rendered}"
455        );
456        assert!(!rendered.contains("PRIVATE KEY"), "{rendered}");
457        // What a reader debugging a handshake actually needs is still there.
458        assert!(rendered.contains("redacted"), "{rendered}");
459        assert!(
460            rendered.contains(&format!("{} bytes", KEY.len())),
461            "{rendered}"
462        );
463
464        // And through a Config, which is the realistic path.
465        let cfg = Config::new("https://host:3901").with_tls(tls);
466        let rendered = format!("{cfg:?}");
467        assert!(
468            !rendered.contains("83, 85, 80"),
469            "leaked via Config: {rendered}"
470        );
471    }
472
473    #[test]
474    fn debug_redacts_credentials_in_the_endpoint() {
475        let cfg = Config::new("https://alice:s3cr3t@localhost:3901");
476        let rendered = format!("{cfg:?}");
477        assert!(!rendered.contains("s3cr3t"), "{rendered}");
478        assert!(!rendered.contains("alice"), "{rendered}");
479        assert!(
480            rendered.contains("localhost:3901"),
481            "host must survive: {rendered}"
482        );
483    }
484
485    #[test]
486    fn redact_url_keeps_everything_that_is_not_a_credential() {
487        // Redacted.
488        assert_eq!(redact_url("https://u:p@h:1/x?q=1"), "https://***@h:1/x?q=1");
489        assert_eq!(redact_url("ws://tok@h/v2/updates"), "ws://***@h/v2/updates");
490        // An '@' later in the path is not userinfo.
491        assert_eq!(redact_url("https://h/a@b"), "https://h/a@b");
492        // Untouched: no userinfo, no scheme, empty, and the malformed input
493        // this is most likely to meet — it runs on the *invalid*-URI path.
494        for url in [
495            "https://localhost:3901",
496            "http://kc:8082/realms/AppProvider/protocol/openid-connect/token",
497            "not a url at all",
498            "://",
499            "",
500        ] {
501            assert_eq!(redact_url(url), url, "should be untouched: {url}");
502        }
503    }
504}
505
506#[cfg(test)]
507#[allow(clippy::bool_assert_comparison)]
508mod tests {
509    use super::resolve_endpoint;
510
511    #[test]
512    fn with_tls_on_http_endpoint_is_upgraded_to_https() {
513        // The security bug: `with_tls` on an http:// endpoint would otherwise
514        // connect in plaintext. It must become https so TLS is applied.
515        let (uri, tls) = resolve_endpoint("http://host:5001", true);
516        assert_eq!(uri, "https://host:5001");
517        assert_eq!(tls, true);
518    }
519
520    #[test]
521    fn https_scheme_detection_is_case_insensitive() {
522        // `HTTPS://` (no explicit with_tls) must still get TLS — tonic sees the
523        // lowercased scheme and would otherwise error on an https URI with no TLS.
524        let (uri, tls) = resolve_endpoint("HTTPS://host:443", false);
525        assert_eq!(uri, "HTTPS://host:443");
526        assert_eq!(tls, true);
527    }
528
529    #[test]
530    fn plain_http_without_tls_stays_plaintext() {
531        let (uri, tls) = resolve_endpoint("http://host:3901", false);
532        assert_eq!(uri, "http://host:3901");
533        assert_eq!(tls, false);
534    }
535
536    #[test]
537    fn https_without_explicit_tls_wants_tls() {
538        let (uri, tls) = resolve_endpoint("https://host:443", false);
539        assert_eq!(uri, "https://host:443");
540        assert_eq!(tls, true);
541    }
542
543    #[test]
544    fn uppercase_http_with_tls_is_upgraded() {
545        let (uri, tls) = resolve_endpoint("HTTP://host:5001", true);
546        assert_eq!(uri, "https://host:5001");
547        assert_eq!(tls, true);
548    }
549
550    /// A bare `host:port` is what a gRPC client dials, so it is the form
551    /// tooling hands out — `canton-devkit localnet env` exports
552    /// `CANTON_GRPC_LEDGER_API_URL` exactly this way. `Endpoint::from_shared`
553    /// needs a scheme, and without one nothing complains until the first RPC
554    /// fails as an unexplained transport error.
555    #[test]
556    fn a_scheme_less_host_and_port_gets_the_scheme_it_implies() {
557        let (uri, tls) =
558            resolve_endpoint("grpc-ledger-api.app-provider.demo.localhost:3901", false);
559        assert_eq!(
560            uri,
561            "http://grpc-ledger-api.app-provider.demo.localhost:3901"
562        );
563        assert_eq!(tls, false);
564
565        // With TLS configured it is the same choice the rest of this function
566        // makes: never plaintext when the caller asked for TLS.
567        let (uri, tls) = resolve_endpoint("ledger.example:443", true);
568        assert_eq!(uri, "https://ledger.example:443");
569        assert_eq!(tls, true);
570
571        // An IPv6 literal has colons of its own and still is not a scheme.
572        let (uri, _) = resolve_endpoint("[::1]:3901", false);
573        assert_eq!(uri, "http://[::1]:3901");
574    }
575}