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