1use actrix_proto::{
2 CreateRealmRequest, CreateRealmResponse, DeleteConfigOverrideRequest,
3 DeleteConfigOverrideResponse, DeleteRealmRequest, DeleteRealmResponse, GetConfigRequest,
4 GetConfigResponse, GetNodeInfoRequest, GetNodeInfoResponse, GetRealmRequest, GetRealmResponse,
5 ListConfigOverridesRequest, ListConfigOverridesResponse, ListRealmsRequest, ListRealmsResponse,
6 NodeAdminService, NonceCredential, SetConfigOverrideRequest, SetConfigOverrideResponse,
7 ShutdownRequest, ShutdownResponse, UpdateConfigRequest, UpdateConfigResponse,
8 UpdateRealmRequest, UpdateRealmResponse,
9};
10use nonce_auth::{CredentialVerifier, NonceError, storage::NonceStorage};
11use std::sync::Arc;
12use std::time::Duration;
13use tonic::{Request, Response, Status};
14
15pub trait CredentialPayload {
17 fn credential(&self) -> &NonceCredential;
18 fn auth_payload(&self, node_id: &str) -> String;
19 fn body_digest(&self) -> String {
25 String::new()
26 }
27}
28
29fn sha256_hex(s: &str) -> String {
31 use sha2::{Digest, Sha256};
32 let mut hasher = Sha256::new();
33 hasher.update(s.as_bytes());
34 hex::encode(hasher.finalize())
35}
36
37fn opt_str(v: Option<&str>) -> &str {
38 v.unwrap_or("")
39}
40
41fn opt_u64(v: Option<u64>) -> String {
42 v.map(|x| x.to_string()).unwrap_or_default()
43}
44
45fn opt_i32(v: Option<i32>) -> String {
46 v.map(|x| x.to_string()).unwrap_or_default()
47}
48
49fn opt_bool(v: Option<bool>) -> String {
50 v.map(|b| b.to_string()).unwrap_or_default()
51}
52
53#[derive(Clone)]
54struct VerifierState {
55 node_id: String,
56 shared_secret: Arc<Vec<u8>>,
57 nonce_storage: Arc<dyn NonceStorage + Send + Sync>,
58 max_clock_skew_secs: u64,
59}
60
61impl VerifierState {
62 async fn verify(&self, credential: &NonceCredential, payload: String) -> Result<(), Status> {
63 let nonce_credential = nonce_auth::NonceCredential {
64 timestamp: credential.timestamp,
65 nonce: credential.nonce.clone(),
66 signature: credential.signature.clone(),
67 };
68
69 let verifier = CredentialVerifier::new(self.nonce_storage.clone())
70 .with_secret(&self.shared_secret)
71 .with_time_window(Duration::from_secs(self.max_clock_skew_secs))
72 .with_storage_ttl(Duration::from_secs(self.max_clock_skew_secs + 300));
73
74 verifier
75 .verify(&nonce_credential, payload.as_bytes())
76 .await
77 .map_err(|e| map_nonce_error(e, "credential verification failed"))
78 }
79}
80
81#[derive(Clone)]
83pub struct AuthService<S> {
84 inner: S,
85 verifier: Arc<VerifierState>,
86}
87
88impl<S> AuthService<S> {
89 pub fn new(
90 inner: S,
91 node_id: impl Into<String>,
92 shared_secret: Arc<Vec<u8>>,
93 nonce_storage: Arc<dyn NonceStorage + Send + Sync>,
94 max_clock_skew_secs: u64,
95 ) -> Self {
96 let time_window = if max_clock_skew_secs == 0 {
97 300
98 } else {
99 max_clock_skew_secs
100 };
101
102 Self {
103 inner,
104 verifier: Arc::new(VerifierState {
105 node_id: node_id.into(),
106 shared_secret,
107 nonce_storage,
108 max_clock_skew_secs: time_window,
109 }),
110 }
111 }
112
113 async fn verify_body<T: CredentialPayload>(&self, body: &T) -> Result<(), Status> {
114 let mut payload = body.auth_payload(&self.verifier.node_id);
115 let digest = body.body_digest();
116 if !digest.is_empty() {
117 payload.push(':');
118 payload.push_str(&digest);
119 }
120 self.verifier.verify(body.credential(), payload).await
121 }
122}
123
124#[tonic::async_trait]
125impl<S> NodeAdminService for AuthService<S>
126where
127 S: NodeAdminService + Send + Sync + Clone + 'static,
128{
129 async fn update_config(
130 &self,
131 request: Request<UpdateConfigRequest>,
132 ) -> Result<Response<UpdateConfigResponse>, Status> {
133 self.verify_body(request.get_ref()).await?;
134 self.inner.update_config(request).await
135 }
136
137 async fn get_config(
138 &self,
139 request: Request<GetConfigRequest>,
140 ) -> Result<Response<GetConfigResponse>, Status> {
141 self.verify_body(request.get_ref()).await?;
142 self.inner.get_config(request).await
143 }
144
145 async fn create_realm(
146 &self,
147 request: Request<CreateRealmRequest>,
148 ) -> Result<Response<CreateRealmResponse>, Status> {
149 self.verify_body(request.get_ref()).await?;
150 self.inner.create_realm(request).await
151 }
152
153 async fn get_realm(
154 &self,
155 request: Request<GetRealmRequest>,
156 ) -> Result<Response<GetRealmResponse>, Status> {
157 self.verify_body(request.get_ref()).await?;
158 self.inner.get_realm(request).await
159 }
160
161 async fn update_realm(
162 &self,
163 request: Request<UpdateRealmRequest>,
164 ) -> Result<Response<UpdateRealmResponse>, Status> {
165 self.verify_body(request.get_ref()).await?;
166 self.inner.update_realm(request).await
167 }
168
169 async fn delete_realm(
170 &self,
171 request: Request<DeleteRealmRequest>,
172 ) -> Result<Response<DeleteRealmResponse>, Status> {
173 self.verify_body(request.get_ref()).await?;
174 self.inner.delete_realm(request).await
175 }
176
177 async fn list_realms(
178 &self,
179 request: Request<ListRealmsRequest>,
180 ) -> Result<Response<ListRealmsResponse>, Status> {
181 self.verify_body(request.get_ref()).await?;
182 self.inner.list_realms(request).await
183 }
184
185 async fn get_node_info(
186 &self,
187 request: Request<GetNodeInfoRequest>,
188 ) -> Result<Response<GetNodeInfoResponse>, Status> {
189 self.verify_body(request.get_ref()).await?;
190 self.inner.get_node_info(request).await
191 }
192
193 async fn shutdown(
194 &self,
195 request: Request<ShutdownRequest>,
196 ) -> Result<Response<ShutdownResponse>, Status> {
197 self.verify_body(request.get_ref()).await?;
198 self.inner.shutdown(request).await
199 }
200
201 async fn list_config_overrides(
202 &self,
203 request: Request<ListConfigOverridesRequest>,
204 ) -> Result<Response<ListConfigOverridesResponse>, Status> {
205 self.verify_body(request.get_ref()).await?;
206 self.inner.list_config_overrides(request).await
207 }
208
209 async fn set_config_override(
210 &self,
211 request: Request<SetConfigOverrideRequest>,
212 ) -> Result<Response<SetConfigOverrideResponse>, Status> {
213 self.verify_body(request.get_ref()).await?;
214 self.inner.set_config_override(request).await
215 }
216
217 async fn delete_config_override(
218 &self,
219 request: Request<DeleteConfigOverrideRequest>,
220 ) -> Result<Response<DeleteConfigOverrideResponse>, Status> {
221 self.verify_body(request.get_ref()).await?;
222 self.inner.delete_config_override(request).await
223 }
224}
225
226impl CredentialPayload for UpdateConfigRequest {
229 fn credential(&self) -> &NonceCredential {
230 &self.credential
231 }
232
233 fn auth_payload(&self, node_id: &str) -> String {
234 format!(
235 "update_config:{node_id}:{}:{}",
236 self.config_type, self.config_key
237 )
238 }
239}
240
241impl CredentialPayload for GetConfigRequest {
242 fn credential(&self) -> &NonceCredential {
243 &self.credential
244 }
245
246 fn auth_payload(&self, node_id: &str) -> String {
247 format!(
248 "get_config:{node_id}:{}:{}",
249 self.config_type, self.config_key
250 )
251 }
252}
253
254impl CredentialPayload for CreateRealmRequest {
255 fn credential(&self) -> &NonceCredential {
256 &self.credential
257 }
258
259 fn auth_payload(&self, node_id: &str) -> String {
260 format!("create_realm:{node_id}:{}", self.realm_id.unwrap_or(0))
261 }
262
263 fn body_digest(&self) -> String {
264 let canon = format!(
265 "realm_id={}&name={}&enabled={}&status={}&expires_at={}&secret_current_hash={}&secret_previous_hash={}&secret_previous_valid_until={}",
266 self.realm_id.unwrap_or(0),
267 self.name,
268 self.enabled,
269 opt_str(self.status.as_deref()),
270 self.expires_at,
271 opt_str(self.secret_current_hash.as_deref()),
272 opt_str(self.secret_previous_hash.as_deref()),
273 opt_u64(self.secret_previous_valid_until),
274 );
275 sha256_hex(&canon)
276 }
277}
278
279impl CredentialPayload for GetRealmRequest {
280 fn credential(&self) -> &NonceCredential {
281 &self.credential
282 }
283
284 fn auth_payload(&self, node_id: &str) -> String {
285 format!("get_realm:{node_id}:{}", self.realm_id)
286 }
287}
288
289impl CredentialPayload for UpdateRealmRequest {
290 fn credential(&self) -> &NonceCredential {
291 &self.credential
292 }
293
294 fn auth_payload(&self, node_id: &str) -> String {
295 format!("update_realm:{node_id}:{}", self.realm_id)
296 }
297
298 fn body_digest(&self) -> String {
299 let canon = format!(
300 "realm_id={}&name={}&enabled={}&status={}&expires_at={}&secret_current_hash={}&secret_previous_hash={}&secret_previous_valid_until={}",
301 self.realm_id,
302 opt_str(self.name.as_deref()),
303 opt_bool(self.enabled),
304 opt_str(self.status.as_deref()),
305 opt_u64(self.expires_at),
306 opt_str(self.secret_current_hash.as_deref()),
307 opt_str(self.secret_previous_hash.as_deref()),
308 opt_u64(self.secret_previous_valid_until),
309 );
310 sha256_hex(&canon)
311 }
312}
313
314impl CredentialPayload for DeleteRealmRequest {
315 fn credential(&self) -> &NonceCredential {
316 &self.credential
317 }
318
319 fn auth_payload(&self, node_id: &str) -> String {
320 format!("delete_realm:{node_id}:{}", self.realm_id)
321 }
322}
323
324impl CredentialPayload for ListRealmsRequest {
325 fn credential(&self) -> &NonceCredential {
326 &self.credential
327 }
328
329 fn auth_payload(&self, node_id: &str) -> String {
330 format!("list_realms:{node_id}")
331 }
332}
333
334impl CredentialPayload for GetNodeInfoRequest {
335 fn credential(&self) -> &NonceCredential {
336 &self.credential
337 }
338
339 fn auth_payload(&self, node_id: &str) -> String {
340 format!("node_info:{node_id}")
341 }
342}
343
344impl CredentialPayload for ShutdownRequest {
345 fn credential(&self) -> &NonceCredential {
346 &self.credential
347 }
348
349 fn auth_payload(&self, node_id: &str) -> String {
350 format!("shutdown:{node_id}")
351 }
352
353 fn body_digest(&self) -> String {
354 let canon = format!(
355 "graceful={}&timeout_secs={}&reason={}",
356 self.graceful,
357 opt_i32(self.timeout_secs),
358 opt_str(self.reason.as_deref()),
359 );
360 sha256_hex(&canon)
361 }
362}
363
364impl CredentialPayload for ListConfigOverridesRequest {
365 fn credential(&self) -> &NonceCredential {
366 &self.credential
367 }
368
369 fn auth_payload(&self, node_id: &str) -> String {
370 format!("list_config_overrides:{node_id}")
371 }
372}
373
374impl CredentialPayload for SetConfigOverrideRequest {
375 fn credential(&self) -> &NonceCredential {
376 &self.credential
377 }
378
379 fn auth_payload(&self, node_id: &str) -> String {
380 format!("set_config_override:{node_id}:{}", self.key)
381 }
382}
383
384impl CredentialPayload for DeleteConfigOverrideRequest {
385 fn credential(&self) -> &NonceCredential {
386 &self.credential
387 }
388
389 fn auth_payload(&self, node_id: &str) -> String {
390 format!("delete_config_override:{node_id}:{}", self.key)
391 }
392}
393
394fn map_nonce_error(err: NonceError, context: &str) -> Status {
395 match err {
396 NonceError::DuplicateNonce => {
397 Status::unauthenticated(format!("{context}: nonce already used"))
398 }
399 NonceError::TimestampOutOfWindow => {
400 Status::unauthenticated(format!("{context}: timestamp out of range"))
401 }
402 NonceError::InvalidSignature => {
403 Status::unauthenticated(format!("{context}: invalid signature"))
404 }
405 other => Status::internal(format!("{context}: {other}")),
406 }
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412
413 const REALM_CANON: &str = "realm_id=42&name=managed&enabled=true&status=Active&expires_at=1900000000&secret_current_hash=hash-42&secret_previous_hash=&secret_previous_valid_until=";
418
419 fn dummy_credential() -> NonceCredential {
420 NonceCredential {
421 timestamp: 0,
422 nonce: String::new(),
423 signature: String::new(),
424 }
425 }
426
427 #[test]
428 fn create_realm_body_digest_matches_canonical() {
429 let req = CreateRealmRequest {
430 realm_id: Some(42),
431 name: "managed".to_string(),
432 enabled: true,
433 credential: dummy_credential(),
434 expires_at: 1_900_000_000,
435 status: Some("Active".to_string()),
436 secret_current_hash: Some("hash-42".to_string()),
437 secret_previous_hash: None,
438 secret_previous_valid_until: None,
439 };
440 assert_eq!(sha256_hex(REALM_CANON), req.body_digest());
441 }
442
443 #[test]
444 fn update_realm_body_digest_matches_canonical() {
445 let req = UpdateRealmRequest {
446 realm_id: 42,
447 name: Some("managed".to_string()),
448 enabled: Some(true),
449 credential: dummy_credential(),
450 status: Some("Active".to_string()),
451 expires_at: Some(1_900_000_000),
452 secret_current_hash: Some("hash-42".to_string()),
453 secret_previous_hash: None,
454 secret_previous_valid_until: None,
455 };
456 assert_eq!(sha256_hex(REALM_CANON), req.body_digest());
457 }
458
459 #[test]
460 fn shutdown_body_digest_matches_canonical() {
461 let req = ShutdownRequest {
462 graceful: true,
463 timeout_secs: Some(30),
464 reason: Some("deploy".to_string()),
465 credential: dummy_credential(),
466 };
467 let canon = "graceful=true&timeout_secs=30&reason=deploy";
468 assert_eq!(sha256_hex(canon), req.body_digest());
469 }
470
471 #[test]
472 fn read_ops_have_empty_body_digest() {
473 let get_realm = GetRealmRequest {
474 realm_id: 42,
475 credential: dummy_credential(),
476 };
477 assert_eq!(get_realm.body_digest(), "");
478 }
479}