1mod 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#[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 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#[derive(Clone, PartialEq, Eq)]
110pub struct BootstrapAdminIdentity {
111 principal_id: CheetahString,
112 certificate_sha256: [u8; 32],
113}
114
115impl BootstrapAdminIdentity {
116 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#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct BootstrapTransportContext {
155 cluster_id: CheetahString,
156 listener: CheetahString,
157 verified_tls: bool,
158}
159
160impl BootstrapTransportContext {
161 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
184pub 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
211pub trait BootstrapAdminProvisioner: Send + Sync {
213 fn create_first_admin(&self, identity: &BootstrapAdminIdentity) -> AuthServiceResult<()>;
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub enum BootstrapStatus {
219 Available,
220 Claimed,
221 Consumed,
222}
223
224#[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
241pub struct OneTimeBootstrap {
243 grant: BootstrapGrant,
244 store: BootstrapStateStore,
245 state: Mutex<BootstrapStateRecord>,
246}
247
248impl OneTimeBootstrap {
249 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 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}