Skip to main content

pmetal_distributed/
namespace.rs

1//! Network namespace isolation via pre-shared keys.
2//!
3//! Provides cryptographic isolation between different clusters or versions
4//! to prevent cross-talk. Uses SHA3-256 hash of the namespace string to
5//! generate a consistent PSK across all nodes in the same namespace.
6//!
7//! # Usage
8//!
9//! ```ignore
10//! let namespace = NetworkNamespace::new("pmetal/0.1.0", Some("my-cluster"));
11//! let psk = namespace.psk();
12//! ```
13//!
14//! # Environment Variable
15//!
16//! The namespace can be overridden via `PMETAL_NAMESPACE` environment variable.
17
18use sha3::{Digest, Sha3_256};
19use std::env;
20use tracing::info;
21
22/// Default version string.
23const DEFAULT_VERSION: &str = env!("CARGO_PKG_VERSION");
24
25/// Environment variable for namespace override.
26const NAMESPACE_ENV_VAR: &str = "PMETAL_NAMESPACE";
27
28/// Network namespace for cluster isolation.
29#[derive(Debug, Clone)]
30pub struct NetworkNamespace {
31    /// Version string (e.g., "pmetal/0.1.0").
32    version: String,
33    /// Optional cluster name for additional isolation.
34    cluster: Option<String>,
35    /// Pre-computed PSK.
36    psk: [u8; 32],
37    /// Human-readable namespace string.
38    namespace_str: String,
39}
40
41impl NetworkNamespace {
42    /// Create a new network namespace.
43    ///
44    /// # Arguments
45    ///
46    /// * `version` - Version string (e.g., "pmetal/0.1.0")
47    /// * `cluster` - Optional cluster name for additional isolation
48    pub fn new(version: &str, cluster: Option<&str>) -> Self {
49        // Check for environment override
50        let namespace_override = env::var(NAMESPACE_ENV_VAR).ok();
51
52        let namespace_str = match &namespace_override {
53            Some(override_ns) => override_ns.clone(),
54            None => match cluster {
55                Some(c) => format!("{}/{}", version, c),
56                None => version.to_string(),
57            },
58        };
59
60        let psk = Self::compute_psk(&namespace_str);
61
62        if namespace_override.is_some() {
63            info!(
64                "Using namespace override from {}: {}",
65                NAMESPACE_ENV_VAR, namespace_str
66            );
67        } else {
68            info!("Network namespace: {}", namespace_str);
69        }
70
71        Self {
72            version: version.to_string(),
73            cluster: cluster.map(|s| s.to_string()),
74            psk,
75            namespace_str,
76        }
77    }
78
79    /// Create a namespace with the default version.
80    pub fn default_version(cluster: Option<&str>) -> Self {
81        Self::new(&format!("pmetal/{}", DEFAULT_VERSION), cluster)
82    }
83
84    /// Compute the PSK from the namespace string.
85    fn compute_psk(namespace: &str) -> [u8; 32] {
86        let mut hasher = Sha3_256::new();
87        hasher.update(namespace.as_bytes());
88        let result = hasher.finalize();
89
90        let mut psk = [0u8; 32];
91        psk.copy_from_slice(&result);
92        psk
93    }
94
95    /// Get the pre-shared key.
96    pub fn psk(&self) -> &[u8; 32] {
97        &self.psk
98    }
99
100    /// Get the namespace string.
101    pub fn namespace_str(&self) -> &str {
102        &self.namespace_str
103    }
104
105    /// Get the version.
106    pub fn version(&self) -> &str {
107        &self.version
108    }
109
110    /// Get the cluster name.
111    pub fn cluster(&self) -> Option<&str> {
112        self.cluster.as_deref()
113    }
114
115    /// Check if another namespace is compatible.
116    pub fn is_compatible(&self, other: &NetworkNamespace) -> bool {
117        self.psk == other.psk
118    }
119
120    /// Verify a received PSK matches ours.
121    pub fn verify_psk(&self, received: &[u8; 32]) -> bool {
122        // Constant-time comparison
123        let mut result = 0u8;
124        for (a, b) in self.psk.iter().zip(received.iter()) {
125            result |= a ^ b;
126        }
127        result == 0
128    }
129
130    /// Create a gossipsub topic for this namespace.
131    pub fn gossipsub_topic(&self, suffix: &str) -> String {
132        format!("{}/{}", self.namespace_str, suffix)
133    }
134
135    /// Create the protocol ID for libp2p.
136    pub fn protocol_id(&self) -> String {
137        format!("/{}", self.namespace_str.replace('/', "-"))
138    }
139}
140
141impl Default for NetworkNamespace {
142    fn default() -> Self {
143        Self::default_version(None)
144    }
145}
146
147/// Validate that a peer belongs to our namespace.
148///
149/// This should be called during the identify protocol exchange.
150pub fn validate_peer_namespace(local: &NetworkNamespace, remote_protocol: &str) -> bool {
151    let expected = local.protocol_id();
152    remote_protocol.starts_with(&expected)
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn test_psk_computation() {
161        let ns1 = NetworkNamespace::new("pmetal/0.1.0", None);
162        let ns2 = NetworkNamespace::new("pmetal/0.1.0", None);
163
164        assert_eq!(ns1.psk(), ns2.psk());
165    }
166
167    #[test]
168    fn test_different_versions() {
169        let ns1 = NetworkNamespace::new("pmetal/0.1.0", None);
170        let ns2 = NetworkNamespace::new("pmetal/0.2.0", None);
171
172        assert_ne!(ns1.psk(), ns2.psk());
173        assert!(!ns1.is_compatible(&ns2));
174    }
175
176    #[test]
177    fn test_cluster_isolation() {
178        let ns1 = NetworkNamespace::new("pmetal/0.1.0", Some("cluster-a"));
179        let ns2 = NetworkNamespace::new("pmetal/0.1.0", Some("cluster-b"));
180
181        assert_ne!(ns1.psk(), ns2.psk());
182        assert!(!ns1.is_compatible(&ns2));
183    }
184
185    #[test]
186    fn test_psk_verification() {
187        let ns = NetworkNamespace::new("test", None);
188        let valid_psk = *ns.psk();
189        let mut invalid_psk = valid_psk;
190        invalid_psk[0] ^= 0xFF;
191
192        assert!(ns.verify_psk(&valid_psk));
193        assert!(!ns.verify_psk(&invalid_psk));
194    }
195
196    #[test]
197    fn test_gossipsub_topic() {
198        let ns = NetworkNamespace::new("pmetal/0.1.0", Some("test"));
199        let topic = ns.gossipsub_topic("gradients");
200
201        assert_eq!(topic, "pmetal/0.1.0/test/gradients");
202    }
203
204    #[test]
205    fn test_protocol_id() {
206        let ns = NetworkNamespace::new("pmetal/0.1.0", None);
207        let protocol = ns.protocol_id();
208
209        assert!(protocol.starts_with("/pmetal-"));
210    }
211}