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::{compute_request_hash, RequestValidationDetails};
16use serde::{Deserialize, Serialize};
17
18/// Default lifetime for locally issued Runtime tokens.
19pub const DEFAULT_RUNTIME_TOKEN_TTL_MS: u64 = 60_000;
20/// Explicit subject required for wildcard local-administration scope.
21pub const LOCAL_ADMIN_SUBJECT: &str = "local-admin";
22
23/// Security-local result type.
24pub type SecurityResult<T> = Result<T, SecurityError>;
25
26/// Security-local errors.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum SecurityError {
29    /// Requested security operation is unsupported.
30    Unsupported(&'static str),
31    /// Token structure or claims are invalid.
32    InvalidToken,
33    /// Signature or payload verification failed.
34    VerificationFailed,
35    /// Secret reference is unsafe or malformed.
36    InvalidSecretRef,
37    /// Referenced secret material is unavailable.
38    SecretUnavailable,
39}
40
41/// Command-token specific validation/generation errors.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum CommandTokenError {
44    /// Bearer token structure or requested claim combination is invalid.
45    InvalidFormat,
46    /// Token is absent, expired, invalid or for another purpose.
47    Unauthorized,
48    /// Token is valid but does not permit the requested resource.
49    Forbidden,
50}
51
52/// Minimal token claims contract for internal token exchange.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct TokenClaims {
55    /// Expected token issuer.
56    pub issuer: String,
57    /// Expected token audience.
58    pub audience: String,
59    /// Provider-specific non-secret salt label.
60    pub salt: String,
61    /// Token lifetime in milliseconds.
62    pub ttl_ms: u64,
63}
64
65/// Bearer claims contract for `/command` authentication (v1).
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct RuntimeTokenClaims {
68    /// Claims schema version.
69    pub version: String,
70    /// Isolated token purpose such as command, query, sync or peer.
71    pub purpose: String,
72    /// Optional command or query name.
73    pub command_name: Option<String>,
74    /// Optional explicit scope.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub scope: Option<String>,
77    /// Optional authenticated subject.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub subject: Option<String>,
80    /// Issue timestamp in Unix milliseconds.
81    pub issued_at_ms: u64,
82    /// Expiry timestamp in Unix milliseconds.
83    pub expires_at_ms: u64,
84    /// Optional single-use token identity persisted by the host replay store.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub jti: Option<String>,
87    // Hash SHA-256 do payload. Protege contra adulteração em trânsito, mas exige determinismo exato de ambos os lados.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    /// Optional digest binding the token to one exact request.
90    pub request_hash: Option<String>,
91}
92
93/// Factory de tokens bearer do runtime.
94pub struct CommandTokenFactory<'a, P: TokenProvider> {
95    provider: &'a P,
96    claims: TokenClaims,
97}
98
99/// Validador de tokens bearer do runtime.
100pub struct CommandTokenValidator<'a, P: TokenProvider> {
101    provider: &'a P,
102    claims: TokenClaims,
103}
104
105/// Contrato de assinatura e validação de payloads.
106pub trait TokenProvider {
107    /// Authenticates and encrypts payload bytes.
108    fn seal(&self, payload: &[u8], claims: &TokenClaims) -> SecurityResult<Vec<u8>>;
109    /// Authenticates and decrypts token bytes.
110    fn open(&self, token: &[u8], claims: &TokenClaims) -> SecurityResult<Vec<u8>>;
111    /// Produces an authenticated signature carrying payload bytes.
112    fn sign(&self, payload: &[u8], claims: &TokenClaims) -> SecurityResult<Vec<u8>>;
113    /// Verifies that a signature carries the expected payload and claims.
114    fn verify(&self, payload: &[u8], signature: &[u8], claims: &TokenClaims) -> SecurityResult<()>;
115}
116
117impl<'a, P: TokenProvider> CommandTokenFactory<'a, P> {
118    /// Creates a bearer token factory.
119    pub fn new(provider: &'a P, claims: TokenClaims) -> Self {
120        Self { provider, claims }
121    }
122
123    /// Issues a V1 command token scoped to one command name.
124    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    /// Issues a V1 command token with an explicit scope.
135    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    /// Issues a V1 token for an isolated purpose.
154    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    /// Issues a V1 purpose token with an explicit scope.
173    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    /// Issues a request-bound V1 token with optional replay identity.
208    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    /// Creates a bearer token validator.
246    pub fn new(provider: &'a P, claims: TokenClaims) -> Self {
247        Self { provider, claims }
248    }
249
250    /// Validates a command token for one command name.
251    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    /// Validates a token for an isolated purpose and optional resource name.
261    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    /// Validates a token and returns its trusted claims.
273    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        // Separação rígida de propósitos (command, query, sync) para evitar que um token de query rode comandos.
282        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    // O escopo coringa '*' é restrito a comandos/queries assinados pelo subject 'local-admin'.
320    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;