Skip to main content

zerodds_bridge_security/
ctx.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! Daemon-facing convenience: `SecurityConfig` (CLI/YAML surface) →
5//! [`SecurityCtx`] (resolved). Used identically by all six bridge
6//! daemons — the only difference is the connection path into which the
7//! ctx is hooked.
8
9use std::collections::HashMap;
10use std::path::PathBuf;
11use std::sync::Arc;
12
13use crate::acl::{Acl, AclEntry, AclOp};
14use crate::auth::{AuthError, AuthInput, AuthMode, AuthSubject};
15use crate::tls::{TlsConfigError, load_server_config, load_server_config_with_client_auth};
16
17/// The security context a running daemon checks against, replaced as a
18/// whole by a SIGHUP reload (the bridge specs' §9.2: TLS material and ACL
19/// hot-update). Every check reads [`LiveSecurity::current`]; a connection
20/// accepted after a reload gets the new TLS configuration, one already open
21/// keeps its session.
22#[derive(Debug)]
23pub struct LiveSecurity(std::sync::RwLock<Arc<SecurityCtx>>);
24
25impl LiveSecurity {
26    /// Starts with `ctx`.
27    #[must_use]
28    pub fn new(ctx: SecurityCtx) -> Self {
29        Self(std::sync::RwLock::new(Arc::new(ctx)))
30    }
31
32    /// The context as it is now.
33    #[must_use]
34    pub fn current(&self) -> Arc<SecurityCtx> {
35        match self.0.read() {
36            Ok(g) => Arc::clone(&g),
37            Err(p) => Arc::clone(&p.into_inner()),
38        }
39    }
40
41    /// Replaces the context.
42    pub fn replace(&self, ctx: SecurityCtx) {
43        match self.0.write() {
44            Ok(mut g) => *g = Arc::new(ctx),
45            Err(p) => *p.into_inner() = Arc::new(ctx),
46        }
47    }
48}
49
50/// Resolved security config — the output of this layer.
51#[derive(Clone)]
52pub struct SecurityCtx {
53    /// `Some(...)` ⇒ TLS active; rustls ServerConfig (with or without mTLS).
54    pub tls: Option<Arc<rustls::ServerConfig>>,
55    /// Auth mode (none/bearer/jwt/mtls/sasl).
56    pub auth: Arc<AuthMode>,
57    /// Topic ACL.
58    pub acl: Arc<Acl>,
59}
60
61impl core::fmt::Debug for SecurityCtx {
62    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
63        f.debug_struct("SecurityCtx")
64            .field("tls_enabled", &self.tls.is_some())
65            .field("auth", &self.auth)
66            .finish_non_exhaustive()
67    }
68}
69
70/// Raw config from YAML/CLI.
71#[derive(Debug, Clone, Default)]
72pub struct SecurityConfig {
73    /// PEM cert path (`--tls-cert`).
74    pub tls_cert: Option<PathBuf>,
75    /// PEM key path (`--tls-key`).
76    pub tls_key: Option<PathBuf>,
77    /// PEM CA bundle for mTLS client cert validation (`--client-ca`).
78    pub client_ca: Option<PathBuf>,
79    /// Auth mode string (`none|bearer|jwt|mtls|sasl`).
80    pub auth_mode: String,
81    /// Bearer tokens as a map `token → subject name`.
82    pub bearer_tokens: HashMap<String, String>,
83    /// JWT RSA public key (PKCS#1 DER).
84    pub jwt_pubkey_der: Option<Vec<u8>>,
85    /// JWT expected `iss` claim.
86    pub jwt_expected_iss: Option<String>,
87    /// JWT expected `aud` claim.
88    pub jwt_expected_aud: Option<String>,
89    /// JWT `(claim, value)` the token has to carry.
90    pub jwt_required_claim: Option<(String, String)>,
91    /// SASL-PLAIN: `user → password` map (for AMQP/MQTT).
92    pub sasl_users: HashMap<String, String>,
93    /// ACL per topic name.
94    pub topic_acl: HashMap<String, AclEntry>,
95    /// ACL default entry for unknown topics. `None` = deny-by-default.
96    pub topic_acl_default: Option<AclEntry>,
97}
98
99/// The `auth.jwt` block of a daemon's configuration (bridge specs §3):
100/// the RS256 key as a PEM file and the claim checks.
101#[derive(Debug, Clone, Default, PartialEq, Eq)]
102pub struct JwtSettings {
103    /// `auth.jwt.public_key` — PEM, PKCS#1 or SubjectPublicKeyInfo.
104    pub public_key: String,
105    /// `auth.jwt.issuer` — the `iss` claim.
106    pub issuer: Option<String>,
107    /// `auth.jwt.audience` — the `aud` claim.
108    pub audience: Option<String>,
109    /// `auth.jwt.required_claim` — `claim=value`.
110    pub required_claim: Option<String>,
111}
112
113impl SecurityConfig {
114    /// Reads the key `jwt` names and sets the JWT fields.
115    ///
116    /// # Errors
117    /// [`SecurityError::Config`] naming the `auth.jwt.*` key at fault.
118    pub fn apply_jwt(&mut self, jwt: &JwtSettings) -> Result<(), SecurityError> {
119        if jwt.public_key.is_empty() {
120            return Err(SecurityError::Config(
121                "auth.mode: jwt needs auth.jwt.public_key".into(),
122            ));
123        }
124        let pem = std::fs::read_to_string(&jwt.public_key).map_err(|e| {
125            SecurityError::Config(format!("auth.jwt.public_key {}: {e}", jwt.public_key))
126        })?;
127        self.jwt_pubkey_der = Some(
128            crate::auth::jwt_public_key_from_pem(&pem)
129                .map_err(|e| SecurityError::Config(format!("auth.jwt.public_key: {e}")))?,
130        );
131        self.jwt_expected_iss = jwt.issuer.clone();
132        self.jwt_expected_aud = jwt.audience.clone();
133        self.jwt_required_claim = match &jwt.required_claim {
134            None => None,
135            Some(rc) => {
136                let (k, v) = rc.split_once('=').ok_or_else(|| {
137                    SecurityError::Config(format!(
138                        "auth.jwt.required_claim {rc}: expected claim=value"
139                    ))
140                })?;
141                Some((k.trim().to_string(), v.trim().to_string()))
142            }
143        };
144        Ok(())
145    }
146}
147
148/// Setup error.
149#[derive(Debug)]
150pub enum SecurityError {
151    /// Cert/key loader reports an error.
152    Tls(TlsConfigError),
153    /// Auth mode string unknown.
154    UnknownAuthMode(String),
155    /// Auth mode requires input that is missing.
156    MissingAuthInput(&'static str),
157    /// A setting that names the key it comes from, such as
158    /// `auth.jwt.public_key` pointing at a file that is not a key.
159    Config(String),
160}
161
162impl core::fmt::Display for SecurityError {
163    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
164        match self {
165            Self::Tls(e) => write!(f, "tls: {e}"),
166            Self::UnknownAuthMode(m) => write!(f, "unknown auth-mode: {m}"),
167            Self::MissingAuthInput(s) => write!(f, "missing auth input: {s}"),
168            Self::Config(m) => f.write_str(m),
169        }
170    }
171}
172
173impl std::error::Error for SecurityError {}
174
175impl From<TlsConfigError> for SecurityError {
176    fn from(e: TlsConfigError) -> Self {
177        Self::Tls(e)
178    }
179}
180
181/// Builds [`SecurityCtx`] from [`SecurityConfig`]. Called at daemon
182/// start and on SIGHUP reload.
183///
184/// # Errors
185/// [`SecurityError`] on TLS config error or unknown auth mode.
186pub fn build_ctx(cfg: &SecurityConfig) -> Result<SecurityCtx, SecurityError> {
187    let tls = match (&cfg.tls_cert, &cfg.tls_key) {
188        (Some(c), Some(k)) => match &cfg.client_ca {
189            Some(ca) => Some(load_server_config_with_client_auth(c, k, ca)?),
190            None => Some(load_server_config(c, k)?),
191        },
192        (None, None) => None,
193        _ => {
194            return Err(SecurityError::Tls(TlsConfigError::Rustls(
195                "tls_cert and tls_key must be set together".to_string(),
196            )));
197        }
198    };
199
200    let auth = match cfg.auth_mode.as_str() {
201        "" | "none" => AuthMode::None,
202        "bearer" => {
203            let mut tokens: HashMap<String, AuthSubject> = HashMap::new();
204            for (tok, name) in &cfg.bearer_tokens {
205                tokens.insert(tok.clone(), AuthSubject::new(name.clone()));
206            }
207            AuthMode::Bearer { tokens }
208        }
209        "jwt" => {
210            let der = cfg
211                .jwt_pubkey_der
212                .clone()
213                .ok_or(SecurityError::MissingAuthInput("jwt_pubkey_der"))?;
214            AuthMode::Jwt {
215                pkcs1_pubkey_der: der,
216                expected_issuer: cfg.jwt_expected_iss.clone(),
217                expected_audience: cfg.jwt_expected_aud.clone(),
218                required_claim: cfg.jwt_required_claim.clone(),
219            }
220        }
221        "mtls" => AuthMode::Mtls,
222        "sasl" => {
223            if cfg.sasl_users.is_empty() {
224                return Err(SecurityError::MissingAuthInput("sasl_users"));
225            }
226            AuthMode::SaslPlain {
227                users: cfg.sasl_users.clone(),
228            }
229        }
230        other => return Err(SecurityError::UnknownAuthMode(other.to_string())),
231    };
232
233    let mut acl = if cfg.topic_acl.is_empty() && cfg.topic_acl_default.is_none() {
234        if matches!(auth, AuthMode::None) {
235            Acl::allow_all()
236        } else {
237            Acl::deny_all()
238        }
239    } else {
240        Acl::deny_all()
241    };
242    for (topic, entry) in &cfg.topic_acl {
243        acl.set(topic.clone(), entry.clone());
244    }
245    if let Some(d) = &cfg.topic_acl_default {
246        acl.set_default(d.clone());
247    }
248
249    Ok(SecurityCtx {
250        tls,
251        auth: Arc::new(auth),
252        acl: Arc::new(acl),
253    })
254}
255
256/// Authentication wrapper. Each daemon fills the inputs relevant to it:
257/// * HTTP/WS/gRPC: `authorization_header`
258/// * mTLS (all TCP bridges): `mtls_subject` extracted from `rustls::ServerConnection::peer_certificates()`
259/// * MQTT/AMQP SASL-PLAIN: `sasl_plain_blob`
260///
261/// # Errors
262/// [`AuthError`] on any form of reject/missing/malformed.
263pub fn authenticate(
264    auth: &AuthMode,
265    authorization_header: Option<&str>,
266    sasl_plain_blob: Option<&[u8]>,
267    mtls_subject: Option<AuthSubject>,
268) -> Result<AuthSubject, AuthError> {
269    let input = AuthInput {
270        authorization_header,
271        sasl_plain_blob,
272        mtls_subject,
273    };
274    auth.validate(&input)
275}
276
277/// Convenience: topic ACL check.
278#[must_use]
279pub fn authorize(acl: &Acl, subject: &AuthSubject, op: AclOp, topic: &str) -> bool {
280    acl.check(subject, op, topic)
281}
282
283/// Extracts an `AuthSubject` from a `rustls::ServerConnection` peer cert
284/// (for `AuthMode::Mtls`). Returns `None` if no cert was presented.
285///
286/// Subject name = the certificate's subject DN (RFC 4514), fingerprint
287/// `mtls:<SHA-256 hex>` alongside; see [`mtls_subject_from_der`].
288#[must_use]
289pub fn extract_mtls_subject(conn: &rustls::ServerConnection) -> Option<AuthSubject> {
290    let certs = conn.peer_certificates()?;
291    Some(mtls_subject_from_der(certs.first()?.as_ref()))
292}
293
294/// The mTLS subject of a client certificate in DER (also for DTLS).
295///
296/// The bridge specs name the subject DN as the identity (§7.2/§7.3): the
297/// name is the certificate's subject as an RFC 4514 string
298/// (`CN=alice,O=Example`). The fingerprint `mtls:<SHA-256 hex>` goes along,
299/// and an ACL entry may name either. A certificate whose subject is empty
300/// or does not parse is known by its fingerprint alone.
301#[must_use]
302pub fn mtls_subject_from_der(leaf_der: &[u8]) -> AuthSubject {
303    let fingerprint = format!("mtls:{}", sha256_hex(leaf_der));
304    let mut subject = AuthSubject::new(subject_dn(leaf_der).unwrap_or_else(|| fingerprint.clone()));
305    subject.fingerprint = Some(fingerprint);
306    subject
307}
308
309/// RFC 4514 §2: the RDNs in reverse order of the encoding, joined by `,`,
310/// multi-valued RDNs by `+`; attribute types by their short name (§3) or
311/// dotted OID, values escaped per §2.4.
312fn subject_dn(der: &[u8]) -> Option<String> {
313    use x509_parser::prelude::{FromDer, X509Certificate};
314    let (_, cert) = X509Certificate::from_der(der).ok()?;
315    let registry = x509_parser::objects::oid_registry();
316    let mut rdns: Vec<String> = Vec::new();
317    for rdn in cert.subject().iter() {
318        let mut parts: Vec<String> = Vec::new();
319        for attr in rdn.iter() {
320            let key = x509_parser::objects::oid2abbrev(attr.attr_type(), registry)
321                .map_or_else(|_| attr.attr_type().to_id_string(), ToString::to_string);
322            let value = attr.as_str().ok()?;
323            parts.push(format!("{key}={}", escape_dn_value(value)));
324        }
325        rdns.push(parts.join("+"));
326    }
327    rdns.reverse();
328    let dn = rdns.join(",");
329    (!dn.is_empty()).then_some(dn)
330}
331
332/// RFC 4514 §2.4 escaping of an attribute value.
333fn escape_dn_value(v: &str) -> String {
334    let mut out = String::with_capacity(v.len());
335    let last = v.chars().count().saturating_sub(1);
336    for (i, c) in v.chars().enumerate() {
337        let special = matches!(c, '"' | '+' | ',' | ';' | '<' | '>' | '\\')
338            || (i == 0 && (c == ' ' || c == '#'))
339            || (i == last && c == ' ');
340        if c == '\0' {
341            out.push_str("\\00");
342            continue;
343        }
344        if special {
345            out.push('\\');
346        }
347        out.push(c);
348    }
349    out
350}
351
352fn sha256_hex(data: &[u8]) -> String {
353    use crate::backend::digest::{Context, SHA256};
354    let mut ctx = Context::new(&SHA256);
355    ctx.update(data);
356    let d = ctx.finish();
357    let mut s = String::with_capacity(64);
358    for b in d.as_ref() {
359        s.push_str(&format!("{b:02x}"));
360    }
361    s
362}
363
364#[cfg(test)]
365#[allow(clippy::expect_used, clippy::unwrap_used)]
366mod tests {
367    use super::*;
368
369    #[test]
370    fn build_default_yields_none_auth_allow_all_acl() {
371        let cfg = SecurityConfig::default();
372        let ctx = build_ctx(&cfg).unwrap();
373        assert!(ctx.tls.is_none());
374        assert!(matches!(*ctx.auth, AuthMode::None));
375        assert!(ctx.acl.check(&AuthSubject::anonymous(), AclOp::Read, "X"));
376    }
377
378    #[test]
379    fn build_bearer_with_tokens() {
380        let mut cfg = SecurityConfig {
381            auth_mode: "bearer".into(),
382            ..Default::default()
383        };
384        cfg.bearer_tokens.insert("t".into(), "alice".into());
385        let ctx = build_ctx(&cfg).unwrap();
386        let s = authenticate(&ctx.auth, Some("Bearer t"), None, None).unwrap();
387        assert_eq!(s.name, "alice");
388    }
389
390    #[test]
391    fn build_sasl_with_users() {
392        let mut cfg = SecurityConfig {
393            auth_mode: "sasl".into(),
394            ..Default::default()
395        };
396        cfg.sasl_users.insert("u".into(), "p".into());
397        let ctx = build_ctx(&cfg).unwrap();
398        let s = authenticate(&ctx.auth, None, Some(b"\0u\0p"), None).unwrap();
399        assert_eq!(s.name, "u");
400    }
401
402    #[test]
403    fn build_sasl_without_users_rejected() {
404        let cfg = SecurityConfig {
405            auth_mode: "sasl".into(),
406            ..Default::default()
407        };
408        let err = build_ctx(&cfg).unwrap_err();
409        assert!(matches!(err, SecurityError::MissingAuthInput(_)));
410    }
411
412    #[test]
413    fn unknown_auth_mode_rejected() {
414        let cfg = SecurityConfig {
415            auth_mode: "weird".into(),
416            ..Default::default()
417        };
418        let err = build_ctx(&cfg).unwrap_err();
419        assert!(matches!(err, SecurityError::UnknownAuthMode(_)));
420    }
421
422    #[test]
423    fn jwt_without_key_rejected() {
424        let cfg = SecurityConfig {
425            auth_mode: "jwt".into(),
426            ..Default::default()
427        };
428        let err = build_ctx(&cfg).unwrap_err();
429        assert!(matches!(err, SecurityError::MissingAuthInput(_)));
430    }
431
432    #[test]
433    fn explicit_acl_overrides_open_default() {
434        let mut cfg = SecurityConfig::default();
435        cfg.topic_acl.insert(
436            "T".into(),
437            AclEntry {
438                read: vec!["alice".into()],
439                write: vec!["alice".into()],
440            },
441        );
442        let ctx = build_ctx(&cfg).unwrap();
443        let alice = AuthSubject::new("alice");
444        let bob = AuthSubject::new("bob");
445        assert!(authorize(&ctx.acl, &alice, AclOp::Read, "T"));
446        assert!(!authorize(&ctx.acl, &bob, AclOp::Read, "T"));
447        assert!(!authorize(&ctx.acl, &alice, AclOp::Read, "Other"));
448    }
449
450    #[test]
451    fn explicit_acl_default_used_for_unknown() {
452        let cfg = SecurityConfig {
453            topic_acl_default: Some(AclEntry {
454                read: vec!["*".into()],
455                write: vec![],
456            }),
457            ..SecurityConfig::default()
458        };
459        let ctx = build_ctx(&cfg).unwrap();
460        let bob = AuthSubject::new("bob");
461        assert!(authorize(&ctx.acl, &bob, AclOp::Read, "Anything"));
462        assert!(!authorize(&ctx.acl, &bob, AclOp::Write, "Anything"));
463    }
464
465    #[test]
466    fn partial_tls_paths_rejected() {
467        let cfg = SecurityConfig {
468            tls_cert: Some("/x".into()),
469            tls_key: None,
470            ..Default::default()
471        };
472        let err = build_ctx(&cfg).unwrap_err();
473        assert!(matches!(err, SecurityError::Tls(_)));
474    }
475
476    /// §7.2: the mTLS subject is the certificate's subject DN (RFC 4514,
477    /// most specific RDN first, `,` escaped), and an ACL entry may name the
478    /// DN or the fingerprint.
479    #[test]
480    fn the_mtls_subject_is_the_dn_and_the_acl_also_takes_the_fingerprint() {
481        let mut p = rcgen::CertificateParams::new(Vec::<String>::new()).unwrap();
482        p.distinguished_name
483            .push(rcgen::DnType::OrganizationName, "Example, Inc");
484        p.distinguished_name
485            .push(rcgen::DnType::CommonName, "alice");
486        let key = rcgen::KeyPair::generate().unwrap();
487        let cert = p.self_signed(&key).unwrap();
488        let s = mtls_subject_from_der(cert.der());
489        // The reference: OpenSSL's RFC 2253 form (the order RFC 4514 took
490        // over) of the same certificate.
491        let pem = std::env::temp_dir().join(format!("zd-dn-{}.pem", std::process::id()));
492        std::fs::write(&pem, cert.pem()).unwrap();
493        let out = std::process::Command::new("openssl")
494            .args(["x509", "-noout", "-subject", "-nameopt", "RFC2253", "-in"])
495            .arg(&pem)
496            .output()
497            .expect("openssl on PATH: the reference for the DN string");
498        let _ = std::fs::remove_file(&pem);
499        let reference = String::from_utf8(out.stdout).unwrap();
500        let reference = reference.trim().trim_start_matches("subject=");
501        assert_eq!(s.name, reference);
502        assert!(s.name.contains("Example\\, Inc"), "{}", s.name);
503        let fp = s.fingerprint.clone().unwrap();
504        assert!(fp.starts_with("mtls:") && fp.len() == 5 + 64, "{fp}");
505        for pat in [s.name.clone(), fp] {
506            let mut cfg = SecurityConfig::default();
507            cfg.topic_acl.insert(
508                "T".into(),
509                AclEntry {
510                    read: vec![pat.clone()],
511                    write: Vec::new(),
512                },
513            );
514            let ctx = build_ctx(&cfg).unwrap();
515            assert!(authorize(&ctx.acl, &s, AclOp::Read, "T"), "{pat}");
516            assert!(!authorize(
517                &ctx.acl,
518                &AuthSubject::new("bob"),
519                AclOp::Read,
520                "T"
521            ));
522        }
523    }
524}