Skip to main content

rocketmq_auth/
bootstrap.rs

1// Copyright 2023 The RocketMQ Rust Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Fail-closed one-time administrator bootstrap over a verified TLS listener.
16
17mod state_file;
18
19use std::fmt;
20use std::path::PathBuf;
21use std::sync::Mutex;
22use std::time::SystemTime;
23use std::time::UNIX_EPOCH;
24
25use cheetah_string::CheetahString;
26use rocketmq_security_api::SecretMaterial;
27use sha2::Digest;
28use sha2::Sha256;
29use subtle::ConstantTimeEq;
30
31use state_file::BootstrapStateRecord;
32use state_file::BootstrapStateStore;
33
34use crate::AuthFailureKind;
35use crate::AuthOperation;
36use crate::AuthServiceError;
37use crate::AuthServiceResult;
38
39const MINIMUM_BOOTSTRAP_PROOF_BYTES: usize = 32;
40
41/// Grant metadata and a one-way proof digest. Raw proof material is discarded at construction.
42#[derive(Clone)]
43pub struct BootstrapGrant {
44    cluster_id: CheetahString,
45    listener: CheetahString,
46    expires_at_unix_seconds: u64,
47    proof_digest: [u8; 32],
48}
49
50impl BootstrapGrant {
51    /// Creates a cluster/listener/expiry-bound grant from high-entropy proof material.
52    ///
53    /// # Errors
54    ///
55    /// Returns a redacted authentication-service error for invalid bindings, timestamps, or proof
56    /// material shorter than 32 bytes.
57    pub fn new(
58        cluster_id: impl Into<CheetahString>,
59        listener: impl Into<CheetahString>,
60        expires_at: SystemTime,
61        proof: SecretMaterial,
62    ) -> AuthServiceResult<Self> {
63        let cluster_id = cluster_id.into();
64        let listener = listener.into();
65        validate_binding(&cluster_id)?;
66        validate_binding(&listener)?;
67        if proof.len() < MINIMUM_BOOTSTRAP_PROOF_BYTES {
68            return Err(bootstrap_error(AuthFailureKind::InvalidInput));
69        }
70        let expires_at_unix_seconds = unix_seconds(expires_at)?;
71        let proof_digest = Sha256::digest(proof.expose_secret()).into();
72        Ok(Self {
73            cluster_id,
74            listener,
75            expires_at_unix_seconds,
76            proof_digest,
77        })
78    }
79
80    pub fn cluster_id(&self) -> &str {
81        &self.cluster_id
82    }
83
84    pub fn listener(&self) -> &str {
85        &self.listener
86    }
87
88    pub const fn expires_at_unix_seconds(&self) -> u64 {
89        self.expires_at_unix_seconds
90    }
91
92    fn grant_id(&self) -> String {
93        hex::encode(self.proof_digest)
94    }
95}
96
97impl fmt::Debug for BootstrapGrant {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        f.debug_struct("BootstrapGrant")
100            .field("cluster_id", &self.cluster_id)
101            .field("listener", &self.listener)
102            .field("expires_at_unix_seconds", &self.expires_at_unix_seconds)
103            .field("proof_digest", &"[REDACTED]")
104            .finish()
105    }
106}
107
108/// Non-secret first-administrator mTLS identity selected by the enrollment request.
109#[derive(Clone, PartialEq, Eq)]
110pub struct BootstrapAdminIdentity {
111    principal_id: CheetahString,
112    certificate_sha256: [u8; 32],
113}
114
115impl BootstrapAdminIdentity {
116    /// Validates a principal and certificate fingerprint.
117    ///
118    /// # Errors
119    ///
120    /// Returns a redacted authentication-service error for an invalid principal or all-zero
121    /// certificate fingerprint.
122    pub fn new(principal_id: impl Into<CheetahString>, certificate_sha256: [u8; 32]) -> AuthServiceResult<Self> {
123        let principal_id = principal_id.into();
124        validate_binding(&principal_id)?;
125        if certificate_sha256.iter().all(|byte| *byte == 0) {
126            return Err(bootstrap_error(AuthFailureKind::InvalidInput));
127        }
128        Ok(Self {
129            principal_id,
130            certificate_sha256,
131        })
132    }
133
134    pub fn principal_id(&self) -> &str {
135        &self.principal_id
136    }
137
138    pub const fn certificate_sha256(&self) -> &[u8; 32] {
139        &self.certificate_sha256
140    }
141}
142
143impl fmt::Debug for BootstrapAdminIdentity {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        f.debug_struct("BootstrapAdminIdentity")
146            .field("principal_id", &self.principal_id)
147            .field("certificate_sha256", &hex::encode(self.certificate_sha256))
148            .finish()
149    }
150}
151
152/// Authenticated listener metadata supplied by the transport boundary.
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct BootstrapTransportContext {
155    cluster_id: CheetahString,
156    listener: CheetahString,
157    verified_tls: bool,
158}
159
160impl BootstrapTransportContext {
161    /// Creates an unverified context. The transport must explicitly attest TLS verification.
162    ///
163    /// # Errors
164    ///
165    /// Returns a redacted authentication-service error for invalid bindings.
166    pub fn new(cluster_id: impl Into<CheetahString>, listener: impl Into<CheetahString>) -> AuthServiceResult<Self> {
167        let cluster_id = cluster_id.into();
168        let listener = listener.into();
169        validate_binding(&cluster_id)?;
170        validate_binding(&listener)?;
171        Ok(Self {
172            cluster_id,
173            listener,
174            verified_tls: false,
175        })
176    }
177
178    pub fn with_verified_tls(mut self) -> Self {
179        self.verified_tls = true;
180        self
181    }
182}
183
184/// Enrollment input. Proof material is zeroized when this value leaves scope.
185pub struct BootstrapEnrollmentRequest {
186    transport: BootstrapTransportContext,
187    proof: SecretMaterial,
188    identity: BootstrapAdminIdentity,
189}
190
191impl BootstrapEnrollmentRequest {
192    pub fn new(transport: BootstrapTransportContext, proof: SecretMaterial, identity: BootstrapAdminIdentity) -> Self {
193        Self {
194            transport,
195            proof,
196            identity,
197        }
198    }
199}
200
201impl fmt::Debug for BootstrapEnrollmentRequest {
202    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203        f.debug_struct("BootstrapEnrollmentRequest")
204            .field("transport", &self.transport)
205            .field("proof", &"[REDACTED]")
206            .field("identity", &self.identity)
207            .finish()
208    }
209}
210
211/// Injection boundary that must atomically reject creation when an administrator already exists.
212pub trait BootstrapAdminProvisioner: Send + Sync {
213    fn create_first_admin(&self, identity: &BootstrapAdminIdentity) -> AuthServiceResult<()>;
214}
215
216/// Persisted lifecycle of a one-time bootstrap grant.
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub enum BootstrapStatus {
219    Available,
220    Claimed,
221    Consumed,
222}
223
224/// Successful enrollment result without credential or proof material.
225#[derive(Debug, Clone, PartialEq, Eq)]
226pub struct BootstrapEnrollmentResult {
227    principal_id: CheetahString,
228    status: BootstrapStatus,
229}
230
231impl BootstrapEnrollmentResult {
232    pub fn principal_id(&self) -> &str {
233        &self.principal_id
234    }
235
236    pub const fn status(&self) -> BootstrapStatus {
237        self.status
238    }
239}
240
241/// Persistent, fail-closed one-time bootstrap coordinator.
242pub struct OneTimeBootstrap {
243    grant: BootstrapGrant,
244    store: BootstrapStateStore,
245    state: Mutex<BootstrapStateRecord>,
246}
247
248impl OneTimeBootstrap {
249    /// Opens or creates owner-only state. Windows fails closed until ACL verification is available.
250    ///
251    /// # Errors
252    ///
253    /// Returns a redacted authentication-service error when state cannot be validated or persisted
254    /// safely.
255    pub fn open(grant: BootstrapGrant, state_path: impl Into<PathBuf>) -> AuthServiceResult<Self> {
256        let initial = BootstrapStateRecord::available(&grant);
257        let (store, state) = BootstrapStateStore::open(state_path.into(), initial)?;
258        Ok(Self {
259            grant,
260            store,
261            state: Mutex::new(state),
262        })
263    }
264
265    pub fn status(&self) -> AuthServiceResult<BootstrapStatus> {
266        self.state
267            .lock()
268            .map(|state| state.status())
269            .map_err(|_| bootstrap_error(AuthFailureKind::Unavailable))
270    }
271
272    pub fn is_open(&self, now: SystemTime) -> AuthServiceResult<bool> {
273        let now = unix_seconds(now)?;
274        Ok(self.status()? == BootstrapStatus::Available
275            && now < self.grant.expires_at_unix_seconds
276            && !self.store.claim_exists()?)
277    }
278
279    /// Claims the grant before invoking the administrator store and never reopens it after a
280    /// provisioning or final-persistence failure.
281    ///
282    /// # Errors
283    ///
284    /// Returns a redacted authentication-service error for transport, proof, expiry, replay,
285    /// persistence, or provisioning failures.
286    pub fn enroll(
287        &self,
288        request: BootstrapEnrollmentRequest,
289        now: SystemTime,
290        provisioner: &dyn BootstrapAdminProvisioner,
291    ) -> AuthServiceResult<BootstrapEnrollmentResult> {
292        let mut state = self
293            .state
294            .lock()
295            .map_err(|_| bootstrap_error(AuthFailureKind::Unavailable))?;
296        match state.status() {
297            BootstrapStatus::Claimed | BootstrapStatus::Consumed => {
298                return Err(bootstrap_error(AuthFailureKind::Conflict));
299            }
300            BootstrapStatus::Available => {}
301        }
302        let now = unix_seconds(now)?;
303        if now >= self.grant.expires_at_unix_seconds {
304            return Err(bootstrap_error(AuthFailureKind::Expired));
305        }
306        if !request.transport.verified_tls {
307            return Err(bootstrap_error(AuthFailureKind::Unauthenticated));
308        }
309        if request.transport.cluster_id != self.grant.cluster_id || request.transport.listener != self.grant.listener {
310            return Err(bootstrap_error(AuthFailureKind::Unauthenticated));
311        }
312        let presented_digest: [u8; 32] = Sha256::digest(request.proof.expose_secret()).into();
313        if self.grant.proof_digest.ct_eq(&presented_digest).unwrap_u8() != 1 {
314            return Err(bootstrap_error(AuthFailureKind::Unauthenticated));
315        }
316
317        let claimed = state.claimed(&request.identity);
318        self.store.claim(&claimed)?;
319        *state = claimed.clone();
320        drop(state);
321        provisioner.create_first_admin(&request.identity)?;
322
323        let consumed = claimed.consumed(&request.identity);
324        self.store.consume(&consumed)?;
325        let mut state = self
326            .state
327            .lock()
328            .map_err(|_| bootstrap_error(AuthFailureKind::Unavailable))?;
329        *state = consumed;
330        Ok(BootstrapEnrollmentResult {
331            principal_id: request.identity.principal_id,
332            status: BootstrapStatus::Consumed,
333        })
334    }
335}
336
337impl fmt::Debug for OneTimeBootstrap {
338    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
339        f.debug_struct("OneTimeBootstrap")
340            .field("grant", &self.grant)
341            .field("state_path", &"[REDACTED]")
342            .field("status", &self.status().ok())
343            .finish()
344    }
345}
346
347fn validate_binding(value: &str) -> AuthServiceResult<()> {
348    let trimmed = value.trim();
349    if trimmed.is_empty() || trimmed.len() > 256 || trimmed.chars().any(char::is_control) {
350        return Err(bootstrap_error(AuthFailureKind::InvalidInput));
351    }
352    Ok(())
353}
354
355fn unix_seconds(time: SystemTime) -> AuthServiceResult<u64> {
356    time.duration_since(UNIX_EPOCH)
357        .map(|duration| duration.as_secs())
358        .map_err(|source| {
359            AuthServiceError::with_source(AuthOperation::Bootstrap, AuthFailureKind::InvalidInput, source)
360        })
361}
362
363fn bootstrap_error(kind: AuthFailureKind) -> AuthServiceError {
364    AuthServiceError::new(AuthOperation::Bootstrap, kind)
365}
366
367#[cfg(test)]
368mod tests {
369    use std::time::Duration;
370
371    use super::*;
372
373    fn material(bytes: &[u8]) -> SecretMaterial {
374        SecretMaterial::new(bytes.to_vec()).unwrap()
375    }
376
377    #[test]
378    fn grant_rejects_short_proof_and_redacts_digest() {
379        let expires = SystemTime::now() + Duration::from_secs(60);
380        let error = BootstrapGrant::new("cluster", "127.0.0.1:9876", expires, material(b"short")).unwrap_err();
381        assert_eq!(error.operation(), AuthOperation::Bootstrap);
382        assert_eq!(error.kind(), AuthFailureKind::InvalidInput);
383        let grant = BootstrapGrant::new("cluster", "127.0.0.1:9876", expires, material(&[7; 32])).unwrap();
384        let debug = format!("{grant:?}");
385        assert!(debug.contains("[REDACTED]"));
386        assert!(!debug.contains(&hex::encode([7; 32])));
387    }
388}