1use serde::{Deserialize, Serialize};
6
7pub const DEFAULT_RUNTIME_TOKEN_TTL_MS: u64 = 60_000;
9pub const LOCAL_ADMIN_SUBJECT: &str = "local-admin";
11
12pub type SecurityResult<T> = Result<T, SecurityError>;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum SecurityError {
18 Unsupported(&'static str),
20 InvalidToken,
22 VerificationFailed,
24 InvalidSecretRef,
26 SecretUnavailable,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum CommandTokenError {
33 InvalidFormat,
35 Unauthorized,
37 Forbidden,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct TokenClaims {
44 pub issuer: String,
46 pub audience: String,
48 pub salt: String,
50 pub ttl_ms: u64,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct RuntimeTokenClaims {
57 pub version: String,
59 pub purpose: String,
61 pub command_name: Option<String>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub scope: Option<String>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub subject: Option<String>,
69 pub issued_at_ms: u64,
71 pub expires_at_ms: u64,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub jti: Option<String>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub request_hash: Option<String>,
80}
81
82pub struct CommandTokenFactory<'a, P: TokenProvider> {
84 provider: &'a P,
85 claims: TokenClaims,
86}
87
88pub struct CommandTokenValidator<'a, P: TokenProvider> {
90 provider: &'a P,
91 claims: TokenClaims,
92}
93
94pub trait TokenProvider {
96 fn seal(&self, payload: &[u8], claims: &TokenClaims) -> SecurityResult<Vec<u8>>;
98 fn open(&self, token: &[u8], claims: &TokenClaims) -> SecurityResult<Vec<u8>>;
100 fn sign(&self, payload: &[u8], claims: &TokenClaims) -> SecurityResult<Vec<u8>>;
102 fn verify(&self, payload: &[u8], signature: &[u8], claims: &TokenClaims) -> SecurityResult<()>;
104}
105
106impl<'a, P: TokenProvider> CommandTokenFactory<'a, P> {
107 pub fn new(provider: &'a P, claims: TokenClaims) -> Self {
109 Self { provider, claims }
110 }
111
112 pub fn create_v1(
114 &self,
115 command_name: Option<&str>,
116 subject: Option<&str>,
117 issued_at_ms: u64,
118 ttl_ms: u64,
119 ) -> Result<String, CommandTokenError> {
120 self.create_v1_scoped(command_name, None, subject, issued_at_ms, ttl_ms)
121 }
122
123 pub fn create_v1_scoped(
125 &self,
126 command_name: Option<&str>,
127 scope: Option<&str>,
128 subject: Option<&str>,
129 issued_at_ms: u64,
130 ttl_ms: u64,
131 ) -> Result<String, CommandTokenError> {
132 self.create_v1_for_purpose_scoped(
133 "command",
134 command_name,
135 scope,
136 subject,
137 issued_at_ms,
138 ttl_ms,
139 )
140 }
141
142 pub fn create_v1_for_purpose(
144 &self,
145 purpose: &str,
146 command_name: Option<&str>,
147 subject: Option<&str>,
148 issued_at_ms: u64,
149 ttl_ms: u64,
150 ) -> Result<String, CommandTokenError> {
151 self.create_v1_for_purpose_scoped(
152 purpose,
153 command_name,
154 None,
155 subject,
156 issued_at_ms,
157 ttl_ms,
158 )
159 }
160
161 pub fn create_v1_for_purpose_scoped(
163 &self,
164 purpose: &str,
165 command_name: Option<&str>,
166 scope: Option<&str>,
167 subject: Option<&str>,
168 issued_at_ms: u64,
169 ttl_ms: u64,
170 ) -> Result<String, CommandTokenError> {
171 validate_generated_claims(purpose, command_name, scope, subject)?;
172 let payload = serde_json::to_vec(&RuntimeTokenClaims {
173 version: "v1".to_string(),
174 purpose: purpose.to_string(),
175 command_name: command_name.map(ToOwned::to_owned),
176 scope: scope.map(ToOwned::to_owned),
177 subject: subject.map(ToOwned::to_owned),
178 issued_at_ms,
179 expires_at_ms: issued_at_ms.saturating_add(ttl_ms),
180 jti: None,
181 request_hash: None,
182 })
183 .map_err(|_| CommandTokenError::InvalidFormat)?;
184 let signature = self
185 .provider
186 .sign(&payload, &self.claims)
187 .map_err(|_| CommandTokenError::Unauthorized)?;
188 Ok(format!(
189 "v1.{}.{}",
190 encode_hex(&payload),
191 encode_hex(&signature)
192 ))
193 }
194
195 #[allow(clippy::too_many_arguments)]
196 pub fn create_v1_with_jti_and_hash(
198 &self,
199 purpose: &str,
200 command_name: Option<&str>,
201 scope: Option<&str>,
202 subject: Option<&str>,
203 issued_at_ms: u64,
204 ttl_ms: u64,
205 jti: Option<String>,
206 request_hash: Option<String>,
207 ) -> Result<String, CommandTokenError> {
208 validate_generated_claims(purpose, command_name, scope, subject)?;
209 let payload = serde_json::to_vec(&RuntimeTokenClaims {
210 version: "v1".to_string(),
211 purpose: purpose.to_string(),
212 command_name: command_name.map(ToOwned::to_owned),
213 scope: scope.map(ToOwned::to_owned),
214 subject: subject.map(ToOwned::to_owned),
215 issued_at_ms,
216 expires_at_ms: issued_at_ms.saturating_add(ttl_ms),
217 jti,
218 request_hash,
219 })
220 .map_err(|_| CommandTokenError::InvalidFormat)?;
221 let signature = self
222 .provider
223 .sign(&payload, &self.claims)
224 .map_err(|_| CommandTokenError::Unauthorized)?;
225 Ok(format!(
226 "v1.{}.{}",
227 encode_hex(&payload),
228 encode_hex(&signature)
229 ))
230 }
231}
232
233impl<'a, P: TokenProvider> CommandTokenValidator<'a, P> {
234 pub fn new(provider: &'a P, claims: TokenClaims) -> Self {
236 Self { provider, claims }
237 }
238
239 pub fn validate(
241 &self,
242 token: &str,
243 command_name: &str,
244 now_ms: u64,
245 ) -> Result<(), CommandTokenError> {
246 self.validate_for_purpose(token, "command", Some(command_name), now_ms)
247 }
248
249 pub fn validate_for_purpose(
251 &self,
252 token: &str,
253 expected_purpose: &str,
254 command_name: Option<&str>,
255 now_ms: u64,
256 ) -> Result<(), CommandTokenError> {
257 self.validate_and_get_claims(token, expected_purpose, command_name, now_ms, None)?;
258 Ok(())
259 }
260
261 pub fn validate_and_get_claims(
263 &self,
264 token: &str,
265 expected_purpose: &str,
266 command_name: Option<&str>,
267 now_ms: u64,
268 expected_request_hash: Option<&str>,
269 ) -> Result<RuntimeTokenClaims, CommandTokenError> {
270 if let Some((payload, signature)) = parse_v1_token(token) {
272 self.provider
273 .verify(&payload, &signature, &self.claims)
274 .map_err(|_| CommandTokenError::Unauthorized)?;
275 let claims = serde_json::from_slice::<RuntimeTokenClaims>(&payload)
276 .map_err(|_| CommandTokenError::InvalidFormat)?;
277 if claims.version != "v1" || claims.purpose != expected_purpose {
278 return Err(CommandTokenError::Unauthorized);
279 }
280 if claims.expires_at_ms <= now_ms {
281 return Err(CommandTokenError::Unauthorized);
282 }
283 validate_claim_scope(&claims, command_name)?;
284
285 if let Some(hash) = &claims.request_hash {
286 if let Some(expected) = expected_request_hash {
287 if hash != expected {
288 return Err(CommandTokenError::Forbidden);
289 }
290 } else {
291 return Err(CommandTokenError::Unauthorized);
292 }
293 }
294
295 return Ok(claims);
296 }
297
298 Err(CommandTokenError::InvalidFormat)
299 }
300}
301
302#[derive(Debug, Clone)]
304pub struct RequestValidationDetails {
305 pub purpose: String,
307 pub name: String,
309 pub id: String,
311 pub idempotency_key: Option<String>,
313 pub payload: String,
315 pub subject: Option<String>,
317 pub audience: Option<String>,
319}
320
321pub fn compute_request_hash(details: &RequestValidationDetails) -> String {
323 use sha2::{Digest, Sha256};
324 let mut hasher = Sha256::new();
325 hasher.update(details.purpose.as_bytes());
326 hasher.update(b"|");
327 hasher.update(details.name.as_bytes());
328 hasher.update(b"|");
329 hasher.update(details.id.as_bytes());
330 hasher.update(b"|");
331 if let Some(key) = &details.idempotency_key {
332 hasher.update(key.as_bytes());
333 }
334 hasher.update(b"|");
335 hasher.update(details.payload.as_bytes());
336 hasher.update(b"|");
337 if let Some(sub) = &details.subject {
338 hasher.update(sub.as_bytes());
339 }
340 hasher.update(b"|");
341 if let Some(aud) = &details.audience {
342 hasher.update(aud.as_bytes());
343 }
344 let result = hasher.finalize();
345 let mut hex = String::with_capacity(result.len() * 2);
346 for byte in result {
347 hex.push_str(&format!("{:02x}", byte));
348 }
349 hex
350}
351
352fn validate_generated_claims(
353 purpose: &str,
354 command_name: Option<&str>,
355 scope: Option<&str>,
356 subject: Option<&str>,
357) -> Result<(), CommandTokenError> {
358 match purpose {
360 "command" | "query" => match (command_name, scope) {
361 (_, Some("*")) if subject == Some(LOCAL_ADMIN_SUBJECT) => Ok(()),
362 (Some(_), None) => Ok(()),
363 _ => Err(CommandTokenError::InvalidFormat),
364 },
365 "sync" if command_name.is_none() && scope.is_none() => Ok(()),
366 "peer" => match (command_name, scope) {
367 (_, Some("*")) if subject == Some(LOCAL_ADMIN_SUBJECT) => Ok(()),
368 (None, None) => Ok(()),
369 _ => Err(CommandTokenError::InvalidFormat),
370 },
371 _ => Err(CommandTokenError::InvalidFormat),
372 }
373}
374
375fn validate_claim_scope(
376 claims: &RuntimeTokenClaims,
377 expected_name: Option<&str>,
378) -> Result<(), CommandTokenError> {
379 match claims.purpose.as_str() {
380 "command" | "query" => {
381 if claims.scope.as_deref() == Some("*") {
382 return if claims.subject.as_deref() == Some(LOCAL_ADMIN_SUBJECT) {
383 Ok(())
384 } else {
385 Err(CommandTokenError::Unauthorized)
386 };
387 }
388 if claims.scope.is_some() {
389 return Err(CommandTokenError::Unauthorized);
390 }
391 let name = claims
392 .command_name
393 .as_deref()
394 .ok_or(CommandTokenError::Unauthorized)?;
395 let expected_name = expected_name.ok_or(CommandTokenError::Unauthorized)?;
396 if name != expected_name {
397 return Err(CommandTokenError::Forbidden);
398 }
399 Ok(())
400 }
401 "sync" if claims.command_name.is_none() && claims.scope.is_none() => Ok(()),
402 "peer" => {
403 if claims.scope.as_deref() == Some("*") {
404 return if claims.subject.as_deref() == Some(LOCAL_ADMIN_SUBJECT) {
405 Ok(())
406 } else {
407 Err(CommandTokenError::Unauthorized)
408 };
409 }
410 if claims.command_name.is_none() && claims.scope.is_none() {
411 return Ok(());
412 }
413 Err(CommandTokenError::Unauthorized)
414 }
415 _ => Err(CommandTokenError::Unauthorized),
416 }
417}
418
419fn parse_v1_token(token: &str) -> Option<(Vec<u8>, Vec<u8>)> {
420 let token = token.strip_prefix("v1.")?;
421 let (payload_hex, signature_hex) = token.split_once('.')?;
422 let payload = decode_hex(payload_hex)?;
423 let signature = decode_hex(signature_hex)?;
424 Some((payload, signature))
425}
426
427fn encode_hex(bytes: &[u8]) -> String {
428 const HEX: &[u8; 16] = b"0123456789abcdef";
429 let mut out = String::with_capacity(bytes.len() * 2);
430 for byte in bytes {
431 out.push(HEX[(byte >> 4) as usize] as char);
432 out.push(HEX[(byte & 0x0f) as usize] as char);
433 }
434 out
435}
436
437fn decode_hex(input: &str) -> Option<Vec<u8>> {
438 if input.is_empty() || !input.len().is_multiple_of(2) {
439 return None;
440 }
441 let mut output = Vec::with_capacity(input.len() / 2);
442 let bytes = input.as_bytes();
443 let mut index = 0usize;
444 while index < bytes.len() {
445 let hi = hex_value(bytes[index])?;
446 let lo = hex_value(bytes[index + 1])?;
447 output.push((hi << 4) | lo);
448 index += 2;
449 }
450 Some(output)
451}
452
453fn hex_value(byte: u8) -> Option<u8> {
454 match byte {
455 b'0'..=b'9' => Some(byte - b'0'),
456 b'a'..=b'f' => Some(10 + byte - b'a'),
457 b'A'..=b'F' => Some(10 + byte - b'A'),
458 _ => None,
459 }
460}
461
462#[cfg(test)]
463mod token_tests;