Skip to main content

appcore_security/
token.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: token.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/05/31 13:38:42 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/07/24 16:07:49 by dnettoRaw
8//      ###########      S: 1.0.1-rc.8
9// =============================================================================
10
11//! Token contracts for internal runtime trust and delegation.
12// ATENÇÃO: Isso não é segurança perfeita. Não protege contra invasão física ou comprometimento das chaves.
13// A segurança real depende de chaves simétricas bem protegidas e tráfego encapsulado em TLS/mTLS.
14
15pub 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
26/// Default lifetime for locally issued Runtime tokens.
27pub const DEFAULT_RUNTIME_TOKEN_TTL_MS: u64 = 60_000;
28/// Maximum accepted clock skew for Runtime token issue timestamps.
29pub const MAX_RUNTIME_TOKEN_CLOCK_SKEW_MS: u64 = 300_000;
30/// Explicit subject required for wildcard local-administration scope.
31pub const LOCAL_ADMIN_SUBJECT: &str = "local-admin";
32
33/// Security-local result type.
34pub type SecurityResult<T> = Result<T, SecurityError>;
35
36/// Security-local errors.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum SecurityError {
39    /// Requested security operation is unsupported.
40    Unsupported(&'static str),
41    /// Token structure or claims are invalid.
42    InvalidToken,
43    /// Signature or payload verification failed.
44    VerificationFailed,
45    /// Secret reference is unsafe or malformed.
46    InvalidSecretRef,
47    /// Referenced secret material is unavailable.
48    SecretUnavailable,
49}
50
51/// Command-token specific validation/generation errors.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum CommandTokenError {
54    /// Bearer token structure or requested claim combination is invalid.
55    InvalidFormat,
56    /// Token is absent, expired, invalid or for another purpose.
57    Unauthorized,
58    /// Token is valid but does not permit the requested resource.
59    Forbidden,
60}
61
62/// Minimal token claims contract for internal token exchange.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct TokenClaims {
65    /// Expected token issuer.
66    pub issuer: String,
67    /// Expected token audience.
68    pub audience: String,
69    /// Provider-specific non-secret salt label.
70    pub salt: String,
71    /// Token lifetime in milliseconds.
72    pub ttl_ms: u64,
73}
74
75/// Bearer claims contract for `/command` authentication (v1).
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct RuntimeTokenClaims {
78    /// Claims schema version.
79    pub version: String,
80    /// Isolated token purpose such as command, query, sync or peer.
81    pub purpose: String,
82    /// Optional command or query name.
83    pub command_name: Option<String>,
84    /// Optional explicit scope.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub scope: Option<String>,
87    /// Optional authenticated subject.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub subject: Option<String>,
90    /// Issue timestamp in Unix milliseconds.
91    pub issued_at_ms: u64,
92    /// Expiry timestamp in Unix milliseconds.
93    pub expires_at_ms: u64,
94    /// Optional single-use token identity persisted by the host replay store.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub jti: Option<String>,
97    // Hash SHA-256 do payload. Protege contra adulteração em trânsito, mas exige determinismo exato de ambos os lados.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    /// Optional digest binding the token to one exact request.
100    pub request_hash: Option<String>,
101}
102
103/// Factory de tokens bearer do runtime.
104pub struct CommandTokenFactory<'a, P: TokenProvider> {
105    provider: &'a P,
106    claims: TokenClaims,
107}
108
109/// Validador de tokens bearer do runtime.
110pub struct CommandTokenValidator<'a, P: TokenProvider> {
111    provider: &'a P,
112    claims: TokenClaims,
113    clock_skew_ms: u64,
114}
115
116/// Contrato de assinatura e validação de payloads.
117pub trait TokenProvider {
118    /// Authenticates and encrypts payload bytes.
119    fn seal(&self, payload: &[u8], claims: &TokenClaims) -> SecurityResult<Vec<u8>>;
120    /// Authenticates and decrypts token bytes.
121    fn open(&self, token: &[u8], claims: &TokenClaims) -> SecurityResult<Vec<u8>>;
122    /// Produces an authenticated signature carrying payload bytes.
123    fn sign(&self, payload: &[u8], claims: &TokenClaims) -> SecurityResult<Vec<u8>>;
124    /// Verifies that a signature carries the expected payload and claims.
125    fn verify(&self, payload: &[u8], signature: &[u8], claims: &TokenClaims) -> SecurityResult<()>;
126}
127
128impl<'a, P: TokenProvider> CommandTokenFactory<'a, P> {
129    /// Creates a bearer token factory.
130    pub fn new(provider: &'a P, claims: TokenClaims) -> Self {
131        Self { provider, claims }
132    }
133
134    /// Issues a V1 command token scoped to one command name.
135    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    /// Issues a V1 command token with an explicit scope.
146    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    /// Issues a V1 token for an isolated purpose.
165    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    /// Issues a V1 purpose token with an explicit scope.
184    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    /// Issues a request-bound V1 token with optional replay identity.
220    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    /// Creates a bearer token validator.
260    pub fn new(provider: &'a P, claims: TokenClaims) -> Self {
261        Self {
262            provider,
263            claims,
264            clock_skew_ms: 0,
265        }
266    }
267
268    /// Allows bounded positive clock skew for issue timestamps.
269    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    /// Validates a command token for one command name.
278    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    /// Validates a token for an isolated purpose and optional resource name.
288    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    /// Validates a token and returns its trusted claims.
300    ///
301    /// Rejects envelopes exceeding the public byte limits before hex decoding
302    /// or invoking the provider. Invalid envelopes return `InvalidFormat`.
303    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        // Separação rígida de propósitos (command, query, sync) para evitar que um token de query rode comandos.
312        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    // O escopo coringa '*' é restrito a comandos/queries assinados pelo subject 'local-admin'.
356    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    // Check both components before allocating either decoded buffer.
423    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;