Skip to main content

appcore_security/
token.rs

1//! Token contracts for internal runtime trust and delegation.
2// ATENÇÃO: Isso não é segurança perfeita. Não protege contra invasão física ou comprometimento das chaves.
3// A segurança real depende de chaves simétricas bem protegidas e tráfego encapsulado em TLS/mTLS.
4
5use serde::{Deserialize, Serialize};
6
7/// Default lifetime for locally issued Runtime tokens.
8pub const DEFAULT_RUNTIME_TOKEN_TTL_MS: u64 = 60_000;
9/// Explicit subject required for wildcard local-administration scope.
10pub const LOCAL_ADMIN_SUBJECT: &str = "local-admin";
11
12/// Security-local result type.
13pub type SecurityResult<T> = Result<T, SecurityError>;
14
15/// Security-local errors.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum SecurityError {
18    /// Requested security operation is unsupported.
19    Unsupported(&'static str),
20    /// Token structure or claims are invalid.
21    InvalidToken,
22    /// Signature or payload verification failed.
23    VerificationFailed,
24    /// Secret reference is unsafe or malformed.
25    InvalidSecretRef,
26    /// Referenced secret material is unavailable.
27    SecretUnavailable,
28}
29
30/// Command-token specific validation/generation errors.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum CommandTokenError {
33    /// Bearer token structure or requested claim combination is invalid.
34    InvalidFormat,
35    /// Token is absent, expired, invalid or for another purpose.
36    Unauthorized,
37    /// Token is valid but does not permit the requested resource.
38    Forbidden,
39}
40
41/// Minimal token claims contract for internal token exchange.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct TokenClaims {
44    /// Expected token issuer.
45    pub issuer: String,
46    /// Expected token audience.
47    pub audience: String,
48    /// Provider-specific non-secret salt label.
49    pub salt: String,
50    /// Token lifetime in milliseconds.
51    pub ttl_ms: u64,
52}
53
54/// Bearer claims contract for `/command` authentication (v1).
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct RuntimeTokenClaims {
57    /// Claims schema version.
58    pub version: String,
59    /// Isolated token purpose such as command, query, sync or peer.
60    pub purpose: String,
61    /// Optional command or query name.
62    pub command_name: Option<String>,
63    /// Optional explicit scope.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub scope: Option<String>,
66    /// Optional authenticated subject.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub subject: Option<String>,
69    /// Issue timestamp in Unix milliseconds.
70    pub issued_at_ms: u64,
71    /// Expiry timestamp in Unix milliseconds.
72    pub expires_at_ms: u64,
73    /// Optional single-use token identity persisted by the host replay store.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub jti: Option<String>,
76    // Hash SHA-256 do payload. Protege contra adulteração em trânsito, mas exige determinismo exato de ambos os lados.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    /// Optional digest binding the token to one exact request.
79    pub request_hash: Option<String>,
80}
81
82/// Factory de tokens bearer do runtime.
83pub struct CommandTokenFactory<'a, P: TokenProvider> {
84    provider: &'a P,
85    claims: TokenClaims,
86}
87
88/// Validador de tokens bearer do runtime.
89pub struct CommandTokenValidator<'a, P: TokenProvider> {
90    provider: &'a P,
91    claims: TokenClaims,
92}
93
94/// Contrato de assinatura e validação de payloads.
95pub trait TokenProvider {
96    /// Authenticates and encrypts payload bytes.
97    fn seal(&self, payload: &[u8], claims: &TokenClaims) -> SecurityResult<Vec<u8>>;
98    /// Authenticates and decrypts token bytes.
99    fn open(&self, token: &[u8], claims: &TokenClaims) -> SecurityResult<Vec<u8>>;
100    /// Produces an authenticated signature carrying payload bytes.
101    fn sign(&self, payload: &[u8], claims: &TokenClaims) -> SecurityResult<Vec<u8>>;
102    /// Verifies that a signature carries the expected payload and claims.
103    fn verify(&self, payload: &[u8], signature: &[u8], claims: &TokenClaims) -> SecurityResult<()>;
104}
105
106impl<'a, P: TokenProvider> CommandTokenFactory<'a, P> {
107    /// Creates a bearer token factory.
108    pub fn new(provider: &'a P, claims: TokenClaims) -> Self {
109        Self { provider, claims }
110    }
111
112    /// Issues a V1 command token scoped to one command name.
113    pub fn create_v1(
114        &self,
115        command_name: Option<&str>,
116        subject: Option<&str>,
117        issued_at_ms: u64,
118        ttl_ms: u64,
119    ) -> Result<String, CommandTokenError> {
120        self.create_v1_scoped(command_name, None, subject, issued_at_ms, ttl_ms)
121    }
122
123    /// Issues a V1 command token with an explicit scope.
124    pub fn create_v1_scoped(
125        &self,
126        command_name: Option<&str>,
127        scope: Option<&str>,
128        subject: Option<&str>,
129        issued_at_ms: u64,
130        ttl_ms: u64,
131    ) -> Result<String, CommandTokenError> {
132        self.create_v1_for_purpose_scoped(
133            "command",
134            command_name,
135            scope,
136            subject,
137            issued_at_ms,
138            ttl_ms,
139        )
140    }
141
142    /// Issues a V1 token for an isolated purpose.
143    pub fn create_v1_for_purpose(
144        &self,
145        purpose: &str,
146        command_name: 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            purpose,
153            command_name,
154            None,
155            subject,
156            issued_at_ms,
157            ttl_ms,
158        )
159    }
160
161    /// Issues a V1 purpose token with an explicit scope.
162    pub fn create_v1_for_purpose_scoped(
163        &self,
164        purpose: &str,
165        command_name: Option<&str>,
166        scope: Option<&str>,
167        subject: Option<&str>,
168        issued_at_ms: u64,
169        ttl_ms: u64,
170    ) -> Result<String, CommandTokenError> {
171        validate_generated_claims(purpose, command_name, scope, subject)?;
172        let payload = serde_json::to_vec(&RuntimeTokenClaims {
173            version: "v1".to_string(),
174            purpose: purpose.to_string(),
175            command_name: command_name.map(ToOwned::to_owned),
176            scope: scope.map(ToOwned::to_owned),
177            subject: subject.map(ToOwned::to_owned),
178            issued_at_ms,
179            expires_at_ms: issued_at_ms.saturating_add(ttl_ms),
180            jti: None,
181            request_hash: None,
182        })
183        .map_err(|_| CommandTokenError::InvalidFormat)?;
184        let signature = self
185            .provider
186            .sign(&payload, &self.claims)
187            .map_err(|_| CommandTokenError::Unauthorized)?;
188        Ok(format!(
189            "v1.{}.{}",
190            encode_hex(&payload),
191            encode_hex(&signature)
192        ))
193    }
194
195    #[allow(clippy::too_many_arguments)]
196    /// Issues a request-bound V1 token with optional replay identity.
197    pub fn create_v1_with_jti_and_hash(
198        &self,
199        purpose: &str,
200        command_name: Option<&str>,
201        scope: Option<&str>,
202        subject: Option<&str>,
203        issued_at_ms: u64,
204        ttl_ms: u64,
205        jti: Option<String>,
206        request_hash: Option<String>,
207    ) -> Result<String, CommandTokenError> {
208        validate_generated_claims(purpose, command_name, scope, subject)?;
209        let payload = serde_json::to_vec(&RuntimeTokenClaims {
210            version: "v1".to_string(),
211            purpose: purpose.to_string(),
212            command_name: command_name.map(ToOwned::to_owned),
213            scope: scope.map(ToOwned::to_owned),
214            subject: subject.map(ToOwned::to_owned),
215            issued_at_ms,
216            expires_at_ms: issued_at_ms.saturating_add(ttl_ms),
217            jti,
218            request_hash,
219        })
220        .map_err(|_| CommandTokenError::InvalidFormat)?;
221        let signature = self
222            .provider
223            .sign(&payload, &self.claims)
224            .map_err(|_| CommandTokenError::Unauthorized)?;
225        Ok(format!(
226            "v1.{}.{}",
227            encode_hex(&payload),
228            encode_hex(&signature)
229        ))
230    }
231}
232
233impl<'a, P: TokenProvider> CommandTokenValidator<'a, P> {
234    /// Creates a bearer token validator.
235    pub fn new(provider: &'a P, claims: TokenClaims) -> Self {
236        Self { provider, claims }
237    }
238
239    /// Validates a command token for one command name.
240    pub fn validate(
241        &self,
242        token: &str,
243        command_name: &str,
244        now_ms: u64,
245    ) -> Result<(), CommandTokenError> {
246        self.validate_for_purpose(token, "command", Some(command_name), now_ms)
247    }
248
249    /// Validates a token for an isolated purpose and optional resource name.
250    pub fn validate_for_purpose(
251        &self,
252        token: &str,
253        expected_purpose: &str,
254        command_name: Option<&str>,
255        now_ms: u64,
256    ) -> Result<(), CommandTokenError> {
257        self.validate_and_get_claims(token, expected_purpose, command_name, now_ms, None)?;
258        Ok(())
259    }
260
261    /// Validates a token and returns its trusted claims.
262    pub fn validate_and_get_claims(
263        &self,
264        token: &str,
265        expected_purpose: &str,
266        command_name: Option<&str>,
267        now_ms: u64,
268        expected_request_hash: Option<&str>,
269    ) -> Result<RuntimeTokenClaims, CommandTokenError> {
270        // Separação rígida de propósitos (command, query, sync) para evitar que um token de query rode comandos.
271        if let Some((payload, signature)) = parse_v1_token(token) {
272            self.provider
273                .verify(&payload, &signature, &self.claims)
274                .map_err(|_| CommandTokenError::Unauthorized)?;
275            let claims = serde_json::from_slice::<RuntimeTokenClaims>(&payload)
276                .map_err(|_| CommandTokenError::InvalidFormat)?;
277            if claims.version != "v1" || claims.purpose != expected_purpose {
278                return Err(CommandTokenError::Unauthorized);
279            }
280            if claims.expires_at_ms <= now_ms {
281                return Err(CommandTokenError::Unauthorized);
282            }
283            validate_claim_scope(&claims, command_name)?;
284
285            if let Some(hash) = &claims.request_hash {
286                if let Some(expected) = expected_request_hash {
287                    if hash != expected {
288                        return Err(CommandTokenError::Forbidden);
289                    }
290                } else {
291                    return Err(CommandTokenError::Unauthorized);
292                }
293            }
294
295            return Ok(claims);
296        }
297
298        Err(CommandTokenError::InvalidFormat)
299    }
300}
301
302/// Details of an incoming query or command request used to verify its integrity.
303#[derive(Debug, Clone)]
304pub struct RequestValidationDetails {
305    /// Request purpose.
306    pub purpose: String,
307    /// Command or query name.
308    pub name: String,
309    /// Request identity.
310    pub id: String,
311    /// Optional idempotency key.
312    pub idempotency_key: Option<String>,
313    /// Canonical serialized payload.
314    pub payload: String,
315    /// Optional authenticated subject.
316    pub subject: Option<String>,
317    /// Optional target audience.
318    pub audience: Option<String>,
319}
320
321/// Computes the deterministic SHA-256 hash of a request's contents.
322pub fn compute_request_hash(details: &RequestValidationDetails) -> String {
323    use sha2::{Digest, Sha256};
324    let mut hasher = Sha256::new();
325    hasher.update(details.purpose.as_bytes());
326    hasher.update(b"|");
327    hasher.update(details.name.as_bytes());
328    hasher.update(b"|");
329    hasher.update(details.id.as_bytes());
330    hasher.update(b"|");
331    if let Some(key) = &details.idempotency_key {
332        hasher.update(key.as_bytes());
333    }
334    hasher.update(b"|");
335    hasher.update(details.payload.as_bytes());
336    hasher.update(b"|");
337    if let Some(sub) = &details.subject {
338        hasher.update(sub.as_bytes());
339    }
340    hasher.update(b"|");
341    if let Some(aud) = &details.audience {
342        hasher.update(aud.as_bytes());
343    }
344    let result = hasher.finalize();
345    let mut hex = String::with_capacity(result.len() * 2);
346    for byte in result {
347        hex.push_str(&format!("{:02x}", byte));
348    }
349    hex
350}
351
352fn validate_generated_claims(
353    purpose: &str,
354    command_name: Option<&str>,
355    scope: Option<&str>,
356    subject: Option<&str>,
357) -> Result<(), CommandTokenError> {
358    // O escopo coringa '*' é restrito a comandos/queries assinados pelo subject 'local-admin'.
359    match purpose {
360        "command" | "query" => match (command_name, scope) {
361            (_, Some("*")) if subject == Some(LOCAL_ADMIN_SUBJECT) => Ok(()),
362            (Some(_), None) => Ok(()),
363            _ => Err(CommandTokenError::InvalidFormat),
364        },
365        "sync" if command_name.is_none() && scope.is_none() => Ok(()),
366        "peer" => match (command_name, scope) {
367            (_, Some("*")) if subject == Some(LOCAL_ADMIN_SUBJECT) => Ok(()),
368            (None, None) => Ok(()),
369            _ => Err(CommandTokenError::InvalidFormat),
370        },
371        _ => Err(CommandTokenError::InvalidFormat),
372    }
373}
374
375fn validate_claim_scope(
376    claims: &RuntimeTokenClaims,
377    expected_name: Option<&str>,
378) -> Result<(), CommandTokenError> {
379    match claims.purpose.as_str() {
380        "command" | "query" => {
381            if claims.scope.as_deref() == Some("*") {
382                return if claims.subject.as_deref() == Some(LOCAL_ADMIN_SUBJECT) {
383                    Ok(())
384                } else {
385                    Err(CommandTokenError::Unauthorized)
386                };
387            }
388            if claims.scope.is_some() {
389                return Err(CommandTokenError::Unauthorized);
390            }
391            let name = claims
392                .command_name
393                .as_deref()
394                .ok_or(CommandTokenError::Unauthorized)?;
395            let expected_name = expected_name.ok_or(CommandTokenError::Unauthorized)?;
396            if name != expected_name {
397                return Err(CommandTokenError::Forbidden);
398            }
399            Ok(())
400        }
401        "sync" if claims.command_name.is_none() && claims.scope.is_none() => Ok(()),
402        "peer" => {
403            if claims.scope.as_deref() == Some("*") {
404                return if claims.subject.as_deref() == Some(LOCAL_ADMIN_SUBJECT) {
405                    Ok(())
406                } else {
407                    Err(CommandTokenError::Unauthorized)
408                };
409            }
410            if claims.command_name.is_none() && claims.scope.is_none() {
411                return Ok(());
412            }
413            Err(CommandTokenError::Unauthorized)
414        }
415        _ => Err(CommandTokenError::Unauthorized),
416    }
417}
418
419fn parse_v1_token(token: &str) -> Option<(Vec<u8>, Vec<u8>)> {
420    let token = token.strip_prefix("v1.")?;
421    let (payload_hex, signature_hex) = token.split_once('.')?;
422    let payload = decode_hex(payload_hex)?;
423    let signature = decode_hex(signature_hex)?;
424    Some((payload, signature))
425}
426
427fn encode_hex(bytes: &[u8]) -> String {
428    const HEX: &[u8; 16] = b"0123456789abcdef";
429    let mut out = String::with_capacity(bytes.len() * 2);
430    for byte in bytes {
431        out.push(HEX[(byte >> 4) as usize] as char);
432        out.push(HEX[(byte & 0x0f) as usize] as char);
433    }
434    out
435}
436
437fn decode_hex(input: &str) -> Option<Vec<u8>> {
438    if input.is_empty() || !input.len().is_multiple_of(2) {
439        return None;
440    }
441    let mut output = Vec::with_capacity(input.len() / 2);
442    let bytes = input.as_bytes();
443    let mut index = 0usize;
444    while index < bytes.len() {
445        let hi = hex_value(bytes[index])?;
446        let lo = hex_value(bytes[index + 1])?;
447        output.push((hi << 4) | lo);
448        index += 2;
449    }
450    Some(output)
451}
452
453fn hex_value(byte: u8) -> Option<u8> {
454    match byte {
455        b'0'..=b'9' => Some(byte - b'0'),
456        b'a'..=b'f' => Some(10 + byte - b'a'),
457        b'A'..=b'F' => Some(10 + byte - b'A'),
458        _ => None,
459    }
460}
461
462#[cfg(test)]
463mod token_tests;