1pub use crate::request_hash::{compute_request_hash, RequestValidationDetails};
16use serde::{Deserialize, Serialize};
17
18pub const DEFAULT_RUNTIME_TOKEN_TTL_MS: u64 = 60_000;
20pub const LOCAL_ADMIN_SUBJECT: &str = "local-admin";
22
23pub type SecurityResult<T> = Result<T, SecurityError>;
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum SecurityError {
29 Unsupported(&'static str),
31 InvalidToken,
33 VerificationFailed,
35 InvalidSecretRef,
37 SecretUnavailable,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum CommandTokenError {
44 InvalidFormat,
46 Unauthorized,
48 Forbidden,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct TokenClaims {
55 pub issuer: String,
57 pub audience: String,
59 pub salt: String,
61 pub ttl_ms: u64,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct RuntimeTokenClaims {
68 pub version: String,
70 pub purpose: String,
72 pub command_name: Option<String>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub scope: Option<String>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub subject: Option<String>,
80 pub issued_at_ms: u64,
82 pub expires_at_ms: u64,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub jti: Option<String>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub request_hash: Option<String>,
91}
92
93pub struct CommandTokenFactory<'a, P: TokenProvider> {
95 provider: &'a P,
96 claims: TokenClaims,
97}
98
99pub struct CommandTokenValidator<'a, P: TokenProvider> {
101 provider: &'a P,
102 claims: TokenClaims,
103}
104
105pub trait TokenProvider {
107 fn seal(&self, payload: &[u8], claims: &TokenClaims) -> SecurityResult<Vec<u8>>;
109 fn open(&self, token: &[u8], claims: &TokenClaims) -> SecurityResult<Vec<u8>>;
111 fn sign(&self, payload: &[u8], claims: &TokenClaims) -> SecurityResult<Vec<u8>>;
113 fn verify(&self, payload: &[u8], signature: &[u8], claims: &TokenClaims) -> SecurityResult<()>;
115}
116
117impl<'a, P: TokenProvider> CommandTokenFactory<'a, P> {
118 pub fn new(provider: &'a P, claims: TokenClaims) -> Self {
120 Self { provider, claims }
121 }
122
123 pub fn create_v1(
125 &self,
126 command_name: Option<&str>,
127 subject: Option<&str>,
128 issued_at_ms: u64,
129 ttl_ms: u64,
130 ) -> Result<String, CommandTokenError> {
131 self.create_v1_scoped(command_name, None, subject, issued_at_ms, ttl_ms)
132 }
133
134 pub fn create_v1_scoped(
136 &self,
137 command_name: Option<&str>,
138 scope: Option<&str>,
139 subject: Option<&str>,
140 issued_at_ms: u64,
141 ttl_ms: u64,
142 ) -> Result<String, CommandTokenError> {
143 self.create_v1_for_purpose_scoped(
144 "command",
145 command_name,
146 scope,
147 subject,
148 issued_at_ms,
149 ttl_ms,
150 )
151 }
152
153 pub fn create_v1_for_purpose(
155 &self,
156 purpose: &str,
157 command_name: Option<&str>,
158 subject: Option<&str>,
159 issued_at_ms: u64,
160 ttl_ms: u64,
161 ) -> Result<String, CommandTokenError> {
162 self.create_v1_for_purpose_scoped(
163 purpose,
164 command_name,
165 None,
166 subject,
167 issued_at_ms,
168 ttl_ms,
169 )
170 }
171
172 pub fn create_v1_for_purpose_scoped(
174 &self,
175 purpose: &str,
176 command_name: Option<&str>,
177 scope: Option<&str>,
178 subject: Option<&str>,
179 issued_at_ms: u64,
180 ttl_ms: u64,
181 ) -> Result<String, CommandTokenError> {
182 validate_generated_claims(purpose, command_name, scope, subject)?;
183 let payload = serde_json::to_vec(&RuntimeTokenClaims {
184 version: "v1".to_string(),
185 purpose: purpose.to_string(),
186 command_name: command_name.map(ToOwned::to_owned),
187 scope: scope.map(ToOwned::to_owned),
188 subject: subject.map(ToOwned::to_owned),
189 issued_at_ms,
190 expires_at_ms: issued_at_ms.saturating_add(ttl_ms),
191 jti: None,
192 request_hash: None,
193 })
194 .map_err(|_| CommandTokenError::InvalidFormat)?;
195 let signature = self
196 .provider
197 .sign(&payload, &self.claims)
198 .map_err(|_| CommandTokenError::Unauthorized)?;
199 Ok(format!(
200 "v1.{}.{}",
201 encode_hex(&payload),
202 encode_hex(&signature)
203 ))
204 }
205
206 #[allow(clippy::too_many_arguments)]
207 pub fn create_v1_with_jti_and_hash(
209 &self,
210 purpose: &str,
211 command_name: Option<&str>,
212 scope: Option<&str>,
213 subject: Option<&str>,
214 issued_at_ms: u64,
215 ttl_ms: u64,
216 jti: Option<String>,
217 request_hash: Option<String>,
218 ) -> Result<String, CommandTokenError> {
219 validate_generated_claims(purpose, command_name, scope, subject)?;
220 let payload = serde_json::to_vec(&RuntimeTokenClaims {
221 version: "v1".to_string(),
222 purpose: purpose.to_string(),
223 command_name: command_name.map(ToOwned::to_owned),
224 scope: scope.map(ToOwned::to_owned),
225 subject: subject.map(ToOwned::to_owned),
226 issued_at_ms,
227 expires_at_ms: issued_at_ms.saturating_add(ttl_ms),
228 jti,
229 request_hash,
230 })
231 .map_err(|_| CommandTokenError::InvalidFormat)?;
232 let signature = self
233 .provider
234 .sign(&payload, &self.claims)
235 .map_err(|_| CommandTokenError::Unauthorized)?;
236 Ok(format!(
237 "v1.{}.{}",
238 encode_hex(&payload),
239 encode_hex(&signature)
240 ))
241 }
242}
243
244impl<'a, P: TokenProvider> CommandTokenValidator<'a, P> {
245 pub fn new(provider: &'a P, claims: TokenClaims) -> Self {
247 Self { provider, claims }
248 }
249
250 pub fn validate(
252 &self,
253 token: &str,
254 command_name: &str,
255 now_ms: u64,
256 ) -> Result<(), CommandTokenError> {
257 self.validate_for_purpose(token, "command", Some(command_name), now_ms)
258 }
259
260 pub fn validate_for_purpose(
262 &self,
263 token: &str,
264 expected_purpose: &str,
265 command_name: Option<&str>,
266 now_ms: u64,
267 ) -> Result<(), CommandTokenError> {
268 self.validate_and_get_claims(token, expected_purpose, command_name, now_ms, None)?;
269 Ok(())
270 }
271
272 pub fn validate_and_get_claims(
274 &self,
275 token: &str,
276 expected_purpose: &str,
277 command_name: Option<&str>,
278 now_ms: u64,
279 expected_request_hash: Option<&str>,
280 ) -> Result<RuntimeTokenClaims, CommandTokenError> {
281 if let Some((payload, signature)) = parse_v1_token(token) {
283 self.provider
284 .verify(&payload, &signature, &self.claims)
285 .map_err(|_| CommandTokenError::Unauthorized)?;
286 let claims = serde_json::from_slice::<RuntimeTokenClaims>(&payload)
287 .map_err(|_| CommandTokenError::InvalidFormat)?;
288 if claims.version != "v1" || claims.purpose != expected_purpose {
289 return Err(CommandTokenError::Unauthorized);
290 }
291 if claims.expires_at_ms <= now_ms {
292 return Err(CommandTokenError::Unauthorized);
293 }
294 validate_claim_scope(&claims, command_name)?;
295
296 if let Some(hash) = &claims.request_hash {
297 if let Some(expected) = expected_request_hash {
298 if hash != expected {
299 return Err(CommandTokenError::Forbidden);
300 }
301 } else {
302 return Err(CommandTokenError::Unauthorized);
303 }
304 }
305
306 return Ok(claims);
307 }
308
309 Err(CommandTokenError::InvalidFormat)
310 }
311}
312
313fn validate_generated_claims(
314 purpose: &str,
315 command_name: Option<&str>,
316 scope: Option<&str>,
317 subject: Option<&str>,
318) -> Result<(), CommandTokenError> {
319 match purpose {
321 "command" | "query" => match (command_name, scope) {
322 (_, Some("*")) if subject == Some(LOCAL_ADMIN_SUBJECT) => Ok(()),
323 (Some(_), None) => Ok(()),
324 _ => Err(CommandTokenError::InvalidFormat),
325 },
326 "sync" if command_name.is_none() && scope.is_none() => Ok(()),
327 "peer" => match (command_name, scope) {
328 (_, Some("*")) if subject == Some(LOCAL_ADMIN_SUBJECT) => Ok(()),
329 (None, None) => Ok(()),
330 _ => Err(CommandTokenError::InvalidFormat),
331 },
332 _ => Err(CommandTokenError::InvalidFormat),
333 }
334}
335
336fn validate_claim_scope(
337 claims: &RuntimeTokenClaims,
338 expected_name: Option<&str>,
339) -> Result<(), CommandTokenError> {
340 match claims.purpose.as_str() {
341 "command" | "query" => {
342 if claims.scope.as_deref() == Some("*") {
343 return if claims.subject.as_deref() == Some(LOCAL_ADMIN_SUBJECT) {
344 Ok(())
345 } else {
346 Err(CommandTokenError::Unauthorized)
347 };
348 }
349 if claims.scope.is_some() {
350 return Err(CommandTokenError::Unauthorized);
351 }
352 let name = claims
353 .command_name
354 .as_deref()
355 .ok_or(CommandTokenError::Unauthorized)?;
356 let expected_name = expected_name.ok_or(CommandTokenError::Unauthorized)?;
357 if name != expected_name {
358 return Err(CommandTokenError::Forbidden);
359 }
360 Ok(())
361 }
362 "sync" if claims.command_name.is_none() && claims.scope.is_none() => Ok(()),
363 "peer" => {
364 if claims.scope.as_deref() == Some("*") {
365 return if claims.subject.as_deref() == Some(LOCAL_ADMIN_SUBJECT) {
366 Ok(())
367 } else {
368 Err(CommandTokenError::Unauthorized)
369 };
370 }
371 if claims.command_name.is_none() && claims.scope.is_none() {
372 return Ok(());
373 }
374 Err(CommandTokenError::Unauthorized)
375 }
376 _ => Err(CommandTokenError::Unauthorized),
377 }
378}
379
380fn parse_v1_token(token: &str) -> Option<(Vec<u8>, Vec<u8>)> {
381 let token = token.strip_prefix("v1.")?;
382 let (payload_hex, signature_hex) = token.split_once('.')?;
383 let payload = decode_hex(payload_hex)?;
384 let signature = decode_hex(signature_hex)?;
385 Some((payload, signature))
386}
387
388fn encode_hex(bytes: &[u8]) -> String {
389 const HEX: &[u8; 16] = b"0123456789abcdef";
390 let mut out = String::with_capacity(bytes.len() * 2);
391 for byte in bytes {
392 out.push(HEX[(byte >> 4) as usize] as char);
393 out.push(HEX[(byte & 0x0f) as usize] as char);
394 }
395 out
396}
397
398fn decode_hex(input: &str) -> Option<Vec<u8>> {
399 if input.is_empty() || !input.len().is_multiple_of(2) {
400 return None;
401 }
402 let mut output = Vec::with_capacity(input.len() / 2);
403 let bytes = input.as_bytes();
404 let mut index = 0usize;
405 while index < bytes.len() {
406 let hi = hex_value(bytes[index])?;
407 let lo = hex_value(bytes[index + 1])?;
408 output.push((hi << 4) | lo);
409 index += 2;
410 }
411 Some(output)
412}
413
414fn hex_value(byte: u8) -> Option<u8> {
415 match byte {
416 b'0'..=b'9' => Some(byte - b'0'),
417 b'a'..=b'f' => Some(10 + byte - b'a'),
418 b'A'..=b'F' => Some(10 + byte - b'A'),
419 _ => None,
420 }
421}
422
423#[cfg(test)]
424mod token_tests;