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