From d409173800bd5f98576a62e32a9c2211ca75c95f Mon Sep 17 00:00:00 2001 From: AlexanderWagnerDev Date: Sun, 20 Sep 2026 20:58:54 +0200 Subject: [PATCH 1/3] fix(cluster): bind mTLS node identity --- src/cluster/security.rs | 73 +++++++++------------------------------ tests/cluster_security.rs | 7 ++-- 2 files changed, 20 insertions(+), 60 deletions(-) diff --git a/src/cluster/security.rs b/src/cluster/security.rs index ff68378..d8459a9 100644 --- a/src/cluster/security.rs +++ b/src/cluster/security.rs @@ -17,7 +17,6 @@ use rand::rngs::SysRng; use rustls::pki_types::{CertificateDer, PrivateKeyDer}; use rustls::{ClientConfig, RootCertStore, ServerConfig}; -const NODE_ID_CERT_PREFIX: &[u8] = b"lrtmp2-node-"; const CLUSTER_AUTH_MAX_FAILURES: usize = 10; const CLUSTER_AUTH_FAILURE_WINDOW: Duration = Duration::from_secs(60); const MAX_TRACKED_CLUSTER_AUTH_IPS: usize = 10_000; @@ -165,24 +164,11 @@ pub fn secrets_equal(a: &str, b: &str) -> bool { fn parse_node_id_from_identity_str(value: &str) -> Option { let marker = "lrtmp2-node-"; - let pos = value.find(marker)?; - let digits = &value[pos + marker.len()..]; - let mut id = 0u64; - let mut digits_seen = 0usize; - for ch in digits.chars() { - if ch.is_ascii_digit() { - id = id - .saturating_mul(10) - .saturating_add((ch as u8 - b'0') as u64); - digits_seen += 1; - if digits_seen > 20 { - return None; - } - } else { - break; - } + let digits = value.strip_prefix(marker)?; + if digits.is_empty() || digits.len() > 20 || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; } - (digits_seen > 0 && id > 0).then_some(id) + digits.parse::().ok().filter(|id| *id > 0) } fn node_id_from_cert_der(bytes: &[u8]) -> Option { @@ -213,37 +199,6 @@ fn node_id_from_cert_der(bytes: &[u8]) -> Option { None } -fn node_id_from_cert_bytes_scan(bytes: &[u8]) -> Option { - let mut search_from = 0usize; - while let Some(rel) = bytes[search_from..] - .windows(NODE_ID_CERT_PREFIX.len()) - .position(|w| w == NODE_ID_CERT_PREFIX) - { - let pos = search_from + rel + NODE_ID_CERT_PREFIX.len(); - let mut id = 0u64; - let mut digits = 0usize; - for &b in &bytes[pos..] { - if b.is_ascii_digit() { - id = id.saturating_mul(10).saturating_add((b - b'0') as u64); - digits += 1; - if digits > 20 { - break; - } - } else { - break; - } - } - if digits > 0 && id > 0 { - return Some(id); - } - search_from = pos.saturating_add(1); - if search_from >= bytes.len() { - break; - } - } - None -} - /// CSPRNG nonce for cluster auth handshakes. pub fn auth_nonce() -> Vec { let mut nonce = vec![0u8; 16]; @@ -257,11 +212,7 @@ pub fn auth_nonce() -> Vec { /// `lrtmp2-node-{id}`). Returns `None` when TLS is off or the pattern is absent. pub fn node_id_from_peer_certs(certs: &[CertificateDer<'_>]) -> Option { for cert in certs { - let bytes = cert.as_ref(); - if let Some(id) = node_id_from_cert_der(bytes) { - return Some(id); - } - if let Some(id) = node_id_from_cert_bytes_scan(bytes) { + if let Some(id) = node_id_from_cert_der(cert.as_ref()) { return Some(id); } } @@ -428,11 +379,21 @@ mod tests { } #[test] - fn node_id_from_cert_prefix_scan() { + fn node_id_rejects_unparsed_certificate_bytes() { let mut der = vec![0u8; 64]; der.extend_from_slice(b"prefix-lrtmp2-node-42-suffix"); let id = node_id_from_peer_certs(&[CertificateDer::from(der)]); - assert_eq!(id, Some(42)); + assert_eq!(id, None); + } + + #[test] + fn node_id_identity_requires_an_exact_value() { + assert_eq!(parse_node_id_from_identity_str("lrtmp2-node-42"), Some(42)); + assert_eq!(parse_node_id_from_identity_str("node-lrtmp2-node-42"), None); + assert_eq!( + parse_node_id_from_identity_str("lrtmp2-node-42.example"), + None + ); } #[test] diff --git a/tests/cluster_security.rs b/tests/cluster_security.rs index 6e13658..55075f3 100644 --- a/tests/cluster_security.rs +++ b/tests/cluster_security.rs @@ -98,12 +98,11 @@ fn admin_proof_is_deterministic_so_replay_cache_is_required() { } #[test] -fn tls_identity_requires_cert_marker_when_tls_on() { +fn tls_identity_rejects_unparsed_certificate_bytes() { let mut der = Vec::new(); der.extend_from_slice(b"noise-lrtmp2-node-99-trailer"); let cert_id = node_id_from_peer_certs(&[rustls::pki_types::CertificateDer::from(der)]); - assert_eq!(cert_id, Some(99)); - assert!(verify_tls_node_identity(true, cert_id, 99).is_ok()); - assert!(verify_tls_node_identity(true, cert_id, 1).is_err()); + assert_eq!(cert_id, None); + assert!(verify_tls_node_identity(true, cert_id, 99).is_err()); assert!(verify_tls_node_identity(true, None, 1).is_err()); } From 1fbf0e0897acae7557e6374029367f2a9416d2f0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:59:28 +0000 Subject: [PATCH 2/3] Update Cargo.lock --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index edcd6d3..5b75d68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -970,9 +970,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http", "hyper", From 3a5255c75ff202899f929bfae2825cb1f533c41e Mon Sep 17 00:00:00 2001 From: AlexanderWagnerDev Date: Sun, 20 Sep 2026 21:16:45 +0200 Subject: [PATCH 3/3] fix(cluster): use leaf certificate identity --- docs/clustering.md | 9 ++++++--- src/cluster/security.rs | 14 ++++++++------ 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/docs/clustering.md b/docs/clustering.md index 3792592..42f8fb8 100644 --- a/docs/clustering.md +++ b/docs/clustering.md @@ -233,9 +233,12 @@ ingress eligibility rapidly. - Control/media use shared-secret challenge-response auth; enable `CLUSTER_TLS_ENABLED` with cert/key/CA for mTLS in production. - When mTLS is enabled, each node client certificate must embed its - `CLUSTER_NODE_ID` as the printable string `lrtmp2-node-{id}` in the - subject CN or SAN (the server scans the DER for this marker). The - authenticated control/media `node_id` must match the certificate. + `CLUSTER_NODE_ID` as the exact printable string `lrtmp2-node-{id}` in the + leaf certificate's subject CN or SAN. The authenticated control/media + `node_id` must match that leaf-certificate identity; issuer certificates + and arbitrary certificate data are not considered. Existing certificates + whose CN/SAN merely contains the marker (for example, + `node-lrtmp2-node-42`) must be reissued before upgrading. - HA relay export carries live frames only. Peers that join after export starts must also fetch `stream_init_snapshot` (or receive init-cache via the media mesh `InitCache` subscribe path) before playing. diff --git a/src/cluster/security.rs b/src/cluster/security.rs index d8459a9..63485a4 100644 --- a/src/cluster/security.rs +++ b/src/cluster/security.rs @@ -211,12 +211,7 @@ pub fn auth_nonce() -> Vec { /// Extract `node_id` embedded in a peer client certificate (SAN/CN string /// `lrtmp2-node-{id}`). Returns `None` when TLS is off or the pattern is absent. pub fn node_id_from_peer_certs(certs: &[CertificateDer<'_>]) -> Option { - for cert in certs { - if let Some(id) = node_id_from_cert_der(cert.as_ref()) { - return Some(id); - } - } - None + node_id_from_cert_der(certs.first()?.as_ref()) } /// When mTLS is active, the authenticated `node_id` must match the client cert. @@ -386,6 +381,13 @@ mod tests { assert_eq!(id, None); } + #[test] + fn node_id_ignores_identity_in_issuer_certificate_bytes() { + let leaf = CertificateDer::from(b"identity-free-leaf".to_vec()); + let issuer = CertificateDer::from(b"issuer-lrtmp2-node-42".to_vec()); + assert_eq!(node_id_from_peer_certs(&[leaf, issuer]), None); + } + #[test] fn node_id_identity_requires_an_exact_value() { assert_eq!(parse_node_id_from_identity_str("lrtmp2-node-42"), Some(42));