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