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