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/// Hand-written so the endpoint's userinfo is redacted. `auth` and `tls` redact
226/// themselves; the endpoint was the remaining way a credential reached a log
227/// through a `Config`, and a `Config` is exactly the thing an application
228/// attaches to a tracing span while debugging a connection.
229impl std::fmt::Debug for Config {
230    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
231        f.debug_struct("Config")
232            .field("endpoint", &redact_url(&self.endpoint))
233            .field("auth", &self.auth)
234            .field("retry", &self.retry)
235            .field("tls", &self.tls)
236            .field("timeout", &self.timeout)
237            .field("max_decoding_message_size", &self.max_decoding_message_size)
238            .finish()
239    }
240}
241
242impl Config {
243    /// Create a configuration targeting `endpoint`, with no authentication and
244    /// no retrying.
245    #[must_use]
246    pub fn new(endpoint: impl Into<String>) -> Self {
247        Self {
248            endpoint: endpoint.into(),
249            auth: Auth::None,
250            retry: None,
251            tls: None,
252            timeout: None,
253            max_decoding_message_size: DEFAULT_MAX_DECODING_MESSAGE_SIZE,
254        }
255    }
256
257    /// Authenticate with a fixed bearer token.
258    #[must_use]
259    pub fn with_token(mut self, token: impl Into<String>) -> Self {
260        self.auth = Auth::Static(token.into());
261        self
262    }
263
264    /// Authenticate with a dynamic token source (e.g. an OIDC provider with
265    /// client-credentials and auto-refresh).
266    #[must_use]
267    pub fn with_oidc<T: TokenSource + 'static>(mut self, provider: T) -> Self {
268        self.auth = Auth::Dynamic(Arc::new(provider));
269        self
270    }
271
272    /// Enable retrying of unary calls on retriable errors, per `retry`.
273    #[must_use]
274    pub fn with_retry(mut self, retry: RetryConfig) -> Self {
275        self.retry = Some(retry);
276        self
277    }
278
279    /// Use TLS for the gRPC channel (server-side and, if configured, mutual).
280    #[must_use]
281    pub fn with_tls(mut self, tls: TlsConfig) -> Self {
282        self.tls = Some(tls);
283        self
284    }
285
286    /// Set the per-request timeout on the gRPC channel (default 30s). Long
287    /// `submitAndWait` calls under load may need a higher bound.
288    #[must_use]
289    pub fn with_timeout(mut self, timeout: Duration) -> Self {
290        self.timeout = Some(timeout);
291        self
292    }
293
294    /// The largest gRPC response to decode, in bytes
295    /// (default [`DEFAULT_MAX_DECODING_MESSAGE_SIZE`], 128 MiB).
296    ///
297    /// Raise it for a participant that returns very large ACS pages or
298    /// transactions; lower it to bound the memory a single response can claim.
299    #[must_use]
300    pub fn with_max_decoding_message_size(mut self, bytes: usize) -> Self {
301        self.max_decoding_message_size = bytes;
302        self
303    }
304
305    /// The largest gRPC response this configuration will decode, in bytes.
306    #[must_use]
307    pub fn max_decoding_message_size(&self) -> usize {
308        self.max_decoding_message_size
309    }
310
311    /// The gRPC endpoint of the target service, e.g. `http://localhost:3901`.
312    #[must_use]
313    pub fn endpoint(&self) -> &str {
314        &self.endpoint
315    }
316
317    /// The configured authentication mode.
318    #[must_use]
319    pub fn auth(&self) -> &Auth {
320        &self.auth
321    }
322
323    /// The configured retry policy, if any.
324    #[must_use]
325    pub fn retry(&self) -> Option<&RetryConfig> {
326        self.retry.as_ref()
327    }
328
329    /// Build a lazily-connected gRPC [`Channel`] for this configuration.
330    ///
331    /// Returns immediately; the TCP/TLS handshake happens on the first RPC. TLS
332    /// is applied when [`Config::with_tls`] was set or the endpoint is `https`;
333    /// a `with_tls` endpoint given as `http://` is normalised to `https://` so
334    /// TLS is never silently downgraded (see the private `resolve_endpoint`).
335    ///
336    /// # Errors
337    /// Returns [`Error::InvalidRequest`] if the endpoint URI or the TLS
338    /// configuration is invalid.
339    pub fn connect_channel(&self) -> Result<Channel> {
340        let (uri, want_tls) = resolve_endpoint(&self.endpoint, self.tls.is_some());
341        let mut endpoint = Endpoint::from_shared(uri.clone())
342            .map_err(|e| {
343                Error::InvalidRequest(format!("invalid endpoint uri {}: {e}", redact_url(&uri)))
344            })?
345            .timeout(self.timeout.unwrap_or(Duration::from_secs(30)))
346            .connect_timeout(Duration::from_secs(10))
347            .http2_keep_alive_interval(Duration::from_secs(30))
348            .keep_alive_timeout(Duration::from_secs(20))
349            .keep_alive_while_idle(true)
350            .tcp_keepalive(Some(Duration::from_secs(60)))
351            .tcp_nodelay(true);
352
353        if want_tls {
354            endpoint = endpoint
355                .tls_config(build_tls(self.tls.as_ref()))
356                .map_err(|e| Error::InvalidRequest(format!("invalid TLS config: {e}")))?;
357        }
358
359        Ok(endpoint.connect_lazy())
360    }
361}
362
363/// Resolve the effective endpoint URI and whether TLS should be attached.
364///
365/// `tonic` gates the TLS handshake on the URI **scheme**, not on the presence of
366/// a `tls_config` — so attaching TLS to an `http://` endpoint is silently ignored
367/// and the connection runs in plaintext (no encryption, no server-cert
368/// verification, no client cert). When TLS is configured — or the endpoint is
369/// already `https` — the scheme is normalised to `https` so TLS is actually
370/// applied. Scheme detection is case-insensitive (tonic lowercases the parsed
371/// scheme, so `HTTPS://…` must be treated as `https`).
372fn resolve_endpoint(endpoint: &str, tls_configured: bool) -> (String, bool) {
373    // A bare `host:port` is what a gRPC client dials, so it is what tooling
374    // hands out — canton-devkit's `CANTON_GRPC_LEDGER_API_URL` is exactly this
375    // shape. `Endpoint::from_shared` needs a scheme, and without one the
376    // failure arrives at connect time as an unexplained transport error, so
377    // supply the scheme the rest of this function would have chosen anyway.
378    if !endpoint.contains("://") {
379        let scheme = if tls_configured { "https" } else { "http" };
380        return (format!("{scheme}://{endpoint}"), tls_configured);
381    }
382
383    let is_https = endpoint
384        .get(..8)
385        .is_some_and(|s| s.eq_ignore_ascii_case("https://"));
386    let is_http = endpoint
387        .get(..7)
388        .is_some_and(|s| s.eq_ignore_ascii_case("http://"));
389    let want_tls = tls_configured || is_https;
390    if want_tls && is_http {
391        (format!("https://{}", &endpoint[7..]), true)
392    } else {
393        (endpoint.to_string(), want_tls)
394    }
395}
396
397#[cfg(test)]
398mod decode_limit_tests {
399    use super::*;
400
401    #[test]
402    fn the_default_is_far_above_what_a_real_page_needs() {
403        // tonic's own default is 4 MiB. A `GetActiveContractsPageResponse`
404        // carries a whole page in one message and the participant allows page
405        // sizes up to 10 000, so the default is reachable in ordinary use —
406        // measured against a live 3.5.7 participant, 627 trivial contracts with
407        // created-event blobs already fill a quarter of it.
408        assert_eq!(DEFAULT_MAX_DECODING_MESSAGE_SIZE, 128 * 1024 * 1024);
409        const { assert!(DEFAULT_MAX_DECODING_MESSAGE_SIZE > 4 * 1024 * 1024) };
410        assert_eq!(
411            Config::new("http://localhost:3901").max_decoding_message_size(),
412            DEFAULT_MAX_DECODING_MESSAGE_SIZE
413        );
414    }
415
416    #[test]
417    fn the_limit_is_configurable_in_both_directions() {
418        // Raise it for a participant returning very large pages…
419        let big = Config::new("http://x").with_max_decoding_message_size(512 * 1024 * 1024);
420        assert_eq!(big.max_decoding_message_size(), 512 * 1024 * 1024);
421        // …or lower it to bound what one response can claim.
422        let small = Config::new("http://x").with_max_decoding_message_size(1024);
423        assert_eq!(small.max_decoding_message_size(), 1024);
424    }
425}
426
427#[cfg(test)]
428#[allow(clippy::unwrap_used, clippy::expect_used)]
429mod redaction_tests {
430    use super::*;
431
432    const KEY: &[u8] = b"-----BEGIN PRIVATE KEY-----SUPERSECRET-----END PRIVATE KEY-----";
433
434    /// The mutual-TLS private key must never reach a log. The derived `Debug`
435    /// printed it byte by byte, and a `Config` is exactly what an application
436    /// attaches to a span while debugging a handshake.
437    #[test]
438    fn debug_never_prints_key_material() {
439        let tls = TlsConfig::new().with_client_identity(b"certbytes".to_vec(), KEY.to_vec());
440        let rendered = format!("{tls:?}");
441
442        // 'S','U','P' as Debug-formatted bytes, which is how the leak looked.
443        assert!(
444            !rendered.contains("83, 85, 80"),
445            "key bytes leaked: {rendered}"
446        );
447        assert!(!rendered.contains("PRIVATE KEY"), "{rendered}");
448        // What a reader debugging a handshake actually needs is still there.
449        assert!(rendered.contains("redacted"), "{rendered}");
450        assert!(
451            rendered.contains(&format!("{} bytes", KEY.len())),
452            "{rendered}"
453        );
454
455        // And through a Config, which is the realistic path.
456        let cfg = Config::new("https://host:3901").with_tls(tls);
457        let rendered = format!("{cfg:?}");
458        assert!(
459            !rendered.contains("83, 85, 80"),
460            "leaked via Config: {rendered}"
461        );
462    }
463
464    #[test]
465    fn debug_redacts_credentials_in_the_endpoint() {
466        let cfg = Config::new("https://alice:s3cr3t@localhost:3901");
467        let rendered = format!("{cfg:?}");
468        assert!(!rendered.contains("s3cr3t"), "{rendered}");
469        assert!(!rendered.contains("alice"), "{rendered}");
470        assert!(
471            rendered.contains("localhost:3901"),
472            "host must survive: {rendered}"
473        );
474    }
475
476    #[test]
477    fn redact_url_keeps_everything_that_is_not_a_credential() {
478        // Redacted.
479        assert_eq!(redact_url("https://u:p@h:1/x?q=1"), "https://***@h:1/x?q=1");
480        assert_eq!(redact_url("ws://tok@h/v2/updates"), "ws://***@h/v2/updates");
481        // An '@' later in the path is not userinfo.
482        assert_eq!(redact_url("https://h/a@b"), "https://h/a@b");
483        // Untouched: no userinfo, no scheme, empty, and the malformed input
484        // this is most likely to meet — it runs on the *invalid*-URI path.
485        for url in [
486            "https://localhost:3901",
487            "http://kc:8082/realms/AppProvider/protocol/openid-connect/token",
488            "not a url at all",
489            "://",
490            "",
491        ] {
492            assert_eq!(redact_url(url), url, "should be untouched: {url}");
493        }
494    }
495}
496
497#[cfg(test)]
498#[allow(clippy::bool_assert_comparison)]
499mod tests {
500    use super::resolve_endpoint;
501
502    #[test]
503    fn with_tls_on_http_endpoint_is_upgraded_to_https() {
504        // The security bug: `with_tls` on an http:// endpoint would otherwise
505        // connect in plaintext. It must become https so TLS is applied.
506        let (uri, tls) = resolve_endpoint("http://host:5001", true);
507        assert_eq!(uri, "https://host:5001");
508        assert_eq!(tls, true);
509    }
510
511    #[test]
512    fn https_scheme_detection_is_case_insensitive() {
513        // `HTTPS://` (no explicit with_tls) must still get TLS — tonic sees the
514        // lowercased scheme and would otherwise error on an https URI with no TLS.
515        let (uri, tls) = resolve_endpoint("HTTPS://host:443", false);
516        assert_eq!(uri, "HTTPS://host:443");
517        assert_eq!(tls, true);
518    }
519
520    #[test]
521    fn plain_http_without_tls_stays_plaintext() {
522        let (uri, tls) = resolve_endpoint("http://host:3901", false);
523        assert_eq!(uri, "http://host:3901");
524        assert_eq!(tls, false);
525    }
526
527    #[test]
528    fn https_without_explicit_tls_wants_tls() {
529        let (uri, tls) = resolve_endpoint("https://host:443", false);
530        assert_eq!(uri, "https://host:443");
531        assert_eq!(tls, true);
532    }
533
534    #[test]
535    fn uppercase_http_with_tls_is_upgraded() {
536        let (uri, tls) = resolve_endpoint("HTTP://host:5001", true);
537        assert_eq!(uri, "https://host:5001");
538        assert_eq!(tls, true);
539    }
540
541    /// A bare `host:port` is what a gRPC client dials, so it is the form
542    /// tooling hands out — `canton-devkit localnet env` exports
543    /// `CANTON_GRPC_LEDGER_API_URL` exactly this way. `Endpoint::from_shared`
544    /// needs a scheme, and without one nothing complains until the first RPC
545    /// fails as an unexplained transport error.
546    #[test]
547    fn a_scheme_less_host_and_port_gets_the_scheme_it_implies() {
548        let (uri, tls) =
549            resolve_endpoint("grpc-ledger-api.app-provider.demo.localhost:3901", false);
550        assert_eq!(
551            uri,
552            "http://grpc-ledger-api.app-provider.demo.localhost:3901"
553        );
554        assert_eq!(tls, false);
555
556        // With TLS configured it is the same choice the rest of this function
557        // makes: never plaintext when the caller asked for TLS.
558        let (uri, tls) = resolve_endpoint("ledger.example:443", true);
559        assert_eq!(uri, "https://ledger.example:443");
560        assert_eq!(tls, true);
561
562        // An IPv6 literal has colons of its own and still is not a scheme.
563        let (uri, _) = resolve_endpoint("[::1]:3901", false);
564        assert_eq!(uri, "http://[::1]:3901");
565    }
566}