From 825ef822e837052cdab529a4f4bbccdedafb2133 Mon Sep 17 00:00:00 2001 From: dnettoRaw Date: Fri, 15 May 2026 16:37:11 +0200 Subject: [PATCH 1/4] Implement native signed token format --- AGENTS.md | 173 +++++ README.fr.md | 123 +++ README.md | 123 +++ README.pt.md | 123 +++ SECURITY_NOTES.md | 9 + examples/manager_integration.rs | 29 + examples/sign_verify.rs | 30 + examples/with_claims.rs | 37 + src/advanced_token_manager.rs | 524 +------------ src/advanced_token_manager/crypto.rs | 53 ++ src/advanced_token_manager/defaults.rs | 17 + src/advanced_token_manager/env.rs | 28 + src/advanced_token_manager/error.rs | 23 + src/advanced_token_manager/init.rs | 106 +++ src/advanced_token_manager/jwt.rs | 54 ++ src/advanced_token_manager/native.rs | 24 + src/advanced_token_manager/normalize.rs | 67 ++ src/advanced_token_manager/options.rs | 65 ++ src/advanced_token_manager/random.rs | 12 + src/advanced_token_manager/salts.rs | 17 + src/advanced_token_manager/secret.rs | 16 + src/advanced_token_manager/time.rs | 29 + src/advanced_token_manager/token.rs | 163 ++++ src/advanced_token_manager/token/build.rs | 50 ++ src/advanced_token_manager/token/parse.rs | 49 ++ src/advanced_token_manager/token/validate.rs | 39 + .../token/validate/scope.rs | 26 + .../token/validate/temporal.rs | 58 ++ src/jwt.rs | 729 +----------------- src/jwt/base64url.rs | 41 + src/jwt/claims.rs | 12 + src/jwt/claims/apply.rs | 80 ++ src/jwt/claims/audience.rs | 35 + src/jwt/claims/enforce.rs | 18 + src/jwt/claims/header.rs | 30 + src/jwt/claims/string.rs | 46 ++ src/jwt/claims/validate.rs | 40 + src/jwt/error.rs | 22 + src/jwt/signing.rs | 54 ++ src/jwt/signing/hmac.rs | 21 + src/jwt/time.rs | 29 + src/jwt/types.rs | 115 +++ src/jwt/verify.rs | 64 ++ src/jwt/verify/claims.rs | 93 +++ src/jwt/verify/claims/allowed.rs | 19 + src/jwt/verify/header.rs | 61 ++ src/jwt/verify/payload.rs | 39 + src/jwt/verify/temporal.rs | 63 ++ src/jwt/verify/temporal/max_age.rs | 35 + src/lib.rs | 4 +- tests/advanced_token_manager_test.rs | 166 +++- tests/jwt_test.rs | 242 +++++- 52 files changed, 2918 insertions(+), 1207 deletions(-) create mode 100644 AGENTS.md create mode 100644 README.fr.md create mode 100644 README.md create mode 100644 README.pt.md create mode 100644 SECURITY_NOTES.md create mode 100644 examples/manager_integration.rs create mode 100644 examples/sign_verify.rs create mode 100644 examples/with_claims.rs create mode 100644 src/advanced_token_manager/crypto.rs create mode 100644 src/advanced_token_manager/defaults.rs create mode 100644 src/advanced_token_manager/env.rs create mode 100644 src/advanced_token_manager/error.rs create mode 100644 src/advanced_token_manager/init.rs create mode 100644 src/advanced_token_manager/jwt.rs create mode 100644 src/advanced_token_manager/native.rs create mode 100644 src/advanced_token_manager/normalize.rs create mode 100644 src/advanced_token_manager/options.rs create mode 100644 src/advanced_token_manager/random.rs create mode 100644 src/advanced_token_manager/salts.rs create mode 100644 src/advanced_token_manager/secret.rs create mode 100644 src/advanced_token_manager/time.rs create mode 100644 src/advanced_token_manager/token.rs create mode 100644 src/advanced_token_manager/token/build.rs create mode 100644 src/advanced_token_manager/token/parse.rs create mode 100644 src/advanced_token_manager/token/validate.rs create mode 100644 src/advanced_token_manager/token/validate/scope.rs create mode 100644 src/advanced_token_manager/token/validate/temporal.rs create mode 100644 src/jwt/base64url.rs create mode 100644 src/jwt/claims.rs create mode 100644 src/jwt/claims/apply.rs create mode 100644 src/jwt/claims/audience.rs create mode 100644 src/jwt/claims/enforce.rs create mode 100644 src/jwt/claims/header.rs create mode 100644 src/jwt/claims/string.rs create mode 100644 src/jwt/claims/validate.rs create mode 100644 src/jwt/error.rs create mode 100644 src/jwt/signing.rs create mode 100644 src/jwt/signing/hmac.rs create mode 100644 src/jwt/time.rs create mode 100644 src/jwt/types.rs create mode 100644 src/jwt/verify.rs create mode 100644 src/jwt/verify/claims.rs create mode 100644 src/jwt/verify/claims/allowed.rs create mode 100644 src/jwt/verify/header.rs create mode 100644 src/jwt/verify/payload.rs create mode 100644 src/jwt/verify/temporal.rs create mode 100644 src/jwt/verify/temporal/max_age.rs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fd380af --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,173 @@ +# hashTokenRust — Agents + +## Objetivo Do Projeto + +Implementar em Rust o mesmo objetivo do `hash-token`: um gerenciador leve de tokens HMAC com suporte JWT nativo, seguro, legivel e sem excesso de abstracao. + +O codigo deve ser pequeno, previsivel e facil de auditar. Prefira funcoes curtas, tipos explicitos e fluxo claro. + +## Regras Globais De Engenharia + +- Linguagem: Rust 2021. +- Validacao obrigatoria antes de assinar, verificar ou decodificar dados externos. +- Evitar dependencias novas. Se uma dependencia for realmente necessaria, justificar no PR/commit. +- Preferir `Result` com erros claros em vez de `panic!`. +- Nao usar `unwrap()` ou `expect()` em codigo de biblioteca, exceto em testes quando deixar a intencao mais clara. +- Manter compatibilidade com `cargo test`, `cargo fmt` e `cargo clippy`. +- O codigo deve ser extremamente leve: sem macros complexas, sem frameworks e sem alocacoes desnecessarias. +- Quando houver fluxo com muitos passos, usar state machine simples e explicita. + +## Estilo De Arquivos E Funcoes + +- Maximo de 5 funcoes por arquivo sempre que for pratico. +- Cada funcao deve fazer quase uma unica tarefa. +- Uma funcao deve ser facil de ler do inicio ao fim sem pular contexto. +- Se um arquivo passar de 5 funcoes, dividir por responsabilidade: + - parsing + - assinatura + - validacao + - claims + - erros + - state machine +- Comentarios sao bem-vindos quando explicam uma decisao de seguranca ou uma etapa nao obvia. +- Evitar comentarios obvios como "incrementa contador". +- Nomes devem descrever a acao: `decode_segment`, `verify_signature`, `validate_expiration`. + +## State Machine + +Quando possivel, representar validacoes em etapas de state machine. Exemplo: + +```rust +enum JwtVerifyState { + SplitToken, + DecodeHeader, + DecodePayload, + VerifyAlgorithm, + VerifySignature, + ValidateClaims, + Done, +} +``` + +Regras: + +- Cada estado deve ter uma responsabilidade clara. +- Transicoes devem ser explicitas. +- Estados nao devem esconder validacoes criticas. +- Nao criar state machine se isso deixar uma funcao simples mais dificil de entender. + +## 1. Refactor Agent + +**Papel:** Engenheiro Rust +**Objetivo:** Manter e evoluir o token manager e o JWT nativo com API publica simples. + +Tarefas: + +- Implementar assinatura e verificacao JWT HS256/HS512. +- Manter Base64URL encode/decode sem aceitar entrada malformada. +- Integrar `generate_jwt` e `validate_jwt` em `AdvancedTokenManager`. +- Separar responsabilidades em arquivos pequenos. +- Manter tipagem forte e erros legiveis. +- Preferir structs de options em vez de parametros soltos demais. + +Checklist: + +- `cargo fmt` +- `cargo clippy --all-targets --all-features` +- `cargo test` + +## 2. Security Agent + +**Papel:** Auditor de seguranca Rust +**Objetivo:** Revisar o modulo JWT e o manager para evitar falhas comuns. + +Checklist: + +- Proibir `alg: none`. +- Aceitar apenas HS256 e HS512. +- Comparar assinaturas com igualdade em tempo constante. +- Validar rigorosamente `exp`, `nbf`, `iat`, `iss`, `aud`, `sub`. +- Aplicar `clock_tolerance` de forma segura. +- Rejeitar payloads grandes quando `max_payload_size` estiver definido. +- Rejeitar tokens truncados, segmentos vazios e Base64URL invalido. +- Nao adicionar dependencias externas sem justificativa. +- Manter ou atualizar `SECURITY_NOTES.md` quando houver mudanca de seguranca. + +## 3. Test Agent + +**Papel:** Engenheiro de testes Rust +**Objetivo:** Garantir cobertura forte do comportamento critico. + +Tarefas: + +- Criar ou manter testes em `tests/jwt_test.rs`. +- Criar ou manter testes em `tests/advanced_token_manager_test.rs`. +- Testar sucesso e falhas: + - HS256 + - HS512 + - expiracao + - `nbf` + - `iat` futuro + - `alg: none` + - algoritmo inesperado + - assinatura truncada + - claims invalidas + - payload grande + - segredo errado +- Testar integracao com `AdvancedTokenManager`. + +Comandos: + +```bash +cargo test +cargo fmt --check +cargo clippy --all-targets --all-features +``` + +## 4. Examples Agent + +**Papel:** Criador de exemplos Rust +**Objetivo:** Mostrar uso real sem complicar a biblioteca. + +Tarefas: + +- Criar exemplos em `examples/` quando necessario. +- Exemplos recomendados: + - `examples/sign_verify.rs` + - `examples/with_claims.rs` + - `examples/manager_integration.rs` +- Cada exemplo deve rodar com `cargo run --example nome`. +- Comentarios devem explicar apenas o que importa para uso e seguranca. + +## 5. Docs Agent + +**Papel:** Editor tecnico +**Objetivo:** Manter documentacao curta, clara e pratica. + +Tarefas: + +- Atualizar `README.md` quando a API publica mudar. +- Documentar JWT nativo sem dependencias desnecessarias. +- Incluir tabela de options quando houver parametros novos. +- Incluir notas de seguranca para claims, algoritmos e segredo. +- Manter exemplos compilaveis. + +## Workflow Sugerido + +1. Refactor Agent ajusta a API e separa arquivos quando necessario. +2. Security Agent revisa assinatura, verificacao, claims e comparacao constante. +3. Test Agent adiciona ou atualiza testes. +4. Examples Agent cria exemplos pequenos e executaveis. +5. Docs Agent atualiza README e notas de seguranca. +6. Rodar `cargo fmt --check`, `cargo clippy --all-targets --all-features` e `cargo test`. + +## Definicao De Pronto + +Uma mudanca so esta pronta quando: + +- Compila sem warnings relevantes. +- Passa em todos os testes. +- Mantem funcoes pequenas e mono tarefa. +- Nao aumenta dependencias sem motivo forte. +- Tem comentarios onde ha decisao de seguranca. +- A API publica continua facil de usar. diff --git a/README.fr.md b/README.fr.md new file mode 100644 index 0000000..478d212 --- /dev/null +++ b/README.fr.md @@ -0,0 +1,123 @@ +# hash_token_rust + +Gestionnaire de tokens leger en Rust pour binaires standalone, secrets partages, salts, controle d age et support JWT optionnel. + +Documentation : + +- English: `README.md` +- Portugues: `README.pt.md` +- Francais: `README.fr.md` + +## A Quoi Sert Cette Bibliotheque + +`hash_token_rust` sert a creer et verifier des tokens signes de maniere simple, previsible et facile a auditer. + +Elle couvre deux usages principaux : + +- Tokens HMAC avec secret et salts, geres par `AdvancedTokenManager`. +- JWT natifs signes avec HMAC via `HS256` ou `HS512`. + +Utilisez ce projet quand des programmes standalone doivent echanger des donnees signees sans certificats, cles publiques, cles privees, frameworks ou beaucoup de dependances. + +## Fonctionnement + +Dans le flux natif `AdvancedTokenManager`, le manager conserve un secret partage et une liste de salts. Il genere un nouveau format versionne : `htr1...`. Le payload et les metadata utilisent Base64URL. Les metadata contiennent la version, l.algorithme, l.index de salt, `iat`, `exp` optionnel, `iss` optionnel et `aud` optionnel. La signature authentifie la version, le payload et les metadata avec HMAC plus le salt choisi. + +Dans le flux JWT, la bibliotheque construit un header JSON et un payload JSON, encode les deux segments en Base64URL sans padding, signe le texte `header.payload` avec HMAC et place la signature dans le troisieme segment. Lors de la verification, elle valide la structure, decode les segments, verifie l'algorithme, controle la signature puis valide les claims configurees. + +## Exemple JWT Simple + +```rust +use hash_token_rust::{ + sign_jwt, verify_jwt, JwtAlgorithm, JwtClaims, SignJwtOptions, VerifyJwtOptions, +}; + +let mut claims = JwtClaims::new(); +claims.insert("sub".to_string(), "user-123".into()); + +let token = sign_jwt(&claims, &SignJwtOptions { + secret: "a-very-secure-secret-value".to_string(), + algorithm: Some(JwtAlgorithm::HS256), + expires_in: Some(300.0), + ..Default::default() +})?; + +let verified = verify_jwt(&token, &VerifyJwtOptions { + secret: "a-very-secure-secret-value".to_string(), + algorithms: Some(vec![JwtAlgorithm::HS256]), + ..Default::default() +})?; + +assert_eq!(verified.get("sub").unwrap(), "user-123"); +# Ok::<(), Box>(()) +``` + +## AdvancedTokenManager + +`AdvancedTokenManager` est l.API principale. Il cree des tokens natifs `htr1` pour la communication entre binaires et expose aussi `generate_jwt` et `validate_jwt`. Par defaut, les JWT utilisent le secret du manager, mais chaque appel peut fournir son propre secret. + +```rust +use hash_token_rust::{AdvancedTokenManager, AdvancedTokenManagerOptions, Algorithm}; + +let mut manager = AdvancedTokenManager::new( + Some("a-very-secure-secret-value".to_string()), + Some(vec!["salt-a".into(), "salt-b".into()]), + Some(Algorithm::Sha256), + false, + true, + Some(AdvancedTokenManagerOptions::default()), +)?; + +let token = manager.generate_token("payload-data", None)?; +let data = manager.validate_token(&token)?; + +assert_eq!(data, Some("payload-data".to_string())); +# Ok::<(), Box>(()) +``` + +## Options JWT Importantes + +| Option | Role | +| --- | --- | +| `algorithm` | Definit l'algorithme de signature. La valeur par defaut est `HS256`. | +| `algorithms` | Limite la verification aux algorithmes attendus. | +| `expires_in` | Ajoute `exp` relativement au moment de signature. | +| `not_before` | Ajoute `nbf` relativement au moment de signature. | +| `issued_at` | Definit `iat`; sinon la bibliotheque ajoute le timestamp courant. | +| `clock_tolerance` | Autorise un petit decalage d'horloge pendant la validation temporelle. | +| `max_age` | Rejette les tokens dont `iat` est trop ancien. | +| `audience` | Exige et valide `aud`. | +| `issuer` | Exige et valide `iss`. | +| `subject` | Exige et valide `sub`. | +| `max_payload_size` | Rejette les payloads JWT trop grands avant et apres decodage. | +| `allowed_claims` | Limite les claims personnalisees hors claims standard. | + +## Modele De Securite + +- `alg: none` est rejete. +- Seuls `HS256` et `HS512` exacts sont acceptes. +- Base64URL doit etre canonique et sans padding. +- Les segments vides, le JSON invalide et les claims de type invalide sont rejetes. +- Les signatures JWT et les signatures natives du manager sont compares sans sortie anticipee pour les entrees de meme taille. +- L'arithmetique temporelle utilise des operations checked. +- `exp`, `nbf`, `iat`, `iss`, `aud` et `sub` sont valides quand ils existent et obligatoires quand ils sont configures. + +Utilisez des secrets forts, limitez les algorithmes acceptes lors de la verification et configurez `max_payload_size` sur les endpoints publics. + +## Exemples + +```bash +cargo run --example sign_verify +cargo run --example with_claims +cargo run --example manager_integration +``` + +## Developpement + +```bash +cargo fmt --check +cargo clippy --all-targets --all-features +cargo test +``` + +Le code est separe par responsabilite : Base64URL, claims, signature, verification, temps, initialisation du manager, parsing de token et integration JWT du manager. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5b3d7b7 --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# hash_token_rust + +Lightweight Rust token manager for standalone binaries, shared secrets, salts, token age control and optional JWT support. + +Documentation: + +- English: `README.md` +- Portugues: `README.pt.md` +- Francais: `README.fr.md` + +## What This Library Is For + +`hash_token_rust` helps applications create and verify two kinds of tokens: + +- Salted HMAC tokens managed by `AdvancedTokenManager`. +- Native JWT tokens signed with HMAC algorithms `HS256` or `HS512`. + +The project is intentionally small. It avoids framework-style abstractions and keeps validation, parsing, signing and verification in explicit modules so the security flow is easy to audit. + +Use it when standalone programs need to exchange signed data without certificates, public keys, private keys, frameworks or a large dependency tree. + +## How It Works + +For native manager tokens, the manager stores a shared secret and a list of salts. It emits a new versioned format: `htr1...`. The payload and metadata are Base64URL encoded. Metadata carries the token version, algorithm, salt index, `iat`, optional `exp`, optional `iss` and optional `aud`. The signature authenticates the version, payload and metadata with HMAC plus the selected salt. + +For JWTs, the library builds a JSON header and payload, Base64URL-encodes both segments, signs `header.payload` with HMAC, and verifies the signature before validating claims. It rejects unsigned tokens and accepts only exact `HS256` or `HS512`. + +## Basic JWT Example + +```rust +use hash_token_rust::{ + sign_jwt, verify_jwt, JwtAlgorithm, JwtClaims, SignJwtOptions, VerifyJwtOptions, +}; + +let mut claims = JwtClaims::new(); +claims.insert("sub".to_string(), "user-123".into()); + +let token = sign_jwt(&claims, &SignJwtOptions { + secret: "a-very-secure-secret-value".to_string(), + algorithm: Some(JwtAlgorithm::HS256), + expires_in: Some(300.0), + ..Default::default() +})?; + +let verified = verify_jwt(&token, &VerifyJwtOptions { + secret: "a-very-secure-secret-value".to_string(), + algorithms: Some(vec![JwtAlgorithm::HS256]), + ..Default::default() +})?; + +assert_eq!(verified.get("sub").unwrap(), "user-123"); +# Ok::<(), Box>(()) +``` + +## AdvancedTokenManager + +`AdvancedTokenManager` is the primary API. It creates native `htr1` tokens for binary-to-binary communication and also exposes `generate_jwt` and `validate_jwt`. JWT calls use the manager secret by default, but each call can override the secret when needed. + +```rust +use hash_token_rust::{AdvancedTokenManager, AdvancedTokenManagerOptions, Algorithm}; + +let mut manager = AdvancedTokenManager::new( + Some("a-very-secure-secret-value".to_string()), + Some(vec!["salt-a".into(), "salt-b".into()]), + Some(Algorithm::Sha256), + false, + true, + Some(AdvancedTokenManagerOptions::default()), +)?; + +let token = manager.generate_token("payload-data", None)?; +let data = manager.validate_token(&token)?; + +assert_eq!(data, Some("payload-data".to_string())); +# Ok::<(), Box>(()) +``` + +## Important JWT Options + +| Option | Purpose | +| --- | --- | +| `algorithm` | Selects the signing algorithm, defaulting to `HS256`. | +| `algorithms` | Restricts verification to expected algorithms. | +| `expires_in` | Adds an `exp` claim relative to the signing timestamp. | +| `not_before` | Adds an `nbf` claim relative to the signing timestamp. | +| `issued_at` | Sets `iat`; otherwise the library adds the current timestamp. | +| `clock_tolerance` | Allows small clock drift during temporal validation. | +| `max_age` | Rejects tokens whose `iat` is older than the configured age. | +| `audience` | Requires and validates `aud`. | +| `issuer` | Requires and validates `iss`. | +| `subject` | Requires and validates `sub`. | +| `max_payload_size` | Rejects large JWT payloads before and after decoding. | +| `allowed_claims` | Restricts non-standard custom claims. | + +## Security Model + +- `alg: none` is rejected. +- Only exact `HS256` and `HS512` are accepted. +- Base64URL input must be canonical and unpadded. +- Empty segments, malformed JSON and invalid claim shapes are rejected. +- JWT signatures and native manager signatures are compared without early exit for equal-length inputs. +- Temporal arithmetic uses checked operations. +- `exp`, `nbf`, `iat`, `iss`, `aud` and `sub` are validated when present, and required when configured. + +Use high-entropy secrets and pin accepted algorithms during verification. For public endpoints, set `max_payload_size`. + +## Examples + +```bash +cargo run --example sign_verify +cargo run --example with_claims +cargo run --example manager_integration +``` + +## Development + +```bash +cargo fmt --check +cargo clippy --all-targets --all-features +cargo test +``` + +The code is split by responsibility: Base64URL, claims, signing, verification, time handling, manager initialization, token parsing and manager JWT integration. diff --git a/README.pt.md b/README.pt.md new file mode 100644 index 0000000..25c30ca --- /dev/null +++ b/README.pt.md @@ -0,0 +1,123 @@ +# hash_token_rust + +Gerenciador leve de tokens em Rust para binarios standalone, segredos compartilhados, salts, controle de idade e suporte JWT opcional. + +Documentacao: + +- English: `README.md` +- Portugues: `README.pt.md` +- Francais: `README.fr.md` + +## Para Que Serve + +`hash_token_rust` serve para criar e validar tokens assinados de forma simples, previsivel e facil de auditar. + +Ele cobre dois usos principais: + +- Tokens HMAC com segredo e salts, gerenciados por `AdvancedTokenManager`. +- JWTs nativos assinados com HMAC usando `HS256` ou `HS512`. + +Use este projeto quando programas standalone precisam trocar dados assinados sem certificados, chaves publicas, chaves privadas, frameworks ou muitas dependencias. + +## Como Funciona + +No fluxo nativo do `AdvancedTokenManager`, o manager guarda um segredo compartilhado e uma lista de salts. Ele gera um formato novo e versionado: `htr1...`. O payload e o metadata usam Base64URL. O metadata carrega versao, algoritmo, indice de salt, `iat`, `exp` opcional, `iss` opcional e `aud` opcional. A assinatura autentica versao, payload e metadata com HMAC mais o salt escolhido. + +No fluxo JWT, a biblioteca monta um header JSON e um payload JSON, codifica os dois com Base64URL sem padding, assina o texto `header.payload` com HMAC e grava a assinatura no terceiro segmento. Na validacao, ela valida a estrutura, decodifica os segmentos, verifica o algoritmo, checa a assinatura e depois valida as claims configuradas. + +## Exemplo JWT Basico + +```rust +use hash_token_rust::{ + sign_jwt, verify_jwt, JwtAlgorithm, JwtClaims, SignJwtOptions, VerifyJwtOptions, +}; + +let mut claims = JwtClaims::new(); +claims.insert("sub".to_string(), "user-123".into()); + +let token = sign_jwt(&claims, &SignJwtOptions { + secret: "a-very-secure-secret-value".to_string(), + algorithm: Some(JwtAlgorithm::HS256), + expires_in: Some(300.0), + ..Default::default() +})?; + +let verified = verify_jwt(&token, &VerifyJwtOptions { + secret: "a-very-secure-secret-value".to_string(), + algorithms: Some(vec![JwtAlgorithm::HS256]), + ..Default::default() +})?; + +assert_eq!(verified.get("sub").unwrap(), "user-123"); +# Ok::<(), Box>(()) +``` + +## AdvancedTokenManager + +`AdvancedTokenManager` e a API principal. Ele cria tokens nativos `htr1` para comunicacao entre binarios e tambem integra JWT por meio de `generate_jwt` e `validate_jwt`. Por padrao, os JWTs usam o segredo do manager, mas cada chamada pode receber um segredo proprio. + +```rust +use hash_token_rust::{AdvancedTokenManager, AdvancedTokenManagerOptions, Algorithm}; + +let mut manager = AdvancedTokenManager::new( + Some("a-very-secure-secret-value".to_string()), + Some(vec!["salt-a".into(), "salt-b".into()]), + Some(Algorithm::Sha256), + false, + true, + Some(AdvancedTokenManagerOptions::default()), +)?; + +let token = manager.generate_token("payload-data", None)?; +let data = manager.validate_token(&token)?; + +assert_eq!(data, Some("payload-data".to_string())); +# Ok::<(), Box>(()) +``` + +## Opcoes Importantes De JWT + +| Opcao | Funcao | +| --- | --- | +| `algorithm` | Define o algoritmo de assinatura. O padrao e `HS256`. | +| `algorithms` | Restringe a verificacao aos algoritmos esperados. | +| `expires_in` | Adiciona `exp` relativo ao momento da assinatura. | +| `not_before` | Adiciona `nbf` relativo ao momento da assinatura. | +| `issued_at` | Define `iat`; se ausente, a biblioteca adiciona o timestamp atual. | +| `clock_tolerance` | Permite pequena diferenca de relogio na validacao temporal. | +| `max_age` | Rejeita tokens cujo `iat` seja antigo demais. | +| `audience` | Exige e valida `aud`. | +| `issuer` | Exige e valida `iss`. | +| `subject` | Exige e valida `sub`. | +| `max_payload_size` | Rejeita payloads JWT grandes antes e depois do decode. | +| `allowed_claims` | Restringe claims customizadas fora das claims padrao. | + +## Modelo De Seguranca + +- `alg: none` e rejeitado. +- Apenas `HS256` e `HS512` exatos sao aceitos. +- Base64URL deve ser canonico e sem padding. +- Segmentos vazios, JSON invalido e claims com tipo invalido sao rejeitados. +- Assinaturas JWT e assinaturas nativas do manager sao comparados sem early exit para entradas de mesmo tamanho. +- Aritmetica temporal usa operacoes checked. +- `exp`, `nbf`, `iat`, `iss`, `aud` e `sub` sao validados quando existem e obrigatorios quando configurados. + +Use segredos fortes, limite os algoritmos aceitos na verificacao e configure `max_payload_size` em endpoints publicos. + +## Exemplos + +```bash +cargo run --example sign_verify +cargo run --example with_claims +cargo run --example manager_integration +``` + +## Desenvolvimento + +```bash +cargo fmt --check +cargo clippy --all-targets --all-features +cargo test +``` + +O codigo e separado por responsabilidade: Base64URL, claims, assinatura, verificacao, tempo, inicializacao do manager, parsing de token e integracao JWT do manager. diff --git a/SECURITY_NOTES.md b/SECURITY_NOTES.md new file mode 100644 index 0000000..375a0e9 --- /dev/null +++ b/SECURITY_NOTES.md @@ -0,0 +1,9 @@ +# Security Notes + +- JWT verification rejects `alg: none` and accepts only exact `HS256` or `HS512`. +- Signatures and legacy token checksums are compared without early exit for equal-length inputs. +- Base64URL segments must use the unpadded URL-safe alphabet. Empty or malformed segments are rejected. +- `exp`, `nbf`, `iat`, `iss`, `aud` and `sub` are validated when present, and required when configured in verify options. +- `clock_tolerance` is non-negative and temporal arithmetic uses checked operations. +- `max_payload_size` is checked before and after payload decoding to limit unauthenticated allocation. +- Use high-entropy secrets. `AdvancedTokenManager::new` requires at least 16 characters for its main secret. diff --git a/examples/manager_integration.rs b/examples/manager_integration.rs new file mode 100644 index 0000000..3ca574b --- /dev/null +++ b/examples/manager_integration.rs @@ -0,0 +1,29 @@ +use hash_token_rust::{ + AdvancedTokenManager, AdvancedTokenManagerOptions, Algorithm, JwtAlgorithm, JwtClaims, + ManagerVerifyJwtOptions, +}; + +fn main() -> Result<(), Box> { + let manager = AdvancedTokenManager::new( + Some("a-very-secure-secret-value".to_string()), + Some(vec!["salt-a".into(), "salt-b".into()]), + Some(Algorithm::Sha256), + false, + true, + Some(AdvancedTokenManagerOptions { + jwt_default_algorithms: Some(vec![JwtAlgorithm::HS256]), + jwt_max_payload_size: Some(1024), + ..Default::default() + }), + )?; + + let mut claims = JwtClaims::new(); + claims.insert("sub".to_string(), "user-123".into()); + + let token = manager.generate_jwt(&claims, None)?; + let verified: JwtClaims = + manager.validate_jwt(&token, Some(ManagerVerifyJwtOptions::default()))?; + + println!("subject={}", verified["sub"]); + Ok(()) +} diff --git a/examples/sign_verify.rs b/examples/sign_verify.rs new file mode 100644 index 0000000..40afc41 --- /dev/null +++ b/examples/sign_verify.rs @@ -0,0 +1,30 @@ +use hash_token_rust::{ + sign_jwt, verify_jwt, JwtAlgorithm, JwtClaims, SignJwtOptions, VerifyJwtOptions, +}; + +fn main() -> Result<(), Box> { + let mut claims = JwtClaims::new(); + claims.insert("sub".to_string(), "user-123".into()); + + let token = sign_jwt( + &claims, + &SignJwtOptions { + secret: "a-very-secure-secret-value".to_string(), + algorithm: Some(JwtAlgorithm::HS256), + expires_in: Some(300.0), + ..Default::default() + }, + )?; + + let verified = verify_jwt( + &token, + &VerifyJwtOptions { + secret: "a-very-secure-secret-value".to_string(), + algorithms: Some(vec![JwtAlgorithm::HS256]), + ..Default::default() + }, + )?; + + println!("subject={}", verified["sub"]); + Ok(()) +} diff --git a/examples/with_claims.rs b/examples/with_claims.rs new file mode 100644 index 0000000..7013cec --- /dev/null +++ b/examples/with_claims.rs @@ -0,0 +1,37 @@ +use hash_token_rust::{ + sign_jwt, verify_jwt, Audience, Issuer, JwtAlgorithm, JwtClaims, SignJwtOptions, + VerifyJwtOptions, +}; + +fn main() -> Result<(), Box> { + let mut claims = JwtClaims::new(); + claims.insert("role".to_string(), "admin".into()); + + let token = sign_jwt( + &claims, + &SignJwtOptions { + secret: "a-very-secure-secret-value".to_string(), + algorithm: Some(JwtAlgorithm::HS512), + expires_in: Some(600.0), + audience: Some(Audience::Single("internal-api".into())), + issuer: Some("auth-service".into()), + subject: Some("user-123".into()), + ..Default::default() + }, + )?; + + let verified = verify_jwt( + &token, + &VerifyJwtOptions { + secret: "a-very-secure-secret-value".to_string(), + algorithms: Some(vec![JwtAlgorithm::HS512]), + audience: Some(Audience::Single("internal-api".into())), + issuer: Some(Issuer::Single("auth-service".into())), + subject: Some("user-123".into()), + ..Default::default() + }, + )?; + + println!("claims={verified:?}"); + Ok(()) +} diff --git a/src/advanced_token_manager.rs b/src/advanced_token_manager.rs index f3312bc..78c5a88 100644 --- a/src/advanced_token_manager.rs +++ b/src/advanced_token_manager.rs @@ -1,30 +1,33 @@ -use std::env; -use std::sync::Arc; - -use base64::{engine::general_purpose, Engine as _}; -use rand::distributions::{Distribution, Uniform}; -use rand::{rngs::OsRng, Rng}; -use serde::de::DeserializeOwned; -use serde_json::{Map, Value}; -use thiserror::Error; +mod crypto; +mod defaults; +mod env; +mod error; +mod init; +mod jwt; +mod native; +mod normalize; +mod options; +mod random; +mod salts; +mod secret; +mod time; +mod token; -use hmac::{Hmac, Mac}; -use sha2::{Sha256, Sha512}; +use std::sync::Arc; -use crate::jwt::{ - sign_jwt, verify_jwt_as, Audience, Issuer, JwtAlgorithm, JwtClaims, JwtError, SignJwtOptions, - VerifyJwtOptions, +pub use error::{AdvancedTokenError, TokenValidationError}; +pub use options::{ + AdvancedTokenManagerOptions, GenerateTokenOptions, ManagerSignJwtOptions, + ManagerVerifyJwtOptions, ValidateTokenOptions, }; +use crate::jwt::JwtAlgorithm; + const DEFAULT_SECRET_LENGTH: usize = 32; const DEFAULT_SALT_COUNT: usize = 10; const DEFAULT_SALT_LENGTH: usize = 16; const MIN_SECRET_LENGTH: usize = 16; const MIN_SALT_COUNT: usize = 2; -const CHARACTERS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - -type HmacSha256 = Hmac; -type HmacSha512 = Hmac; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Algorithm { @@ -32,35 +35,6 @@ pub enum Algorithm { Sha512, } -impl Algorithm { - fn to_hmac(self, secret: &[u8], input: &[u8]) -> Result, TokenValidationError> { - match self { - Algorithm::Sha256 => compute_hmac_sha256(secret, input), - Algorithm::Sha512 => compute_hmac_sha512(secret, input), - } - } -} - -#[derive(Debug, Error)] -pub enum AdvancedTokenError { - #[error("{0}")] - Message(String), - #[error(transparent)] - Jwt(#[from] JwtError), -} - -#[derive(Debug, Error, Clone)] -pub enum TokenValidationError { - #[error("{0}")] - Message(String), -} - -impl TokenValidationError { - fn new(message: impl Into) -> Self { - Self::Message(message.into()) - } -} - pub trait AdvancedTokenManagerLogger: Send + Sync { fn warn(&self, message: &str); fn error(&self, message: &str); @@ -79,51 +53,6 @@ impl AdvancedTokenManagerLogger for DefaultLogger { } } -#[derive(Default, Clone)] -pub struct AdvancedTokenManagerOptions { - pub logger: Option>, - pub jwt_default_algorithms: Option>, - pub default_secret_length: Option, - pub default_salt_count: Option, - pub default_salt_length: Option, - pub throw_on_validation_failure: Option, - pub jwt_max_payload_size: Option, - pub jwt_allowed_claims: Option>, -} - -#[derive(Default, Clone)] -pub struct ValidateTokenOptions { - pub throw_on_failure: Option, -} - -#[derive(Default, Clone)] -pub struct ManagerSignJwtOptions { - pub secret: Option, - pub algorithm: Option, - pub header: Option>, - pub expires_in: Option, - pub not_before: Option, - pub audience: Option, - pub issuer: Option, - pub subject: Option, - pub issued_at: Option, - pub clock_timestamp: Option, -} - -#[derive(Default, Clone)] -pub struct ManagerVerifyJwtOptions { - pub secret: Option, - pub algorithms: Option>, - pub clock_tolerance: Option, - pub audience: Option, - pub issuer: Option, - pub subject: Option, - pub max_age: Option, - pub clock_timestamp: Option, - pub max_payload_size: Option, - pub allowed_claims: Option>, -} - pub struct ManagerConfig { pub secret: String, pub salts: Vec, @@ -152,423 +81,46 @@ impl AdvancedTokenManager { options: Option, ) -> Result { let options = options.unwrap_or_default(); - let logger = options.logger.unwrap_or_else(|| Arc::new(DefaultLogger)); - - let default_secret_length = resolve_length_option( - "defaultSecretLength", - options.default_secret_length, - DEFAULT_SECRET_LENGTH, - MIN_SECRET_LENGTH, - )?; - let default_salt_count = resolve_length_option( - "defaultSaltCount", - options.default_salt_count, - DEFAULT_SALT_COUNT, - MIN_SALT_COUNT, - )?; - let default_salt_length = resolve_length_option( - "defaultSaltLength", - options.default_salt_length, - DEFAULT_SALT_LENGTH, - 1, - )?; - let jwt_default_algorithms = normalize_algorithms(options.jwt_default_algorithms)?; - let throw_on_validation_failure = options.throw_on_validation_failure.unwrap_or(false); - let jwt_max_payload_size = - normalize_positive_usize("jwtMaxPayloadSize", options.jwt_max_payload_size)?; - let jwt_allowed_claims = normalize_allowed_claims(options.jwt_allowed_claims)?; - - let secret = initialize_secret( + let logger = options + .logger + .clone() + .unwrap_or_else(|| Arc::new(DefaultLogger)); + let defaults = init::resolve_defaults(&options)?; + let jwt_options = init::resolve_jwt_options(options)?; + + let secret = init::initialize_secret( secret, allow_auto_generate, no_env, - default_secret_length, + defaults.secret_length, &*logger, )?; - let salts = initialize_salts( + let salts = init::initialize_salts( salts, allow_auto_generate, no_env, - default_salt_count, - default_salt_length, + defaults.salt_count, + defaults.salt_length, &*logger, )?; - let algorithm = algorithm.unwrap_or(Algorithm::Sha256); Ok(Self { secret, salts, - algorithm, + algorithm: algorithm.unwrap_or(Algorithm::Sha256), last_salt_index: None, logger, - throw_on_validation_failure, - jwt_default_algorithms, - jwt_max_payload_size, - jwt_allowed_claims, + throw_on_validation_failure: jwt_options.throw_on_validation_failure, + jwt_default_algorithms: jwt_options.default_algorithms, + jwt_max_payload_size: jwt_options.max_payload_size, + jwt_allowed_claims: jwt_options.allowed_claims, }) } - pub fn generate_token( - &mut self, - input: &str, - salt_index: Option, - ) -> Result { - let index = match salt_index { - Some(index) => { - self.validate_salt_index(index)?; - index - } - None => self.get_random_salt_index(), - }; - let salt = &self.salts[index]; - let checksum = self.create_checksum(input, salt)?; - Ok(general_purpose::STANDARD.encode(format!("{}|{}|{}", input, index, checksum))) - } - - pub fn validate_token(&self, token: &str) -> Result, TokenValidationError> { - self.validate_token_with_options(token, None) - } - - pub fn validate_token_with_options( - &self, - token: &str, - options: Option, - ) -> Result, TokenValidationError> { - let options = options.unwrap_or_default(); - let should_throw = options - .throw_on_failure - .unwrap_or(self.throw_on_validation_failure); - - match self.validate_token_internal(token) { - Ok(value) => Ok(Some(value)), - Err(error) => { - self.logger - .error(&format!("Error validating token: {}", error)); - if should_throw { - Err(error) - } else { - Ok(None) - } - } - } - } - - pub fn validate_token_lenient(&self, token: &str) -> Option { - self.validate_token_with_options( - token, - Some(ValidateTokenOptions { - throw_on_failure: Some(false), - }), - ) - .ok() - .flatten() - } - - pub fn extract_data(&self, token: &str) -> Result, TokenValidationError> { - self.validate_token(token) - } - - pub fn generate_jwt( - &self, - payload: &JwtClaims, - options: Option, - ) -> Result { - let options = options.unwrap_or_default(); - let secret = options.secret.unwrap_or_else(|| self.secret.clone()); - let sign_options = SignJwtOptions { - secret, - algorithm: options.algorithm, - header: options.header, - expires_in: options.expires_in, - not_before: options.not_before, - audience: options.audience, - issuer: options.issuer, - subject: options.subject, - issued_at: options.issued_at, - clock_timestamp: options.clock_timestamp, - }; - Ok(sign_jwt(payload, &sign_options)?) - } - - pub fn validate_jwt( - &self, - token: &str, - options: Option, - ) -> Result { - let options = options.unwrap_or_default(); - let secret = options.secret.unwrap_or_else(|| self.secret.clone()); - let verify_options = VerifyJwtOptions { - secret, - algorithms: options - .algorithms - .or_else(|| self.jwt_default_algorithms.clone()), - clock_tolerance: options.clock_tolerance, - audience: options.audience, - issuer: options.issuer, - subject: options.subject, - max_age: options.max_age, - clock_timestamp: options.clock_timestamp, - max_payload_size: options.max_payload_size.or(self.jwt_max_payload_size), - allowed_claims: options - .allowed_claims - .or_else(|| self.jwt_allowed_claims.clone()), - }; - - Ok(verify_jwt_as(token, &verify_options)?) - } - pub fn get_config(&self) -> ManagerConfig { ManagerConfig { secret: self.secret.clone(), salts: self.salts.clone(), } } - - fn validate_token_internal(&self, token: &str) -> Result { - let decoded = general_purpose::STANDARD - .decode(token) - .map_err(|_| TokenValidationError::new("Invalid base64 token."))?; - let decoded = String::from_utf8(decoded) - .map_err(|_| TokenValidationError::new("Token is not valid UTF-8."))?; - let mut parts = decoded.split('|'); - let input = parts - .next() - .ok_or_else(|| TokenValidationError::new("Token missing payload."))?; - let salt_index = parts - .next() - .ok_or_else(|| TokenValidationError::new("Token missing salt index."))?; - let checksum = parts - .next() - .ok_or_else(|| TokenValidationError::new("Token missing checksum."))?; - - if parts.next().is_some() { - return Err(TokenValidationError::new( - "Token has unexpected extra data.", - )); - } - - let index: usize = salt_index - .parse() - .map_err(|_| TokenValidationError::new("Token has invalid salt index."))?; - self.validate_salt_index(index)?; - let expected_checksum = self.create_checksum(input, &self.salts[index])?; - if expected_checksum == checksum { - Ok(input.to_string()) - } else { - Err(TokenValidationError::new("Checksum mismatch.")) - } - } - - fn validate_salt_index(&self, index: usize) -> Result<(), TokenValidationError> { - if index < self.salts.len() { - Ok(()) - } else { - Err(TokenValidationError::new(format!( - "Invalid salt index: {}", - index - ))) - } - } - - fn create_checksum(&self, input: &str, salt: &str) -> Result { - let mut payload = String::with_capacity(input.len() + salt.len()); - payload.push_str(input); - payload.push_str(salt); - let digest = self - .algorithm - .to_hmac(self.secret.as_bytes(), payload.as_bytes())?; - Ok(hex::encode(digest)) - } - - fn get_random_salt_index(&mut self) -> usize { - let len = self.salts.len(); - let mut rng = rand::thread_rng(); - loop { - let index = rng.gen_range(0..len); - if Some(index) != self.last_salt_index { - self.last_salt_index = Some(index); - return index; - } - } - } -} - -fn compute_hmac_sha256(secret: &[u8], input: &[u8]) -> Result, TokenValidationError> { - let mut mac = HmacSha256::new_from_slice(secret) - .map_err(|_| TokenValidationError::new("Invalid HMAC key."))?; - mac.update(input); - Ok(mac.finalize().into_bytes().to_vec()) -} - -fn compute_hmac_sha512(secret: &[u8], input: &[u8]) -> Result, TokenValidationError> { - let mut mac = HmacSha512::new_from_slice(secret) - .map_err(|_| TokenValidationError::new("Invalid HMAC key."))?; - mac.update(input); - Ok(mac.finalize().into_bytes().to_vec()) -} - -fn initialize_secret( - secret: Option, - allow_auto_generate: bool, - no_env: bool, - default_length: usize, - logger: &dyn AdvancedTokenManagerLogger, -) -> Result { - let mut candidate = secret.map(|value| value.trim().to_string()); - - if !no_env && candidate.is_none() { - candidate = env::var("TOKEN_SECRET") - .ok() - .map(|value| value.trim().to_string()); - } - - if let Some(secret) = candidate { - if secret.len() < MIN_SECRET_LENGTH { - return Err(AdvancedTokenError::Message(format!( - "Secret must be at least {} characters long.", - MIN_SECRET_LENGTH - ))); - } - Ok(secret) - } else if allow_auto_generate { - let generated = generate_random_key(default_length); - logger.warn("⚠️ Secret generated automatically. Store it securely."); - Ok(generated) - } else { - Err(AdvancedTokenError::Message(format!( - "Secret must be at least {} characters long.", - MIN_SECRET_LENGTH - ))) - } -} - -fn initialize_salts( - salts: Option>, - allow_auto_generate: bool, - no_env: bool, - default_count: usize, - default_length: usize, - logger: &dyn AdvancedTokenManagerLogger, -) -> Result, AdvancedTokenError> { - let mut resolved = salts; - if !no_env { - if resolved.as_ref().map_or(true, |values| values.is_empty()) { - if let Ok(value) = env::var("TOKEN_SALTS") { - resolved = Some( - value - .split(',') - .map(|entry| entry.trim().to_string()) - .collect(), - ); - } - } - } - - if let Some(values) = resolved { - let sanitized: Vec = values - .into_iter() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .collect(); - if sanitized.len() < MIN_SALT_COUNT { - return Err(AdvancedTokenError::Message(format!( - "Salt array cannot be empty or less than {}.", - MIN_SALT_COUNT - ))); - } - Ok(sanitized) - } else if allow_auto_generate { - let salts: Vec = (0..default_count) - .map(|_| generate_random_key(default_length)) - .collect(); - logger.warn("⚠️ Salts generated automatically. Store them securely."); - Ok(salts) - } else { - Err(AdvancedTokenError::Message( - "Salt array cannot be empty or less than 2.".to_string(), - )) - } -} - -fn resolve_length_option( - name: &str, - provided: Option, - fallback: usize, - minimum: usize, -) -> Result { - match provided { - None => Ok(fallback), - Some(value) if value < minimum => Err(AdvancedTokenError::Message(format!( - "{} must be an integer greater than or equal to {}.", - name, minimum - ))), - Some(value) => Ok(value), - } -} - -fn normalize_positive_usize( - name: &str, - value: Option, -) -> Result, AdvancedTokenError> { - match value { - None => Ok(None), - Some(0) => Err(AdvancedTokenError::Message(format!( - "{} must be a positive number.", - name - ))), - Some(value) => Ok(Some(value)), - } -} - -fn normalize_allowed_claims( - allowed: Option>, -) -> Result>, AdvancedTokenError> { - match allowed { - None => Ok(None), - Some(values) => { - let mut unique = Vec::new(); - for value in values { - let trimmed = value.trim().to_string(); - if trimmed.is_empty() { - return Err(AdvancedTokenError::Message( - "jwtAllowedClaims must be an array of non-empty strings.".to_string(), - )); - } - if !unique.contains(&trimmed) { - unique.push(trimmed); - } - } - Ok(Some(unique)) - } - } -} - -fn normalize_algorithms( - algorithms: Option>, -) -> Result>, AdvancedTokenError> { - match algorithms { - None => Ok(None), - Some(values) => { - if values.is_empty() { - return Err(AdvancedTokenError::Message( - "jwtDefaultAlgorithms must be a non-empty array when provided.".to_string(), - )); - } - let mut unique = Vec::new(); - for value in values { - if !unique.contains(&value) { - unique.push(value); - } - } - Ok(Some(unique)) - } - } -} - -fn generate_random_key(length: usize) -> String { - let distribution = Uniform::from(0..CHARACTERS.len()); - let mut rng = OsRng; - (0..length) - .map(|_| CHARACTERS[distribution.sample(&mut rng)] as char) - .collect() } diff --git a/src/advanced_token_manager/crypto.rs b/src/advanced_token_manager/crypto.rs new file mode 100644 index 0000000..f05e63f --- /dev/null +++ b/src/advanced_token_manager/crypto.rs @@ -0,0 +1,53 @@ +use hmac::{Hmac, Mac}; +use sha2::{Sha256, Sha512}; + +use super::{Algorithm, TokenValidationError}; + +type HmacSha256 = Hmac; +type HmacSha512 = Hmac; + +impl Algorithm { + pub(super) fn name(self) -> &'static str { + match self { + Algorithm::Sha256 => "HS256", + Algorithm::Sha512 => "HS512", + } + } + + pub(super) fn to_hmac( + self, + secret: &[u8], + input: &[u8], + ) -> Result, TokenValidationError> { + match self { + Algorithm::Sha256 => compute_hmac_sha256(secret, input), + Algorithm::Sha512 => compute_hmac_sha512(secret, input), + } + } +} + +fn compute_hmac_sha256(secret: &[u8], input: &[u8]) -> Result, TokenValidationError> { + let mut mac = HmacSha256::new_from_slice(secret) + .map_err(|_| TokenValidationError::new("Invalid HMAC key."))?; + mac.update(input); + Ok(mac.finalize().into_bytes().to_vec()) +} + +fn compute_hmac_sha512(secret: &[u8], input: &[u8]) -> Result, TokenValidationError> { + let mut mac = HmacSha512::new_from_slice(secret) + .map_err(|_| TokenValidationError::new("Invalid HMAC key."))?; + mac.update(input); + Ok(mac.finalize().into_bytes().to_vec()) +} + +pub(super) fn constant_time_compare(expected: &[u8], provided: &[u8]) -> bool { + if expected.len() != provided.len() { + return false; + } + + let mut diff: u8 = 0; + for (a, b) in expected.iter().zip(provided) { + diff |= a ^ b; + } + diff == 0 +} diff --git a/src/advanced_token_manager/defaults.rs b/src/advanced_token_manager/defaults.rs new file mode 100644 index 0000000..6f5838e --- /dev/null +++ b/src/advanced_token_manager/defaults.rs @@ -0,0 +1,17 @@ +use super::AdvancedTokenError; + +pub(super) fn resolve_length_option( + name: &str, + provided: Option, + fallback: usize, + minimum: usize, +) -> Result { + match provided { + None => Ok(fallback), + Some(value) if value < minimum => Err(AdvancedTokenError::Message(format!( + "{} must be an integer greater than or equal to {}.", + name, minimum + ))), + Some(value) => Ok(value), + } +} diff --git a/src/advanced_token_manager/env.rs b/src/advanced_token_manager/env.rs new file mode 100644 index 0000000..fa484f3 --- /dev/null +++ b/src/advanced_token_manager/env.rs @@ -0,0 +1,28 @@ +use std::env; + +pub(super) fn resolve_secret_candidate(secret: Option, no_env: bool) -> Option { + let provided = secret.map(|value| value.trim().to_string()); + if no_env || provided.is_some() { + provided + } else { + env::var("TOKEN_SECRET") + .ok() + .map(|value| value.trim().to_string()) + } +} + +pub(super) fn resolve_salt_candidates( + salts: Option>, + no_env: bool, +) -> Option> { + if no_env || salts.as_ref().is_some_and(|values| !values.is_empty()) { + salts + } else { + env::var("TOKEN_SALTS").ok().map(|value| { + value + .split(',') + .map(|entry| entry.trim().to_string()) + .collect() + }) + } +} diff --git a/src/advanced_token_manager/error.rs b/src/advanced_token_manager/error.rs new file mode 100644 index 0000000..7e74cd1 --- /dev/null +++ b/src/advanced_token_manager/error.rs @@ -0,0 +1,23 @@ +use thiserror::Error; + +use crate::jwt::JwtError; + +#[derive(Debug, Error)] +pub enum AdvancedTokenError { + #[error("{0}")] + Message(String), + #[error(transparent)] + Jwt(#[from] JwtError), +} + +#[derive(Debug, Error, Clone)] +pub enum TokenValidationError { + #[error("{0}")] + Message(String), +} + +impl TokenValidationError { + pub(super) fn new(message: impl Into) -> Self { + Self::Message(message.into()) + } +} diff --git a/src/advanced_token_manager/init.rs b/src/advanced_token_manager/init.rs new file mode 100644 index 0000000..30208bd --- /dev/null +++ b/src/advanced_token_manager/init.rs @@ -0,0 +1,106 @@ +use super::defaults::resolve_length_option; +use super::env::{resolve_salt_candidates, resolve_secret_candidate}; +use super::normalize::{normalize_algorithms, normalize_allowed_claims, normalize_positive_usize}; +use super::random::generate_random_key; +use super::salts::validate_salts; +use super::secret::{short_secret_error, validate_secret}; +use super::{ + AdvancedTokenError, AdvancedTokenManagerLogger, AdvancedTokenManagerOptions, + DEFAULT_SALT_COUNT, DEFAULT_SALT_LENGTH, DEFAULT_SECRET_LENGTH, MIN_SALT_COUNT, + MIN_SECRET_LENGTH, +}; +use crate::jwt::JwtAlgorithm; + +pub(super) struct ManagerDefaults { + pub secret_length: usize, + pub salt_count: usize, + pub salt_length: usize, +} + +pub(super) struct JwtManagerOptions { + pub default_algorithms: Option>, + pub throw_on_validation_failure: bool, + pub max_payload_size: Option, + pub allowed_claims: Option>, +} + +pub(super) fn resolve_defaults( + options: &AdvancedTokenManagerOptions, +) -> Result { + Ok(ManagerDefaults { + secret_length: resolve_length_option( + "defaultSecretLength", + options.default_secret_length, + DEFAULT_SECRET_LENGTH, + MIN_SECRET_LENGTH, + )?, + salt_count: resolve_length_option( + "defaultSaltCount", + options.default_salt_count, + DEFAULT_SALT_COUNT, + MIN_SALT_COUNT, + )?, + salt_length: resolve_length_option( + "defaultSaltLength", + options.default_salt_length, + DEFAULT_SALT_LENGTH, + 1, + )?, + }) +} + +pub(super) fn resolve_jwt_options( + options: AdvancedTokenManagerOptions, +) -> Result { + Ok(JwtManagerOptions { + default_algorithms: normalize_algorithms(options.jwt_default_algorithms)?, + throw_on_validation_failure: options.throw_on_validation_failure.unwrap_or(false), + max_payload_size: normalize_positive_usize( + "jwtMaxPayloadSize", + options.jwt_max_payload_size, + )?, + allowed_claims: normalize_allowed_claims(options.jwt_allowed_claims)?, + }) +} + +pub(super) fn initialize_secret( + secret: Option, + allow_auto_generate: bool, + no_env: bool, + default_length: usize, + logger: &dyn AdvancedTokenManagerLogger, +) -> Result { + let candidate = resolve_secret_candidate(secret, no_env); + match candidate { + Some(secret) => validate_secret(secret), + None if allow_auto_generate => { + let generated = generate_random_key(default_length); + logger.warn("⚠️ Secret generated automatically. Store it securely."); + Ok(generated) + } + None => Err(short_secret_error()), + } +} + +pub(super) fn initialize_salts( + salts: Option>, + allow_auto_generate: bool, + no_env: bool, + default_count: usize, + default_length: usize, + logger: &dyn AdvancedTokenManagerLogger, +) -> Result, AdvancedTokenError> { + match resolve_salt_candidates(salts, no_env) { + Some(values) => validate_salts(values), + None if allow_auto_generate => { + let salts = (0..default_count) + .map(|_| generate_random_key(default_length)) + .collect(); + logger.warn("⚠️ Salts generated automatically. Store them securely."); + Ok(salts) + } + None => Err(AdvancedTokenError::Message( + "Salt array cannot be empty or less than 2.".to_string(), + )), + } +} diff --git a/src/advanced_token_manager/jwt.rs b/src/advanced_token_manager/jwt.rs new file mode 100644 index 0000000..4621984 --- /dev/null +++ b/src/advanced_token_manager/jwt.rs @@ -0,0 +1,54 @@ +use serde::de::DeserializeOwned; + +use super::{ + AdvancedTokenError, AdvancedTokenManager, ManagerSignJwtOptions, ManagerVerifyJwtOptions, +}; +use crate::jwt::{sign_jwt, verify_jwt_as, JwtClaims, SignJwtOptions, VerifyJwtOptions}; + +impl AdvancedTokenManager { + pub fn generate_jwt( + &self, + payload: &JwtClaims, + options: Option, + ) -> Result { + let options = options.unwrap_or_default(); + let sign_options = SignJwtOptions { + secret: options.secret.unwrap_or_else(|| self.secret.clone()), + algorithm: options.algorithm, + header: options.header, + expires_in: options.expires_in, + not_before: options.not_before, + audience: options.audience, + issuer: options.issuer, + subject: options.subject, + issued_at: options.issued_at, + clock_timestamp: options.clock_timestamp, + }; + Ok(sign_jwt(payload, &sign_options)?) + } + + pub fn validate_jwt( + &self, + token: &str, + options: Option, + ) -> Result { + let options = options.unwrap_or_default(); + let verify_options = VerifyJwtOptions { + secret: options.secret.unwrap_or_else(|| self.secret.clone()), + algorithms: options + .algorithms + .or_else(|| self.jwt_default_algorithms.clone()), + clock_tolerance: options.clock_tolerance, + audience: options.audience, + issuer: options.issuer, + subject: options.subject, + max_age: options.max_age, + clock_timestamp: options.clock_timestamp, + max_payload_size: options.max_payload_size.or(self.jwt_max_payload_size), + allowed_claims: options + .allowed_claims + .or_else(|| self.jwt_allowed_claims.clone()), + }; + Ok(verify_jwt_as(token, &verify_options)?) + } +} diff --git a/src/advanced_token_manager/native.rs b/src/advanced_token_manager/native.rs new file mode 100644 index 0000000..c0e59b2 --- /dev/null +++ b/src/advanced_token_manager/native.rs @@ -0,0 +1,24 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize)] +pub(super) struct NativeTokenMeta { + pub v: u8, + pub alg: String, + pub salt: usize, + pub iat: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub exp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub iss: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub aud: Option, +} + +pub(super) struct NativeTokenParts { + pub payload: String, + pub meta: NativeTokenMeta, + pub signing_input: String, + pub signature: String, +} + +pub(super) const TOKEN_VERSION: &str = "htr1"; diff --git a/src/advanced_token_manager/normalize.rs b/src/advanced_token_manager/normalize.rs new file mode 100644 index 0000000..95d48b1 --- /dev/null +++ b/src/advanced_token_manager/normalize.rs @@ -0,0 +1,67 @@ +use super::AdvancedTokenError; +use crate::jwt::JwtAlgorithm; + +pub(super) fn normalize_positive_usize( + name: &str, + value: Option, +) -> Result, AdvancedTokenError> { + match value { + None => Ok(None), + Some(0) => Err(AdvancedTokenError::Message(format!( + "{} must be a positive number.", + name + ))), + Some(value) => Ok(Some(value)), + } +} + +pub(super) fn normalize_allowed_claims( + allowed: Option>, +) -> Result>, AdvancedTokenError> { + match allowed { + None => Ok(None), + Some(values) => normalize_unique_strings(values, "jwtAllowedClaims"), + } +} + +pub(super) fn normalize_algorithms( + algorithms: Option>, +) -> Result>, AdvancedTokenError> { + match algorithms { + None => Ok(None), + Some(values) if values.is_empty() => Err(AdvancedTokenError::Message( + "jwtDefaultAlgorithms must be a non-empty array when provided.".to_string(), + )), + Some(values) => Ok(Some(unique_algorithms(values))), + } +} + +fn normalize_unique_strings( + values: Vec, + name: &str, +) -> Result>, AdvancedTokenError> { + let mut unique = Vec::new(); + for value in values { + let trimmed = value.trim().to_string(); + if trimmed.is_empty() { + return Err(AdvancedTokenError::Message(format!( + "{} must be an array of non-empty strings.", + name + ))); + } + if !unique.contains(&trimmed) { + unique.push(trimmed); + } + } + Ok(Some(unique)) +} + +fn unique_algorithms(values: Vec) -> Vec { + let mut unique = Vec::new(); + for value in values { + if !unique.contains(&value) { + unique.push(value); + } + } + unique +} diff --git a/src/advanced_token_manager/options.rs b/src/advanced_token_manager/options.rs new file mode 100644 index 0000000..1465db1 --- /dev/null +++ b/src/advanced_token_manager/options.rs @@ -0,0 +1,65 @@ +use std::sync::Arc; + +use serde_json::{Map, Value}; + +use crate::advanced_token_manager::AdvancedTokenManagerLogger; +use crate::jwt::{Audience, Issuer, JwtAlgorithm}; + +#[derive(Default, Clone)] +pub struct AdvancedTokenManagerOptions { + pub logger: Option>, + pub jwt_default_algorithms: Option>, + pub default_secret_length: Option, + pub default_salt_count: Option, + pub default_salt_length: Option, + pub throw_on_validation_failure: Option, + pub jwt_max_payload_size: Option, + pub jwt_allowed_claims: Option>, +} + +#[derive(Default, Clone)] +pub struct GenerateTokenOptions { + pub salt_index: Option, + pub expires_in: Option, + pub issuer: Option, + pub audience: Option, + pub issued_at: Option, +} + +#[derive(Default, Clone)] +pub struct ValidateTokenOptions { + pub throw_on_failure: Option, + pub max_age: Option, + pub issuer: Option, + pub audience: Option, + pub clock_tolerance: Option, + pub clock_timestamp: Option, +} + +#[derive(Default, Clone)] +pub struct ManagerSignJwtOptions { + pub secret: Option, + pub algorithm: Option, + pub header: Option>, + pub expires_in: Option, + pub not_before: Option, + pub audience: Option, + pub issuer: Option, + pub subject: Option, + pub issued_at: Option, + pub clock_timestamp: Option, +} + +#[derive(Default, Clone)] +pub struct ManagerVerifyJwtOptions { + pub secret: Option, + pub algorithms: Option>, + pub clock_tolerance: Option, + pub audience: Option, + pub issuer: Option, + pub subject: Option, + pub max_age: Option, + pub clock_timestamp: Option, + pub max_payload_size: Option, + pub allowed_claims: Option>, +} diff --git a/src/advanced_token_manager/random.rs b/src/advanced_token_manager/random.rs new file mode 100644 index 0000000..a6f283f --- /dev/null +++ b/src/advanced_token_manager/random.rs @@ -0,0 +1,12 @@ +use rand::distributions::{Distribution, Uniform}; +use rand::rngs::OsRng; + +const CHARACTERS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + +pub(super) fn generate_random_key(length: usize) -> String { + let distribution = Uniform::from(0..CHARACTERS.len()); + let mut rng = OsRng; + (0..length) + .map(|_| CHARACTERS[distribution.sample(&mut rng)] as char) + .collect() +} diff --git a/src/advanced_token_manager/salts.rs b/src/advanced_token_manager/salts.rs new file mode 100644 index 0000000..489030c --- /dev/null +++ b/src/advanced_token_manager/salts.rs @@ -0,0 +1,17 @@ +use super::{AdvancedTokenError, MIN_SALT_COUNT}; + +pub(super) fn validate_salts(values: Vec) -> Result, AdvancedTokenError> { + let sanitized: Vec = values + .into_iter() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .collect(); + if sanitized.len() < MIN_SALT_COUNT { + Err(AdvancedTokenError::Message(format!( + "Salt array cannot be empty or less than {}.", + MIN_SALT_COUNT + ))) + } else { + Ok(sanitized) + } +} diff --git a/src/advanced_token_manager/secret.rs b/src/advanced_token_manager/secret.rs new file mode 100644 index 0000000..4cc78cc --- /dev/null +++ b/src/advanced_token_manager/secret.rs @@ -0,0 +1,16 @@ +use super::{AdvancedTokenError, MIN_SECRET_LENGTH}; + +pub(super) fn validate_secret(secret: String) -> Result { + if secret.len() < MIN_SECRET_LENGTH { + Err(short_secret_error()) + } else { + Ok(secret) + } +} + +pub(super) fn short_secret_error() -> AdvancedTokenError { + AdvancedTokenError::Message(format!( + "Secret must be at least {} characters long.", + MIN_SECRET_LENGTH + )) +} diff --git a/src/advanced_token_manager/time.rs b/src/advanced_token_manager/time.rs new file mode 100644 index 0000000..490d5e1 --- /dev/null +++ b/src/advanced_token_manager/time.rs @@ -0,0 +1,29 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use super::TokenValidationError; + +pub(super) fn current_timestamp(clock: Option) -> Result { + if let Some(value) = clock { + if !value.is_finite() { + return Err(TokenValidationError::new( + "clockTimestamp must be a finite number.", + )); + } + return Ok(value.floor() as i64); + } + + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| TokenValidationError::new("System time before UNIX_EPOCH."))?; + Ok(duration.as_secs() as i64) +} + +pub(super) fn positive_seconds(value: f64, name: &str) -> Result { + if !value.is_finite() || value <= 0.0 { + return Err(TokenValidationError::new(format!( + "{} must be a positive number of seconds.", + name + ))); + } + Ok(value.floor() as i64) +} diff --git a/src/advanced_token_manager/token.rs b/src/advanced_token_manager/token.rs new file mode 100644 index 0000000..48cb061 --- /dev/null +++ b/src/advanced_token_manager/token.rs @@ -0,0 +1,163 @@ +mod build; +mod parse; +mod validate; + +use rand::Rng; + +use super::native::NativeTokenMeta; +use super::{ + AdvancedTokenManager, GenerateTokenOptions, TokenValidationError, ValidateTokenOptions, +}; + +impl AdvancedTokenManager { + pub fn generate_token( + &mut self, + input: &str, + salt_index: Option, + ) -> Result { + self.generate_token_with_options( + input, + Some(GenerateTokenOptions { + salt_index, + ..Default::default() + }), + ) + } + + pub fn generate_token_with_options( + &mut self, + input: &str, + options: Option, + ) -> Result { + let options = options.unwrap_or_default(); + let index = self.resolve_salt_index(options.salt_index)?; + let meta = self.build_meta(index, &options)?; + let signing_input = self.create_signature_input(input, &meta)?; + let signature = self.sign_native_input(&signing_input, index)?; + build::build_token(input, &meta, &signature) + } + + pub fn validate_token(&self, token: &str) -> Result, TokenValidationError> { + self.validate_token_with_options(token, None) + } + + pub fn validate_token_with_options( + &self, + token: &str, + options: Option, + ) -> Result, TokenValidationError> { + let options = options.unwrap_or_default(); + let should_throw = options + .throw_on_failure + .unwrap_or(self.throw_on_validation_failure); + + match self.validate_token_internal(token, &options) { + Ok(value) => Ok(Some(value)), + Err(error) if should_throw => Err(error), + Err(error) => { + self.logger + .error(&format!("Error validating token: {}", error)); + Ok(None) + } + } + } + + pub fn validate_token_lenient(&self, token: &str) -> Option { + self.validate_token(token).ok().flatten() + } + + pub fn extract_data(&self, token: &str) -> Result, TokenValidationError> { + self.validate_token(token) + } +} + +impl AdvancedTokenManager { + fn validate_token_internal( + &self, + token: &str, + options: &ValidateTokenOptions, + ) -> Result { + let parts = parse::parse_token(token)?; + self.validate_salt_index(parts.meta.salt)?; + validate::validate_metadata(&parts.meta, options)?; + validate::verify_scope(&parts.meta, options)?; + validate::verify_signature(self, &parts)?; + Ok(parts.payload) + } + + fn build_meta( + &mut self, + index: usize, + options: &GenerateTokenOptions, + ) -> Result { + let issued_at = super::time::current_timestamp(options.issued_at)?; + Ok(NativeTokenMeta { + v: 1, + alg: self.algorithm.name().to_string(), + salt: index, + iat: issued_at, + exp: build::expiration(issued_at, options.expires_in)?, + iss: options.issuer.clone(), + aud: options.audience.clone(), + }) + } + + fn resolve_salt_index( + &mut self, + salt_index: Option, + ) -> Result { + match salt_index { + Some(index) => { + self.validate_salt_index(index)?; + Ok(index) + } + None => Ok(self.get_random_salt_index()), + } + } + + pub(super) fn validate_salt_index(&self, index: usize) -> Result<(), TokenValidationError> { + if index < self.salts.len() { + Ok(()) + } else { + Err(TokenValidationError::new(format!( + "Invalid salt index: {}", + index + ))) + } + } + + pub(super) fn create_signature_input( + &self, + payload: &str, + meta: &NativeTokenMeta, + ) -> Result { + build::signing_input(payload, meta) + } + + pub(super) fn sign_native_input( + &self, + signing_input: &str, + salt_index: usize, + ) -> Result { + let mut material = + String::with_capacity(signing_input.len() + self.salts[salt_index].len()); + material.push_str(signing_input); + material.push_str(&self.salts[salt_index]); + let digest = self + .algorithm + .to_hmac(self.secret.as_bytes(), material.as_bytes())?; + Ok(hex::encode(digest)) + } + + fn get_random_salt_index(&mut self) -> usize { + let len = self.salts.len(); + let mut rng = rand::thread_rng(); + loop { + let index = rng.gen_range(0..len); + if Some(index) != self.last_salt_index { + self.last_salt_index = Some(index); + return index; + } + } + } +} diff --git a/src/advanced_token_manager/token/build.rs b/src/advanced_token_manager/token/build.rs new file mode 100644 index 0000000..a4c3123 --- /dev/null +++ b/src/advanced_token_manager/token/build.rs @@ -0,0 +1,50 @@ +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + +use crate::advanced_token_manager::native::{NativeTokenMeta, TOKEN_VERSION}; +use crate::advanced_token_manager::time::positive_seconds; +use crate::advanced_token_manager::TokenValidationError; + +pub(super) fn build_token( + payload: &str, + meta: &NativeTokenMeta, + signature: &str, +) -> Result { + let encoded_payload = URL_SAFE_NO_PAD.encode(payload.as_bytes()); + let encoded_meta = encode_meta(meta)?; + Ok(format!( + "{}.{}.{}.{}", + TOKEN_VERSION, encoded_payload, encoded_meta, signature + )) +} + +pub(super) fn signing_input( + payload: &str, + meta: &NativeTokenMeta, +) -> Result { + let encoded_payload = URL_SAFE_NO_PAD.encode(payload.as_bytes()); + let encoded_meta = encode_meta(meta)?; + Ok(format!( + "{}.{}.{}", + TOKEN_VERSION, encoded_payload, encoded_meta + )) +} + +pub(super) fn expiration( + issued_at: i64, + expires_in: Option, +) -> Result, TokenValidationError> { + match expires_in { + Some(value) => Ok(Some( + issued_at + .checked_add(positive_seconds(value, "expiresIn")?) + .ok_or_else(|| TokenValidationError::new("Token temporal claim overflow."))?, + )), + None => Ok(None), + } +} + +fn encode_meta(meta: &NativeTokenMeta) -> Result { + let json = serde_json::to_vec(meta) + .map_err(|_| TokenValidationError::new("Failed to serialize token metadata."))?; + Ok(URL_SAFE_NO_PAD.encode(json)) +} diff --git a/src/advanced_token_manager/token/parse.rs b/src/advanced_token_manager/token/parse.rs new file mode 100644 index 0000000..7b139ac --- /dev/null +++ b/src/advanced_token_manager/token/parse.rs @@ -0,0 +1,49 @@ +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + +use crate::advanced_token_manager::native::{NativeTokenMeta, NativeTokenParts, TOKEN_VERSION}; +use crate::advanced_token_manager::TokenValidationError; + +pub(super) fn parse_token(token: &str) -> Result { + let segments = split_token(token)?; + let payload = decode_payload(segments.payload)?; + let meta = decode_meta(segments.meta)?; + Ok(NativeTokenParts { + payload, + meta, + signing_input: format!("{}.{}.{}", TOKEN_VERSION, segments.payload, segments.meta), + signature: segments.signature.to_string(), + }) +} + +struct Segments<'a> { + payload: &'a str, + meta: &'a str, + signature: &'a str, +} + +fn split_token(token: &str) -> Result, TokenValidationError> { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 4 || parts[0] != TOKEN_VERSION || parts.iter().any(|part| part.is_empty()) { + return Err(TokenValidationError::new("Invalid native token structure.")); + } + Ok(Segments { + payload: parts[1], + meta: parts[2], + signature: parts[3], + }) +} + +fn decode_payload(encoded: &str) -> Result { + let bytes = URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|_| TokenValidationError::new("Invalid native token payload."))?; + String::from_utf8(bytes).map_err(|_| TokenValidationError::new("Payload is not valid UTF-8.")) +} + +fn decode_meta(encoded: &str) -> Result { + let bytes = URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|_| TokenValidationError::new("Invalid native token metadata."))?; + serde_json::from_slice(&bytes) + .map_err(|_| TokenValidationError::new("Metadata is not valid JSON.")) +} diff --git a/src/advanced_token_manager/token/validate.rs b/src/advanced_token_manager/token/validate.rs new file mode 100644 index 0000000..f1b87f2 --- /dev/null +++ b/src/advanced_token_manager/token/validate.rs @@ -0,0 +1,39 @@ +mod scope; +mod temporal; + +use crate::advanced_token_manager::crypto::constant_time_compare; +use crate::advanced_token_manager::native::{NativeTokenMeta, NativeTokenParts}; +use crate::advanced_token_manager::{ + AdvancedTokenManager, TokenValidationError, ValidateTokenOptions, +}; + +pub(super) fn validate_metadata( + meta: &NativeTokenMeta, + options: &ValidateTokenOptions, +) -> Result<(), TokenValidationError> { + if meta.v != 1 { + return Err(TokenValidationError::new( + "Unsupported native token version.", + )); + } + temporal::validate_temporal_metadata(meta, options) +} + +pub(super) fn verify_scope( + meta: &NativeTokenMeta, + options: &ValidateTokenOptions, +) -> Result<(), TokenValidationError> { + scope::verify_scope(meta, options) +} + +pub(super) fn verify_signature( + manager: &AdvancedTokenManager, + parts: &NativeTokenParts, +) -> Result<(), TokenValidationError> { + let expected = manager.sign_native_input(&parts.signing_input, parts.meta.salt)?; + if constant_time_compare(expected.as_bytes(), parts.signature.as_bytes()) { + Ok(()) + } else { + Err(TokenValidationError::new("Checksum mismatch.")) + } +} diff --git a/src/advanced_token_manager/token/validate/scope.rs b/src/advanced_token_manager/token/validate/scope.rs new file mode 100644 index 0000000..59bd6ae --- /dev/null +++ b/src/advanced_token_manager/token/validate/scope.rs @@ -0,0 +1,26 @@ +use crate::advanced_token_manager::native::NativeTokenMeta; +use crate::advanced_token_manager::{TokenValidationError, ValidateTokenOptions}; + +pub(super) fn verify_scope( + meta: &NativeTokenMeta, + options: &ValidateTokenOptions, +) -> Result<(), TokenValidationError> { + require_match("issuer", meta.iss.as_deref(), options.issuer.as_deref())?; + require_match("audience", meta.aud.as_deref(), options.audience.as_deref()) +} + +fn require_match( + name: &str, + actual: Option<&str>, + expected: Option<&str>, +) -> Result<(), TokenValidationError> { + match (actual, expected) { + (_, None) => Ok(()), + (Some(actual), Some(expected)) if actual == expected => Ok(()), + (None, Some(_)) => Err(TokenValidationError::new(format!( + "Missing required {}.", + name + ))), + (Some(_), Some(_)) => Err(TokenValidationError::new(format!("{} mismatch.", name))), + } +} diff --git a/src/advanced_token_manager/token/validate/temporal.rs b/src/advanced_token_manager/token/validate/temporal.rs new file mode 100644 index 0000000..ea3ab64 --- /dev/null +++ b/src/advanced_token_manager/token/validate/temporal.rs @@ -0,0 +1,58 @@ +use crate::advanced_token_manager::native::NativeTokenMeta; +use crate::advanced_token_manager::time::{current_timestamp, positive_seconds}; +use crate::advanced_token_manager::{TokenValidationError, ValidateTokenOptions}; + +pub(super) fn validate_temporal_metadata( + meta: &NativeTokenMeta, + options: &ValidateTokenOptions, +) -> Result<(), TokenValidationError> { + let now = current_timestamp(options.clock_timestamp)?; + let tolerance = normalize_tolerance(options.clock_tolerance)?; + validate_expiration(meta, now, tolerance)?; + validate_max_age(meta, options, now, tolerance) +} + +fn normalize_tolerance(value: Option) -> Result { + match value { + Some(value) if value.is_finite() && value >= 0.0 => Ok(value.floor() as i64), + Some(_) => Err(TokenValidationError::new( + "clockTolerance must be a non-negative number.", + )), + None => Ok(0), + } +} + +fn validate_expiration( + meta: &NativeTokenMeta, + now: i64, + tolerance: i64, +) -> Result<(), TokenValidationError> { + if let Some(exp) = meta.exp { + let exp = exp + .checked_add(tolerance) + .ok_or_else(|| TokenValidationError::new("Token temporal claim overflow."))?; + if now > exp { + return Err(TokenValidationError::new("Token expired.")); + } + } + Ok(()) +} + +fn validate_max_age( + meta: &NativeTokenMeta, + options: &ValidateTokenOptions, + now: i64, + tolerance: i64, +) -> Result<(), TokenValidationError> { + if let Some(max_age) = options.max_age { + let max_age = positive_seconds(max_age, "maxAge")?; + let age = now + .checked_sub(meta.iat) + .and_then(|value| value.checked_sub(tolerance)) + .ok_or_else(|| TokenValidationError::new("Token temporal claim overflow."))?; + if age > max_age { + return Err(TokenValidationError::new("Token exceeds maxAge.")); + } + } + Ok(()) +} diff --git a/src/jwt.rs b/src/jwt.rs index 60b1841..140580d 100644 --- a/src/jwt.rs +++ b/src/jwt.rs @@ -1,156 +1,24 @@ -use std::collections::HashSet; -use std::fmt::{self, Display}; -use std::str::FromStr; -use std::time::{SystemTime, UNIX_EPOCH}; +mod base64url; +mod claims; +mod error; +mod signing; +mod time; +mod types; +mod verify; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use base64::Engine; -use hmac::{Hmac, Mac}; use serde::de::DeserializeOwned; -use serde_json::{Map, Value}; -use sha2::{Sha256, Sha512}; -use thiserror::Error; +use serde_json::Value; -const STANDARD_CLAIMS: [&str; 6] = ["iss", "sub", "aud", "exp", "nbf", "iat"]; -const BASE64URL_ALLOWED: &[u8] = - b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; +pub use error::JwtError; +pub use types::{Audience, Issuer, JwtAlgorithm, JwtClaims, SignJwtOptions, VerifyJwtOptions}; -pub type JwtClaims = Map; - -type HmacSha256 = Hmac; -type HmacSha512 = Hmac; - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum JwtAlgorithm { - HS256, - HS512, -} - -impl JwtAlgorithm {} - -impl Display for JwtAlgorithm { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - JwtAlgorithm::HS256 => write!(f, "HS256"), - JwtAlgorithm::HS512 => write!(f, "HS512"), - } - } -} - -impl FromStr for JwtAlgorithm { - type Err = JwtError; - - fn from_str(s: &str) -> Result { - match s { - "HS256" => Ok(JwtAlgorithm::HS256), - "HS512" => Ok(JwtAlgorithm::HS512), - other => Err(JwtError::new(format!( - "JWT: unsupported algorithm: {}.", - other - ))), - } - } -} - -#[derive(Debug, Error)] -#[error("{message}")] -pub struct JwtError { - message: String, -} - -impl JwtError { - pub fn new(message: impl Into) -> Self { - Self { - message: message.into(), - } - } - - fn claim_conflict(claim: &str) -> Self { - Self::new(format!( - "JWT: claim \"{}\" already present with a different value.", - claim - )) - } -} - -#[derive(Clone, Debug, Default)] -pub struct SignJwtOptions { - pub secret: String, - pub algorithm: Option, - pub header: Option>, - pub expires_in: Option, - pub not_before: Option, - pub audience: Option, - pub issuer: Option, - pub subject: Option, - pub issued_at: Option, - pub clock_timestamp: Option, -} - -#[derive(Clone, Debug, Default)] -pub struct VerifyJwtOptions { - pub secret: String, - pub algorithms: Option>, - pub clock_tolerance: Option, - pub audience: Option, - pub issuer: Option, - pub subject: Option, - pub max_age: Option, - pub clock_timestamp: Option, - pub max_payload_size: Option, - pub allowed_claims: Option>, -} - -#[derive(Clone, Debug)] -pub enum Audience { - Single(String), - Multiple(Vec), -} - -impl Audience { - fn into_vec(self) -> Result, JwtError> { - match self { - Audience::Single(value) => { - let normalized = normalize_string(value, "Audience")?; - Ok(vec![normalized]) - } - Audience::Multiple(values) => { - if values.is_empty() { - return Err(JwtError::new("JWT: audience array must not be empty.")); - } - let mut normalized = Vec::with_capacity(values.len()); - for value in values { - normalized.push(normalize_string(value, "Audience")?); - } - Ok(normalized) - } - } - } -} - -#[derive(Clone, Debug)] -pub enum Issuer { - Single(String), - Multiple(Vec), -} - -impl Issuer { - fn into_vec(self) -> Result, JwtError> { - match self { - Issuer::Single(value) => Ok(vec![normalize_string(value, "Issuer")?]), - Issuer::Multiple(values) => { - if values.is_empty() { - return Err(JwtError::new("JWT: issuer array must not be empty.")); - } - let mut normalized = Vec::with_capacity(values.len()); - for value in values { - normalized.push(normalize_string(value, "Issuer")?); - } - Ok(normalized) - } - } - } -} +use claims::{ + apply_audience, apply_expires_in, apply_issued_at, apply_issuer, apply_not_before, + apply_subject, +}; +use signing::create_signature; +use time::current_timestamp; +use verify::verify_token; pub fn sign_jwt(payload: &JwtClaims, options: &SignJwtOptions) -> Result { if options.secret.trim().is_empty() { @@ -160,8 +28,7 @@ pub fn sign_jwt(payload: &JwtClaims, options: &SignJwtOptions) -> Result Result Result Result { - if token.trim().is_empty() { - return Err(JwtError::new("JWT: token must be a non-empty string.")); - } - if options.secret.trim().is_empty() { - return Err(JwtError::new( - "JWT: a non-empty secret is required to verify.", - )); - } - - let parts: Vec<&str> = token.split('.').collect(); - if parts.len() != 3 || parts.iter().any(|part| part.is_empty()) { - return Err(JwtError::new("JWT: invalid token structure.")); - } - - let encoded_header = parts[0]; - let encoded_payload = parts[1]; - let encoded_signature = parts[2]; - - let header_bytes = base64url_decode(encoded_header, "header")?; - let header_value: Value = serde_json::from_slice(&header_bytes) - .map_err(|_| JwtError::new("JWT: invalid header JSON."))?; - let header_obj = header_value - .as_object() - .ok_or_else(|| JwtError::new("JWT: header must be a JSON object."))?; - - let alg_value = header_obj - .get("alg") - .and_then(Value::as_str) - .ok_or_else(|| JwtError::new("JWT: missing algorithm."))?; - - if alg_value.eq_ignore_ascii_case("none") { - return Err(JwtError::new( - "JWT: unsigned tokens (alg \"none\") are not allowed.", - )); - } - - let algorithm = JwtAlgorithm::from_str(alg_value.to_ascii_uppercase().as_str())?; - - if let Some(allowed) = &options.algorithms { - if !allowed.contains(&algorithm) { - return Err(JwtError::new(format!( - "JWT: algorithm {} is not allowed.", - algorithm - ))); - } - } - - if let Some(typ_value) = header_obj.get("typ") { - let typ = typ_value - .as_str() - .ok_or_else(|| JwtError::new("JWT: header type must be a string."))?; - if typ != "JWT" { - return Err(JwtError::new("JWT: header type must be \"JWT\".")); - } - } - - let payload_bytes = base64url_decode(encoded_payload, "payload")?; - if let Some(max_size) = options.max_payload_size { - if payload_bytes.len() > max_size { - return Err(JwtError::new("JWT: payload exceeds maxPayloadSize.")); - } - } - - let payload_value: Value = serde_json::from_slice(&payload_bytes) - .map_err(|_| JwtError::new("JWT: invalid payload JSON."))?; - let payload = payload_value - .as_object() - .cloned() - .ok_or_else(|| JwtError::new("JWT: payload must be a JSON object."))?; - - if let Some(allowed_claims) = &options.allowed_claims { - enforce_allowed_claims(&payload, allowed_claims)?; - } - - verify_signature( - algorithm, - &options.secret, - encoded_header, - encoded_payload, - encoded_signature, - )?; - - validate_temporal_claims(&payload, options)?; - validate_audience(&payload, options)?; - validate_issuer(&payload, options)?; - validate_subject(&payload, options)?; - - Ok(payload) + verify_token(token, options) } pub fn verify_jwt_as( @@ -287,463 +63,8 @@ pub fn verify_jwt_as( .map_err(|_| JwtError::new("JWT: payload could not be deserialized into target type.")) } -fn build_header(options: &SignJwtOptions, algorithm: JwtAlgorithm) -> Result { - let mut header = match &options.header { - Some(custom) => custom.clone(), - None => Map::new(), - }; - - if let Some(alg) = header.get("alg") { - if alg.as_str() != Some(&algorithm.to_string()) { - return Err(JwtError::new("JWT: header algorithm mismatch.")); - } - } - - if let Some(typ) = header.get("typ") { - if typ.as_str() != Some("JWT") { - return Err(JwtError::new("JWT: header type must be \"JWT\".")); - } - } - - header.insert("alg".to_string(), Value::String(algorithm.to_string())); - header.insert("typ".to_string(), Value::String("JWT".to_string())); - - Ok(header) -} - -fn apply_issued_at( - claims: &mut JwtClaims, - issued_at: Option, - timestamp: i64, -) -> Result<(), JwtError> { - if let Some(value) = issued_at { - let normalized = normalize_number(value, "iat")?; - enforce_claim(claims, "iat", Value::from(normalized)) - } else if let Some(existing) = claims.get("iat") { - ensure_numeric(existing, "iat")?; - Ok(()) - } else { - claims.insert("iat".to_string(), Value::from(timestamp)); - Ok(()) - } -} - -fn apply_expires_in( - claims: &mut JwtClaims, - expires_in: Option, - timestamp: i64, -) -> Result<(), JwtError> { - if let Some(value) = expires_in { - if !value.is_finite() || value <= 0.0 { - return Err(JwtError::new( - "JWT: expiresIn must be a positive number of seconds.", - )); - } - let exp = timestamp + value.floor() as i64; - enforce_claim(claims, "exp", Value::from(exp)) - } else if let Some(existing) = claims.get("exp") { - ensure_numeric(existing, "exp") - } else { - Ok(()) - } -} - -fn apply_not_before( - claims: &mut JwtClaims, - not_before: Option, - timestamp: i64, -) -> Result<(), JwtError> { - if let Some(value) = not_before { - if !value.is_finite() { - return Err(JwtError::new("JWT: notBefore must be a number of seconds.")); - } - let nbf = timestamp + value.floor() as i64; - enforce_claim(claims, "nbf", Value::from(nbf)) - } else if let Some(existing) = claims.get("nbf") { - ensure_numeric(existing, "nbf") - } else { - Ok(()) - } -} - -fn apply_audience(claims: &mut JwtClaims, audience: Option) -> Result<(), JwtError> { - if let Some(audience) = audience { - let audiences = audience.into_vec()?; - let value = if audiences.len() == 1 { - Value::String(audiences[0].clone()) - } else { - Value::Array(audiences.into_iter().map(Value::String).collect()) - }; - enforce_claim(claims, "aud", value) - } else if let Some(existing) = claims.get("aud") { - if existing.is_array() { - normalize_audience_array(existing)?; - } else { - ensure_string(existing, "aud")?; - } - Ok(()) - } else { - Ok(()) - } -} - -fn apply_issuer(claims: &mut JwtClaims, issuer: Option) -> Result<(), JwtError> { - if let Some(issuer) = issuer { - let normalized = normalize_string(issuer, "Issuer")?; - enforce_claim(claims, "iss", Value::String(normalized)) - } else if let Some(existing) = claims.get("iss") { - ensure_string(existing, "iss") - } else { - Ok(()) - } -} - -fn apply_subject(claims: &mut JwtClaims, subject: Option) -> Result<(), JwtError> { - if let Some(subject) = subject { - let normalized = normalize_string(subject, "Subject")?; - enforce_claim(claims, "sub", Value::String(normalized)) - } else if let Some(existing) = claims.get("sub") { - ensure_string(existing, "sub") - } else { - Ok(()) - } -} - -fn enforce_claim(claims: &mut JwtClaims, key: &str, value: Value) -> Result<(), JwtError> { - match claims.get(key) { - Some(existing) if *existing != value => Err(JwtError::claim_conflict(key)), - Some(_) => Ok(()), - None => { - claims.insert(key.to_string(), value); - Ok(()) - } - } -} - -fn ensure_numeric(value: &Value, claim: &str) -> Result<(), JwtError> { - match value { - Value::Number(number) if number.as_i64().is_some() => Ok(()), - _ => Err(JwtError::new(format!( - "JWT: Claim \"{}\" must be a finite number.", - claim - ))), - } -} - -fn ensure_string(value: &Value, claim: &str) -> Result<(), JwtError> { - match value { - Value::String(text) if !text.is_empty() => Ok(()), - _ => Err(JwtError::new(format!( - "JWT: Claim \"{}\" must be a non-empty string.", - claim - ))), - } -} - -fn normalize_audience_array(value: &Value) -> Result, JwtError> { - let items = value - .as_array() - .ok_or_else(|| JwtError::new("JWT: audience must be an array of strings."))?; - if items.is_empty() { - return Err(JwtError::new("JWT: audience array must not be empty.")); - } - let mut normalized = Vec::with_capacity(items.len()); - for item in items { - let string = item - .as_str() - .ok_or_else(|| JwtError::new("JWT: audience must be an array of strings."))?; - if string.is_empty() { - return Err(JwtError::new("JWT: audience must be an array of strings.")); - } - normalized.push(string.to_string()); - } - Ok(normalized) -} - -fn enforce_allowed_claims(payload: &JwtClaims, allowed_claims: &[String]) -> Result<(), JwtError> { - let mut normalized = HashSet::new(); - for claim in allowed_claims { - let trimmed = claim.trim(); - if trimmed.is_empty() { - return Err(JwtError::new( - "JWT: allowedClaims must be an array of non-empty strings.", - )); - } - normalized.insert(trimmed.to_string()); - } - - for key in payload.keys() { - if STANDARD_CLAIMS.contains(&key.as_str()) { - continue; - } - if !normalized.contains(key) { - return Err(JwtError::new(format!( - "JWT: claim \"{}\" is not allowed.", - key - ))); - } - } - Ok(()) -} - -fn verify_signature( - algorithm: JwtAlgorithm, - secret: &str, - encoded_header: &str, - encoded_payload: &str, - encoded_signature: &str, -) -> Result<(), JwtError> { - let signing_input = format!("{}.{}", encoded_header, encoded_payload); - let provided_signature = base64url_decode(encoded_signature, "signature")?; - let expected_signature = create_signature_buffer(algorithm, secret, &signing_input)?; - - constant_time_compare(&expected_signature, &provided_signature) -} - -fn create_signature( - algorithm: JwtAlgorithm, - secret: &str, - signing_input: &str, -) -> Result { - let bytes = create_signature_buffer(algorithm, secret, signing_input)?; - Ok(base64url_encode(bytes)) -} - -fn constant_time_compare(expected: &[u8], provided: &[u8]) -> Result<(), JwtError> { - if expected.len() != provided.len() { - return Err(JwtError::new("JWT: invalid signature.")); - } - - let mut diff: u8 = 0; - for (a, b) in expected.iter().zip(provided) { - diff |= a ^ b; - } - - if diff == 0 { - Ok(()) - } else { - Err(JwtError::new("JWT: invalid signature.")) - } -} - -fn create_signature_buffer( - algorithm: JwtAlgorithm, - secret: &str, - signing_input: &str, -) -> Result, JwtError> { - match algorithm { - JwtAlgorithm::HS256 => compute_hmac_sha256(secret, signing_input), - JwtAlgorithm::HS512 => compute_hmac_sha512(secret, signing_input), - } -} - -fn compute_hmac_sha256(secret: &str, signing_input: &str) -> Result, JwtError> { - let mut mac = HmacSha256::new_from_slice(secret.as_bytes()) - .map_err(|_| JwtError::new("JWT: failed to create HMAC instance."))?; - mac.update(signing_input.as_bytes()); - Ok(mac.finalize().into_bytes().to_vec()) -} - -fn compute_hmac_sha512(secret: &str, signing_input: &str) -> Result, JwtError> { - let mut mac = HmacSha512::new_from_slice(secret.as_bytes()) - .map_err(|_| JwtError::new("JWT: failed to create HMAC instance."))?; - mac.update(signing_input.as_bytes()); - Ok(mac.finalize().into_bytes().to_vec()) -} - -fn current_timestamp(clock: Option) -> Result { - if let Some(value) = clock { - if !value.is_finite() { - return Err(JwtError::new( - "JWT: clockTimestamp must be a finite number.", - )); - } - return Ok(value.floor() as i64); - } - let duration = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|_| JwtError::new("JWT: system time before UNIX_EPOCH."))?; - Ok(duration.as_secs() as i64) -} - -fn normalize_number(value: f64, claim: &str) -> Result { - if !value.is_finite() { - return Err(JwtError::new(format!( - "JWT: Claim \"{}\" must be a finite number.", - claim - ))); - } - Ok(value.floor() as i64) -} - -fn normalize_string(value: String, context: &str) -> Result { - let trimmed = value.trim().to_string(); - if trimmed.is_empty() { - return Err(JwtError::new(format!( - "JWT: {} must be a non-empty string.", - context - ))); - } - Ok(trimmed) -} - -fn base64url_encode>(data: T) -> String { - URL_SAFE_NO_PAD.encode(data) -} - -fn base64url_decode(input: &str, part: &str) -> Result, JwtError> { - if !input.bytes().all(|byte| BASE64URL_ALLOWED.contains(&byte)) { - return Err(JwtError::new(format!( - "JWT: invalid base64url encoding in {}.", - part - ))); - } - - let decoded = URL_SAFE_NO_PAD - .decode(input) - .map_err(|_| JwtError::new(format!("JWT: malformed base64url segment in {}.", part)))?; - - if base64url_encode(&decoded) != input.trim_end_matches('=') { - return Err(JwtError::new(format!( - "JWT: malformed base64url segment in {}.", - part - ))); - } - - Ok(decoded) -} - -fn validate_temporal_claims( - payload: &JwtClaims, - options: &VerifyJwtOptions, -) -> Result<(), JwtError> { - let now = current_timestamp(options.clock_timestamp)?; - let tolerance = match options.clock_tolerance { - Some(value) if value.is_finite() && value >= 0.0 => value, - Some(_) => { - return Err(JwtError::new( - "JWT: clockTolerance must be a non-negative number.", - )) - } - None => 0.0, - }; - let tolerance = tolerance.floor() as i64; - - if let Some(exp) = payload.get("exp") { - ensure_numeric(exp, "exp")?; - let exp_value = exp.as_i64().unwrap(); - if now > exp_value + tolerance { - return Err(JwtError::new("JWT: token expired.")); - } - } - - if let Some(nbf) = payload.get("nbf") { - ensure_numeric(nbf, "nbf")?; - let nbf_value = nbf.as_i64().unwrap(); - if now + tolerance < nbf_value { - return Err(JwtError::new("JWT: token not active yet.")); - } - } - - if let Some(iat) = payload.get("iat") { - ensure_numeric(iat, "iat")?; - let iat_value = iat.as_i64().unwrap(); - if iat_value - tolerance > now { - return Err(JwtError::new("JWT: token used before issued.")); - } - } - - if let Some(max_age) = options.max_age { - if !max_age.is_finite() || max_age <= 0.0 { - return Err(JwtError::new( - "JWT: maxAge must be a positive number of seconds.", - )); - } - let max_age = max_age.floor() as i64; - let iat = payload - .get("iat") - .and_then(Value::as_i64) - .ok_or_else(|| JwtError::new("JWT: cannot apply maxAge without an \"iat\" claim."))?; - if now - iat - tolerance > max_age { - return Err(JwtError::new("JWT: token exceeds maxAge.")); - } - } - - Ok(()) -} - -fn validate_audience(payload: &JwtClaims, options: &VerifyJwtOptions) -> Result<(), JwtError> { - if payload.get("aud").is_none() && options.audience.is_none() { - return Ok(()); - } - - let token_audience = match payload.get("aud") { - Some(value) => { - if value.is_array() { - normalize_audience_array(value)? - } else { - ensure_string(value, "aud")?; - vec![value.as_str().unwrap().to_string()] - } - } - None => return Err(JwtError::new("JWT: missing required audience claim.")), - }; - - if let Some(audience) = options.audience.clone() { - let expected = audience.into_vec()?; - if !expected - .iter() - .any(|value| token_audience.iter().any(|aud| aud == value)) - { - return Err(JwtError::new("JWT: audience mismatch.")); - } - } - - Ok(()) -} - -fn validate_issuer(payload: &JwtClaims, options: &VerifyJwtOptions) -> Result<(), JwtError> { - if payload.get("iss").is_none() && options.issuer.is_none() { - return Ok(()); - } - - let issuer = match payload.get("iss") { - Some(value) => { - ensure_string(value, "iss")?; - value.as_str().unwrap().to_string() - } - None => return Err(JwtError::new("JWT: missing required issuer claim.")), - }; - - if let Some(issuer_option) = options.issuer.clone() { - let allowed = issuer_option.into_vec()?; - if !allowed.contains(&issuer) { - return Err(JwtError::new("JWT: issuer mismatch.")); - } - } - - Ok(()) -} - -fn validate_subject(payload: &JwtClaims, options: &VerifyJwtOptions) -> Result<(), JwtError> { - if payload.get("sub").is_none() && options.subject.is_none() { - return Ok(()); - } - - let subject = match payload.get("sub") { - Some(value) => { - ensure_string(value, "sub")?; - value.as_str().unwrap().to_string() - } - None => return Err(JwtError::new("JWT: missing required subject claim.")), - }; - - if let Some(expected) = &options.subject { - let normalized = normalize_string(expected.clone(), "Subject")?; - if subject != normalized { - return Err(JwtError::new("JWT: subject mismatch.")); - } - } - - Ok(()) +fn encode_json_object(claims: &JwtClaims, part: &str) -> Result { + let json = serde_json::to_vec(&Value::Object(claims.clone())) + .map_err(|_| JwtError::new(format!("JWT: failed to serialize {}.", part)))?; + Ok(base64url::encode(json)) } diff --git a/src/jwt/base64url.rs b/src/jwt/base64url.rs new file mode 100644 index 0000000..e5030b0 --- /dev/null +++ b/src/jwt/base64url.rs @@ -0,0 +1,41 @@ +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; + +use super::JwtError; + +const BASE64URL_ALLOWED: &[u8] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + +pub(super) fn encode>(data: T) -> String { + URL_SAFE_NO_PAD.encode(data) +} + +pub(super) fn decode(input: &str, part: &str) -> Result, JwtError> { + if !input.bytes().all(|byte| BASE64URL_ALLOWED.contains(&byte)) { + return Err(JwtError::new(format!( + "JWT: invalid base64url encoding in {}.", + part + ))); + } + + let decoded = URL_SAFE_NO_PAD + .decode(input) + .map_err(|_| JwtError::new(format!("JWT: malformed base64url segment in {}.", part)))?; + reject_non_canonical(input, &decoded, part)?; + Ok(decoded) +} + +pub(super) fn decoded_len_upper_bound(input: &str) -> usize { + input.len().saturating_mul(3).saturating_add(3) / 4 +} + +fn reject_non_canonical(input: &str, decoded: &[u8], part: &str) -> Result<(), JwtError> { + if encode(decoded) == input.trim_end_matches('=') { + Ok(()) + } else { + Err(JwtError::new(format!( + "JWT: malformed base64url segment in {}.", + part + ))) + } +} diff --git a/src/jwt/claims.rs b/src/jwt/claims.rs new file mode 100644 index 0000000..2413afc --- /dev/null +++ b/src/jwt/claims.rs @@ -0,0 +1,12 @@ +mod apply; +mod audience; +mod enforce; +mod header; +mod string; +mod validate; + +pub(super) use apply::{apply_audience, apply_expires_in, apply_issued_at, apply_not_before}; +pub(super) use audience::validate_audience_value; +pub(super) use header::build_header; +pub(super) use string::{apply_issuer, apply_subject, normalize_string}; +pub(super) use validate::{numeric_claim, string_claim}; diff --git a/src/jwt/claims/apply.rs b/src/jwt/claims/apply.rs new file mode 100644 index 0000000..955a486 --- /dev/null +++ b/src/jwt/claims/apply.rs @@ -0,0 +1,80 @@ +use serde_json::Value; + +use super::audience::audience_value; +use super::enforce::enforce_claim; +use super::validate::ensure_numeric; +use crate::jwt::time::normalize_number; +use crate::jwt::{Audience, JwtClaims, JwtError}; + +pub(crate) fn apply_issued_at( + claims: &mut JwtClaims, + issued_at: Option, + timestamp: i64, +) -> Result<(), JwtError> { + if let Some(value) = issued_at { + enforce_claim(claims, "iat", Value::from(normalize_number(value, "iat")?)) + } else if let Some(existing) = claims.get("iat") { + ensure_numeric(existing, "iat") + } else { + claims.insert("iat".to_string(), Value::from(timestamp)); + Ok(()) + } +} + +pub(crate) fn apply_expires_in( + claims: &mut JwtClaims, + expires_in: Option, + timestamp: i64, +) -> Result<(), JwtError> { + if let Some(value) = expires_in { + let exp = checked_duration_claim(value, timestamp, "expiresIn")?; + enforce_claim(claims, "exp", Value::from(exp)) + } else if let Some(existing) = claims.get("exp") { + ensure_numeric(existing, "exp") + } else { + Ok(()) + } +} + +pub(crate) fn apply_not_before( + claims: &mut JwtClaims, + not_before: Option, + timestamp: i64, +) -> Result<(), JwtError> { + if let Some(value) = not_before { + if !value.is_finite() { + return Err(JwtError::new("JWT: notBefore must be a number of seconds.")); + } + enforce_claim(claims, "nbf", Value::from(timestamp + value.floor() as i64)) + } else if let Some(existing) = claims.get("nbf") { + ensure_numeric(existing, "nbf") + } else { + Ok(()) + } +} + +pub(crate) fn apply_audience( + claims: &mut JwtClaims, + audience: Option, +) -> Result<(), JwtError> { + if let Some(audience) = audience { + let audiences = audience.into_vec()?; + enforce_claim(claims, "aud", audience_value(audiences)) + } else if let Some(existing) = claims.get("aud") { + super::audience::validate_audience_value(existing).map(|_| ()) + } else { + Ok(()) + } +} + +fn checked_duration_claim(value: f64, timestamp: i64, name: &str) -> Result { + if !value.is_finite() || value <= 0.0 { + return Err(JwtError::new(format!( + "JWT: {} must be a positive number of seconds.", + name + ))); + } + timestamp + .checked_add(value.floor() as i64) + .ok_or_else(|| JwtError::new("JWT: temporal claim overflow.")) +} diff --git a/src/jwt/claims/audience.rs b/src/jwt/claims/audience.rs new file mode 100644 index 0000000..b02d480 --- /dev/null +++ b/src/jwt/claims/audience.rs @@ -0,0 +1,35 @@ +use serde_json::Value; + +use super::validate::string_claim; +use crate::jwt::JwtError; + +pub(super) fn audience_value(audiences: Vec) -> Value { + if audiences.len() == 1 { + Value::String(audiences[0].clone()) + } else { + Value::Array(audiences.into_iter().map(Value::String).collect()) + } +} + +pub(crate) fn validate_audience_value(value: &Value) -> Result, JwtError> { + if value.is_array() { + normalize_audience_array(value) + } else { + Ok(vec![string_claim(value, "aud")?]) + } +} + +fn normalize_audience_array(value: &Value) -> Result, JwtError> { + let items = value + .as_array() + .ok_or_else(|| JwtError::new("JWT: audience must be an array of strings."))?; + if items.is_empty() { + return Err(JwtError::new("JWT: audience array must not be empty.")); + } + + let mut normalized = Vec::with_capacity(items.len()); + for item in items { + normalized.push(string_claim(item, "aud")?); + } + Ok(normalized) +} diff --git a/src/jwt/claims/enforce.rs b/src/jwt/claims/enforce.rs new file mode 100644 index 0000000..d4cb8bc --- /dev/null +++ b/src/jwt/claims/enforce.rs @@ -0,0 +1,18 @@ +use serde_json::Value; + +use crate::jwt::{JwtClaims, JwtError}; + +pub(super) fn enforce_claim( + claims: &mut JwtClaims, + key: &str, + value: Value, +) -> Result<(), JwtError> { + match claims.get(key) { + Some(existing) if *existing != value => Err(JwtError::claim_conflict(key)), + Some(_) => Ok(()), + None => { + claims.insert(key.to_string(), value); + Ok(()) + } + } +} diff --git a/src/jwt/claims/header.rs b/src/jwt/claims/header.rs new file mode 100644 index 0000000..6687acb --- /dev/null +++ b/src/jwt/claims/header.rs @@ -0,0 +1,30 @@ +use serde_json::Value; + +use crate::jwt::{JwtAlgorithm, JwtClaims, JwtError, SignJwtOptions}; + +pub(crate) fn build_header( + options: &SignJwtOptions, + algorithm: JwtAlgorithm, +) -> Result { + let mut header = options.header.clone().unwrap_or_default(); + validate_header_override(&header, algorithm)?; + header.insert("alg".to_string(), Value::String(algorithm.to_string())); + header.insert("typ".to_string(), Value::String("JWT".to_string())); + Ok(header) +} + +fn validate_header_override(header: &JwtClaims, algorithm: JwtAlgorithm) -> Result<(), JwtError> { + if header + .get("alg") + .is_some_and(|alg| alg.as_str() != Some(&algorithm.to_string())) + { + return Err(JwtError::new("JWT: header algorithm mismatch.")); + } + if header + .get("typ") + .is_some_and(|typ| typ.as_str() != Some("JWT")) + { + return Err(JwtError::new("JWT: header type must be \"JWT\".")); + } + Ok(()) +} diff --git a/src/jwt/claims/string.rs b/src/jwt/claims/string.rs new file mode 100644 index 0000000..bbaf824 --- /dev/null +++ b/src/jwt/claims/string.rs @@ -0,0 +1,46 @@ +use serde_json::Value; + +use super::enforce::enforce_claim; +use super::validate::ensure_string; +use crate::jwt::{JwtClaims, JwtError}; + +pub(crate) fn normalize_string(value: String, context: &str) -> Result { + let trimmed = value.trim().to_string(); + if trimmed.is_empty() { + return Err(JwtError::new(format!( + "JWT: {} must be a non-empty string.", + context + ))); + } + Ok(trimmed) +} + +pub(crate) fn apply_issuer(claims: &mut JwtClaims, issuer: Option) -> Result<(), JwtError> { + apply_string_claim(claims, "iss", issuer, "Issuer") +} + +pub(crate) fn apply_subject( + claims: &mut JwtClaims, + subject: Option, +) -> Result<(), JwtError> { + apply_string_claim(claims, "sub", subject, "Subject") +} + +fn apply_string_claim( + claims: &mut JwtClaims, + key: &str, + value: Option, + context: &str, +) -> Result<(), JwtError> { + if let Some(value) = value { + enforce_claim( + claims, + key, + Value::String(normalize_string(value, context)?), + ) + } else if let Some(existing) = claims.get(key) { + ensure_string(existing, key) + } else { + Ok(()) + } +} diff --git a/src/jwt/claims/validate.rs b/src/jwt/claims/validate.rs new file mode 100644 index 0000000..b921efa --- /dev/null +++ b/src/jwt/claims/validate.rs @@ -0,0 +1,40 @@ +use serde_json::Value; + +use crate::jwt::JwtError; + +pub(crate) fn numeric_claim(value: &Value, claim: &str) -> Result { + ensure_numeric(value, claim)?; + value + .as_i64() + .ok_or_else(|| JwtError::new(format!("JWT: Claim \"{}\" must be a finite number.", claim))) +} + +pub(crate) fn string_claim(value: &Value, claim: &str) -> Result { + ensure_string(value, claim)?; + value.as_str().map(ToString::to_string).ok_or_else(|| { + JwtError::new(format!( + "JWT: Claim \"{}\" must be a non-empty string.", + claim + )) + }) +} + +pub(crate) fn ensure_numeric(value: &Value, claim: &str) -> Result<(), JwtError> { + match value { + Value::Number(number) if number.as_i64().is_some() => Ok(()), + _ => Err(JwtError::new(format!( + "JWT: Claim \"{}\" must be a finite number.", + claim + ))), + } +} + +pub(crate) fn ensure_string(value: &Value, claim: &str) -> Result<(), JwtError> { + match value { + Value::String(text) if !text.is_empty() => Ok(()), + _ => Err(JwtError::new(format!( + "JWT: Claim \"{}\" must be a non-empty string.", + claim + ))), + } +} diff --git a/src/jwt/error.rs b/src/jwt/error.rs new file mode 100644 index 0000000..6f08d1e --- /dev/null +++ b/src/jwt/error.rs @@ -0,0 +1,22 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +#[error("{message}")] +pub struct JwtError { + message: String, +} + +impl JwtError { + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } + + pub(super) fn claim_conflict(claim: &str) -> Self { + Self::new(format!( + "JWT: claim \"{}\" already present with a different value.", + claim + )) + } +} diff --git a/src/jwt/signing.rs b/src/jwt/signing.rs new file mode 100644 index 0000000..0490b4a --- /dev/null +++ b/src/jwt/signing.rs @@ -0,0 +1,54 @@ +mod hmac; + +use super::base64url; +use super::{JwtAlgorithm, JwtError}; + +pub(super) fn create_signature( + algorithm: JwtAlgorithm, + secret: &str, + signing_input: &str, +) -> Result { + let bytes = create_signature_buffer(algorithm, secret, signing_input)?; + Ok(base64url::encode(bytes)) +} + +pub(super) fn verify_signature( + algorithm: JwtAlgorithm, + secret: &str, + encoded_header: &str, + encoded_payload: &str, + encoded_signature: &str, +) -> Result<(), JwtError> { + let signing_input = format!("{}.{}", encoded_header, encoded_payload); + let provided_signature = base64url::decode(encoded_signature, "signature")?; + let expected_signature = create_signature_buffer(algorithm, secret, &signing_input)?; + constant_time_compare(&expected_signature, &provided_signature) +} + +fn create_signature_buffer( + algorithm: JwtAlgorithm, + secret: &str, + signing_input: &str, +) -> Result, JwtError> { + match algorithm { + JwtAlgorithm::HS256 => hmac::compute_hmac_sha256(secret, signing_input), + JwtAlgorithm::HS512 => hmac::compute_hmac_sha512(secret, signing_input), + } +} + +fn constant_time_compare(expected: &[u8], provided: &[u8]) -> Result<(), JwtError> { + if expected.len() != provided.len() { + return Err(JwtError::new("JWT: invalid signature.")); + } + + let mut diff: u8 = 0; + for (a, b) in expected.iter().zip(provided) { + diff |= a ^ b; + } + + if diff == 0 { + Ok(()) + } else { + Err(JwtError::new("JWT: invalid signature.")) + } +} diff --git a/src/jwt/signing/hmac.rs b/src/jwt/signing/hmac.rs new file mode 100644 index 0000000..099a5d1 --- /dev/null +++ b/src/jwt/signing/hmac.rs @@ -0,0 +1,21 @@ +use hmac::{Hmac, Mac}; +use sha2::{Sha256, Sha512}; + +use crate::jwt::JwtError; + +type HmacSha256 = Hmac; +type HmacSha512 = Hmac; + +pub(super) fn compute_hmac_sha256(secret: &str, signing_input: &str) -> Result, JwtError> { + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()) + .map_err(|_| JwtError::new("JWT: failed to create HMAC instance."))?; + mac.update(signing_input.as_bytes()); + Ok(mac.finalize().into_bytes().to_vec()) +} + +pub(super) fn compute_hmac_sha512(secret: &str, signing_input: &str) -> Result, JwtError> { + let mut mac = HmacSha512::new_from_slice(secret.as_bytes()) + .map_err(|_| JwtError::new("JWT: failed to create HMAC instance."))?; + mac.update(signing_input.as_bytes()); + Ok(mac.finalize().into_bytes().to_vec()) +} diff --git a/src/jwt/time.rs b/src/jwt/time.rs new file mode 100644 index 0000000..3ee7efb --- /dev/null +++ b/src/jwt/time.rs @@ -0,0 +1,29 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use super::JwtError; + +pub(super) fn current_timestamp(clock: Option) -> Result { + if let Some(value) = clock { + if !value.is_finite() { + return Err(JwtError::new( + "JWT: clockTimestamp must be a finite number.", + )); + } + return Ok(value.floor() as i64); + } + + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| JwtError::new("JWT: system time before UNIX_EPOCH."))?; + Ok(duration.as_secs() as i64) +} + +pub(super) fn normalize_number(value: f64, claim: &str) -> Result { + if !value.is_finite() { + return Err(JwtError::new(format!( + "JWT: Claim \"{}\" must be a finite number.", + claim + ))); + } + Ok(value.floor() as i64) +} diff --git a/src/jwt/types.rs b/src/jwt/types.rs new file mode 100644 index 0000000..4a14ab3 --- /dev/null +++ b/src/jwt/types.rs @@ -0,0 +1,115 @@ +use std::fmt::{self, Display}; +use std::str::FromStr; + +use serde_json::{Map, Value}; + +use super::claims::normalize_string; +use super::JwtError; + +pub type JwtClaims = Map; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum JwtAlgorithm { + HS256, + HS512, +} + +impl Display for JwtAlgorithm { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + JwtAlgorithm::HS256 => write!(f, "HS256"), + JwtAlgorithm::HS512 => write!(f, "HS512"), + } + } +} + +impl FromStr for JwtAlgorithm { + type Err = JwtError; + + fn from_str(s: &str) -> Result { + match s { + "HS256" => Ok(JwtAlgorithm::HS256), + "HS512" => Ok(JwtAlgorithm::HS512), + other => Err(JwtError::new(format!( + "JWT: unsupported algorithm: {}.", + other + ))), + } + } +} + +#[derive(Clone, Debug, Default)] +pub struct SignJwtOptions { + pub secret: String, + pub algorithm: Option, + pub header: Option>, + pub expires_in: Option, + pub not_before: Option, + pub audience: Option, + pub issuer: Option, + pub subject: Option, + pub issued_at: Option, + pub clock_timestamp: Option, +} + +#[derive(Clone, Debug, Default)] +pub struct VerifyJwtOptions { + pub secret: String, + pub algorithms: Option>, + pub clock_tolerance: Option, + pub audience: Option, + pub issuer: Option, + pub subject: Option, + pub max_age: Option, + pub clock_timestamp: Option, + pub max_payload_size: Option, + pub allowed_claims: Option>, +} + +#[derive(Clone, Debug)] +pub enum Audience { + Single(String), + Multiple(Vec), +} + +impl Audience { + pub(super) fn into_vec(self) -> Result, JwtError> { + match self { + Audience::Single(value) => Ok(vec![normalize_string(value, "Audience")?]), + Audience::Multiple(values) => normalize_non_empty_strings(values, "Audience"), + } + } +} + +#[derive(Clone, Debug)] +pub enum Issuer { + Single(String), + Multiple(Vec), +} + +impl Issuer { + pub(super) fn into_vec(self) -> Result, JwtError> { + match self { + Issuer::Single(value) => Ok(vec![normalize_string(value, "Issuer")?]), + Issuer::Multiple(values) => normalize_non_empty_strings(values, "Issuer"), + } + } +} + +fn normalize_non_empty_strings( + values: Vec, + context: &str, +) -> Result, JwtError> { + if values.is_empty() { + return Err(JwtError::new(format!( + "JWT: {} array must not be empty.", + context.to_lowercase() + ))); + } + + let mut normalized = Vec::with_capacity(values.len()); + for value in values { + normalized.push(normalize_string(value, context)?); + } + Ok(normalized) +} diff --git a/src/jwt/verify.rs b/src/jwt/verify.rs new file mode 100644 index 0000000..f7faebc --- /dev/null +++ b/src/jwt/verify.rs @@ -0,0 +1,64 @@ +mod claims; +mod header; +mod payload; +mod temporal; + +use super::signing::verify_signature; +use super::{JwtClaims, JwtError, VerifyJwtOptions}; + +pub(super) fn verify_token(token: &str, options: &VerifyJwtOptions) -> Result { + validate_verify_inputs(token, options)?; + let segments = split_token(token)?; + let algorithm = header::decode_algorithm(segments.header, options)?; + let payload = payload::decode_payload(segments.payload, options)?; + + claims::enforce_allowed_claims(&payload, options)?; + verify_signature( + algorithm, + &options.secret, + segments.header, + segments.payload, + segments.signature, + )?; + claims::validate_registered_claims(&payload, options)?; + Ok(payload) +} + +pub(super) fn decode_json_object(bytes: &[u8], part: &str) -> Result { + let value: serde_json::Value = serde_json::from_slice(bytes) + .map_err(|_| JwtError::new(format!("JWT: invalid {} JSON.", part)))?; + value + .as_object() + .cloned() + .ok_or_else(|| JwtError::new(format!("JWT: {} must be a JSON object.", part))) +} + +struct TokenSegments<'a> { + header: &'a str, + payload: &'a str, + signature: &'a str, +} + +fn validate_verify_inputs(token: &str, options: &VerifyJwtOptions) -> Result<(), JwtError> { + if token.trim().is_empty() { + return Err(JwtError::new("JWT: token must be a non-empty string.")); + } + if options.secret.trim().is_empty() { + return Err(JwtError::new( + "JWT: a non-empty secret is required to verify.", + )); + } + Ok(()) +} + +fn split_token(token: &str) -> Result, JwtError> { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 || parts.iter().any(|part| part.is_empty()) { + return Err(JwtError::new("JWT: invalid token structure.")); + } + Ok(TokenSegments { + header: parts[0], + payload: parts[1], + signature: parts[2], + }) +} diff --git a/src/jwt/verify/claims.rs b/src/jwt/verify/claims.rs new file mode 100644 index 0000000..6542b47 --- /dev/null +++ b/src/jwt/verify/claims.rs @@ -0,0 +1,93 @@ +mod allowed; + +use crate::jwt::claims::{string_claim, validate_audience_value}; +use crate::jwt::verify::temporal::validate_temporal_claims; +use crate::jwt::{JwtClaims, JwtError, VerifyJwtOptions}; + +const STANDARD_CLAIMS: [&str; 6] = ["iss", "sub", "aud", "exp", "nbf", "iat"]; + +pub(super) fn enforce_allowed_claims( + payload: &JwtClaims, + options: &VerifyJwtOptions, +) -> Result<(), JwtError> { + if let Some(allowed_claims) = &options.allowed_claims { + let allowed = allowed::normalize_allowed_claims(allowed_claims)?; + for key in payload.keys() { + if !STANDARD_CLAIMS.contains(&key.as_str()) && !allowed.contains(key) { + return Err(JwtError::new(format!( + "JWT: claim \"{}\" is not allowed.", + key + ))); + } + } + } + Ok(()) +} + +pub(super) fn validate_registered_claims( + payload: &JwtClaims, + options: &VerifyJwtOptions, +) -> Result<(), JwtError> { + validate_temporal_claims(payload, options)?; + validate_audience(payload, options)?; + validate_issuer(payload, options)?; + validate_subject(payload, options) +} + +fn validate_audience(payload: &JwtClaims, options: &VerifyJwtOptions) -> Result<(), JwtError> { + if payload.get("aud").is_none() && options.audience.is_none() { + return Ok(()); + } + + let token_audience = payload + .get("aud") + .ok_or_else(|| JwtError::new("JWT: missing required audience claim.")) + .and_then(validate_audience_value)?; + if let Some(audience) = options.audience.clone() { + let expected = audience.into_vec()?; + if !expected + .iter() + .any(|value| token_audience.iter().any(|aud| aud == value)) + { + return Err(JwtError::new("JWT: audience mismatch.")); + } + } + Ok(()) +} + +fn validate_issuer(payload: &JwtClaims, options: &VerifyJwtOptions) -> Result<(), JwtError> { + if payload.get("iss").is_none() && options.issuer.is_none() { + return Ok(()); + } + + let issuer = payload + .get("iss") + .ok_or_else(|| JwtError::new("JWT: missing required issuer claim.")) + .and_then(|value| string_claim(value, "iss"))?; + if let Some(issuer_option) = options.issuer.clone() { + let allowed = issuer_option.into_vec()?; + if !allowed.contains(&issuer) { + return Err(JwtError::new("JWT: issuer mismatch.")); + } + } + Ok(()) +} + +fn validate_subject(payload: &JwtClaims, options: &VerifyJwtOptions) -> Result<(), JwtError> { + if payload.get("sub").is_none() && options.subject.is_none() { + return Ok(()); + } + + let subject = payload + .get("sub") + .ok_or_else(|| JwtError::new("JWT: missing required subject claim.")) + .and_then(|value| string_claim(value, "sub"))?; + if options + .subject + .as_ref() + .is_some_and(|expected| subject != expected.trim()) + { + return Err(JwtError::new("JWT: subject mismatch.")); + } + Ok(()) +} diff --git a/src/jwt/verify/claims/allowed.rs b/src/jwt/verify/claims/allowed.rs new file mode 100644 index 0000000..fbd3b98 --- /dev/null +++ b/src/jwt/verify/claims/allowed.rs @@ -0,0 +1,19 @@ +use std::collections::HashSet; + +use crate::jwt::JwtError; + +pub(super) fn normalize_allowed_claims( + allowed_claims: &[String], +) -> Result, JwtError> { + let mut normalized = HashSet::new(); + for claim in allowed_claims { + let trimmed = claim.trim(); + if trimmed.is_empty() { + return Err(JwtError::new( + "JWT: allowedClaims must be an array of non-empty strings.", + )); + } + normalized.insert(trimmed.to_string()); + } + Ok(normalized) +} diff --git a/src/jwt/verify/header.rs b/src/jwt/verify/header.rs new file mode 100644 index 0000000..bc835ed --- /dev/null +++ b/src/jwt/verify/header.rs @@ -0,0 +1,61 @@ +use std::str::FromStr; + +use serde_json::Value; + +use super::decode_json_object; +use crate::jwt::base64url; +use crate::jwt::{JwtAlgorithm, JwtClaims, JwtError, VerifyJwtOptions}; + +pub(super) fn decode_algorithm( + encoded_header: &str, + options: &VerifyJwtOptions, +) -> Result { + let header_bytes = base64url::decode(encoded_header, "header")?; + let header = decode_json_object(&header_bytes, "header")?; + validate_header_type(&header)?; + let algorithm = parse_algorithm(&header)?; + enforce_allowed_algorithm(algorithm, options)?; + Ok(algorithm) +} + +fn parse_algorithm(header: &JwtClaims) -> Result { + let alg_value = header + .get("alg") + .and_then(Value::as_str) + .ok_or_else(|| JwtError::new("JWT: missing algorithm."))?; + if alg_value.eq_ignore_ascii_case("none") { + return Err(JwtError::new( + "JWT: unsigned tokens (alg \"none\") are not allowed.", + )); + } + JwtAlgorithm::from_str(alg_value) +} + +fn validate_header_type(header: &JwtClaims) -> Result<(), JwtError> { + if let Some(typ_value) = header.get("typ") { + let typ = typ_value + .as_str() + .ok_or_else(|| JwtError::new("JWT: header type must be a string."))?; + if typ != "JWT" { + return Err(JwtError::new("JWT: header type must be \"JWT\".")); + } + } + Ok(()) +} + +fn enforce_allowed_algorithm( + algorithm: JwtAlgorithm, + options: &VerifyJwtOptions, +) -> Result<(), JwtError> { + if options + .algorithms + .as_ref() + .is_some_and(|allowed| !allowed.contains(&algorithm)) + { + return Err(JwtError::new(format!( + "JWT: algorithm {} is not allowed.", + algorithm + ))); + } + Ok(()) +} diff --git a/src/jwt/verify/payload.rs b/src/jwt/verify/payload.rs new file mode 100644 index 0000000..e32f34b --- /dev/null +++ b/src/jwt/verify/payload.rs @@ -0,0 +1,39 @@ +use super::decode_json_object; +use crate::jwt::base64url; +use crate::jwt::{JwtClaims, JwtError, VerifyJwtOptions}; + +pub(super) fn decode_payload( + encoded_payload: &str, + options: &VerifyJwtOptions, +) -> Result { + reject_large_payload_before_decode(encoded_payload, options)?; + let payload_bytes = base64url::decode(encoded_payload, "payload")?; + reject_large_payload_after_decode(payload_bytes.len(), options)?; + decode_json_object(&payload_bytes, "payload") +} + +fn reject_large_payload_before_decode( + encoded_payload: &str, + options: &VerifyJwtOptions, +) -> Result<(), JwtError> { + if options + .max_payload_size + .is_some_and(|max_size| base64url::decoded_len_upper_bound(encoded_payload) > max_size) + { + return Err(JwtError::new("JWT: payload exceeds maxPayloadSize.")); + } + Ok(()) +} + +fn reject_large_payload_after_decode( + payload_size: usize, + options: &VerifyJwtOptions, +) -> Result<(), JwtError> { + if options + .max_payload_size + .is_some_and(|max_size| payload_size > max_size) + { + return Err(JwtError::new("JWT: payload exceeds maxPayloadSize.")); + } + Ok(()) +} diff --git a/src/jwt/verify/temporal.rs b/src/jwt/verify/temporal.rs new file mode 100644 index 0000000..ddd0e4b --- /dev/null +++ b/src/jwt/verify/temporal.rs @@ -0,0 +1,63 @@ +mod max_age; + +use crate::jwt::claims::numeric_claim; +use crate::jwt::time::current_timestamp; +use crate::jwt::{JwtClaims, JwtError, VerifyJwtOptions}; + +pub(super) fn validate_temporal_claims( + payload: &JwtClaims, + options: &VerifyJwtOptions, +) -> Result<(), JwtError> { + let now = current_timestamp(options.clock_timestamp)?; + let tolerance = normalize_tolerance(options.clock_tolerance)?; + validate_exp(payload, now, tolerance)?; + validate_nbf(payload, now, tolerance)?; + validate_iat(payload, now, tolerance)?; + max_age::validate_max_age(payload, options, now, tolerance) +} + +fn normalize_tolerance(clock_tolerance: Option) -> Result { + match clock_tolerance { + Some(value) if value.is_finite() && value >= 0.0 => Ok(value.floor() as i64), + Some(_) => Err(JwtError::new( + "JWT: clockTolerance must be a non-negative number.", + )), + None => Ok(0), + } +} + +fn validate_exp(payload: &JwtClaims, now: i64, tolerance: i64) -> Result<(), JwtError> { + if let Some(exp) = payload.get("exp") { + let expires_with_tolerance = numeric_claim(exp, "exp")? + .checked_add(tolerance) + .ok_or_else(|| JwtError::new("JWT: temporal claim overflow."))?; + if now > expires_with_tolerance { + return Err(JwtError::new("JWT: token expired.")); + } + } + Ok(()) +} + +fn validate_nbf(payload: &JwtClaims, now: i64, tolerance: i64) -> Result<(), JwtError> { + if let Some(nbf) = payload.get("nbf") { + let now_with_tolerance = now + .checked_add(tolerance) + .ok_or_else(|| JwtError::new("JWT: temporal claim overflow."))?; + if now_with_tolerance < numeric_claim(nbf, "nbf")? { + return Err(JwtError::new("JWT: token not active yet.")); + } + } + Ok(()) +} + +fn validate_iat(payload: &JwtClaims, now: i64, tolerance: i64) -> Result<(), JwtError> { + if let Some(iat) = payload.get("iat") { + let issued_without_tolerance = numeric_claim(iat, "iat")? + .checked_sub(tolerance) + .ok_or_else(|| JwtError::new("JWT: temporal claim overflow."))?; + if issued_without_tolerance > now { + return Err(JwtError::new("JWT: token used before issued.")); + } + } + Ok(()) +} diff --git a/src/jwt/verify/temporal/max_age.rs b/src/jwt/verify/temporal/max_age.rs new file mode 100644 index 0000000..60cd346 --- /dev/null +++ b/src/jwt/verify/temporal/max_age.rs @@ -0,0 +1,35 @@ +use serde_json::Value; + +use crate::jwt::{JwtClaims, JwtError, VerifyJwtOptions}; + +pub(super) fn validate_max_age( + payload: &JwtClaims, + options: &VerifyJwtOptions, + now: i64, + tolerance: i64, +) -> Result<(), JwtError> { + if let Some(max_age) = options.max_age { + let max_age = normalize_max_age(max_age)?; + let iat = payload + .get("iat") + .and_then(Value::as_i64) + .ok_or_else(|| JwtError::new("JWT: cannot apply maxAge without an \"iat\" claim."))?; + let age = now + .checked_sub(iat) + .and_then(|value| value.checked_sub(tolerance)) + .ok_or_else(|| JwtError::new("JWT: temporal claim overflow."))?; + if age > max_age { + return Err(JwtError::new("JWT: token exceeds maxAge.")); + } + } + Ok(()) +} + +fn normalize_max_age(max_age: f64) -> Result { + if !max_age.is_finite() || max_age <= 0.0 { + return Err(JwtError::new( + "JWT: maxAge must be a positive number of seconds.", + )); + } + Ok(max_age.floor() as i64) +} diff --git a/src/lib.rs b/src/lib.rs index 96ac1f1..7b95c9f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,8 +3,8 @@ pub mod jwt; pub use advanced_token_manager::{ AdvancedTokenError, AdvancedTokenManager, AdvancedTokenManagerLogger, - AdvancedTokenManagerOptions, Algorithm, ManagerConfig, ManagerSignJwtOptions, - ManagerVerifyJwtOptions, TokenValidationError, ValidateTokenOptions, + AdvancedTokenManagerOptions, Algorithm, GenerateTokenOptions, ManagerConfig, + ManagerSignJwtOptions, ManagerVerifyJwtOptions, TokenValidationError, ValidateTokenOptions, }; pub use jwt::{ diff --git a/tests/advanced_token_manager_test.rs b/tests/advanced_token_manager_test.rs index 290aeef..2f82eb4 100644 --- a/tests/advanced_token_manager_test.rs +++ b/tests/advanced_token_manager_test.rs @@ -1,7 +1,7 @@ -use base64::Engine; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use hash_token_rust::advanced_token_manager::{ - AdvancedTokenManager, AdvancedTokenManagerOptions, Algorithm, ManagerSignJwtOptions, - ManagerVerifyJwtOptions, ValidateTokenOptions, + AdvancedTokenManager, AdvancedTokenManagerOptions, Algorithm, GenerateTokenOptions, + ManagerSignJwtOptions, ManagerVerifyJwtOptions, ValidateTokenOptions, }; use hash_token_rust::jwt::{Audience, JwtAlgorithm, JwtClaims}; @@ -68,12 +68,80 @@ fn validate_token_throws_when_configured() { fn generate_token_with_explicit_salt_index() { let mut manager = manager(); let token = manager.generate_token("payload", Some(1)).unwrap(); - let decoded = base64::engine::general_purpose::STANDARD - .decode(token) + let parts: Vec<&str> = token.split('.').collect(); + assert_eq!(parts[0], "htr1"); + let meta = URL_SAFE_NO_PAD.decode(parts[2]).unwrap(); + let meta: serde_json::Value = serde_json::from_slice(&meta).unwrap(); + assert_eq!(meta["salt"], 1); +} + +#[test] +fn native_token_enforces_expiration() { + let mut manager = manager(); + let token = manager + .generate_token_with_options( + "payload", + Some(GenerateTokenOptions { + expires_in: Some(10.0), + issued_at: Some(1000.0), + ..Default::default() + }), + ) + .unwrap(); + + let err = manager + .validate_token_with_options( + &token, + Some(ValidateTokenOptions { + throw_on_failure: Some(true), + clock_timestamp: Some(1011.0), + ..Default::default() + }), + ) + .unwrap_err(); + assert!(err.to_string().contains("Token expired")); +} + +#[test] +fn native_token_enforces_issuer_and_audience() { + let mut manager = manager(); + let token = manager + .generate_token_with_options( + "payload", + Some(GenerateTokenOptions { + issuer: Some("bin-1".into()), + audience: Some("bin-2".into()), + issued_at: Some(1000.0), + ..Default::default() + }), + ) + .unwrap(); + + let valid = manager + .validate_token_with_options( + &token, + Some(ValidateTokenOptions { + issuer: Some("bin-1".into()), + audience: Some("bin-2".into()), + clock_timestamp: Some(1001.0), + ..Default::default() + }), + ) .unwrap(); - let text = String::from_utf8(decoded).unwrap(); - let parts: Vec<&str> = text.split('|').collect(); - assert_eq!(parts[1], "1"); + assert_eq!(valid, Some("payload".to_string())); + + let err = manager + .validate_token_with_options( + &token, + Some(ValidateTokenOptions { + throw_on_failure: Some(true), + audience: Some("bin-3".into()), + clock_timestamp: Some(1001.0), + ..Default::default() + }), + ) + .unwrap_err(); + assert!(err.to_string().contains("audience mismatch")); } #[test] @@ -97,8 +165,10 @@ fn manager_generates_and_validates_jwt() { #[test] fn manager_applies_default_jwt_algorithms() { - let mut options = AdvancedTokenManagerOptions::default(); - options.jwt_default_algorithms = Some(vec![JwtAlgorithm::HS256]); + let options = AdvancedTokenManagerOptions { + jwt_default_algorithms: Some(vec![JwtAlgorithm::HS256]), + ..Default::default() + }; let manager = AdvancedTokenManager::new( Some("averysecuresecretvalue".to_string()), Some(vec!["salt-a".into(), "salt-b".into()]), @@ -113,8 +183,10 @@ fn manager_applies_default_jwt_algorithms() { claims.insert("sub".to_string(), "user-123".into()); let token = manager.generate_jwt(&claims, None).unwrap(); - let mut verify_options = ManagerVerifyJwtOptions::default(); - verify_options.algorithms = Some(vec![JwtAlgorithm::HS256]); + let verify_options = ManagerVerifyJwtOptions { + algorithms: Some(vec![JwtAlgorithm::HS256]), + ..Default::default() + }; manager .validate_jwt::(&token, Some(verify_options)) .unwrap(); @@ -130,6 +202,7 @@ fn validate_token_with_options_no_throw() { &tampered, Some(ValidateTokenOptions { throw_on_failure: Some(false), + ..Default::default() }), ) .unwrap(); @@ -145,8 +218,73 @@ fn configure_audience_for_jwt_verification() { let token = manager.generate_jwt(&claims, None).unwrap(); - let mut verify_options = ManagerVerifyJwtOptions::default(); - verify_options.audience = Some(Audience::Single("service-a".into())); + let verify_options = ManagerVerifyJwtOptions { + audience: Some(Audience::Single("service-a".into())), + ..Default::default() + }; let validated: JwtClaims = manager.validate_jwt(&token, Some(verify_options)).unwrap(); assert_eq!(validated.get("sub").unwrap(), "user-123"); } + +#[test] +fn manager_validate_jwt_rejects_wrong_secret() { + let manager = manager(); + let mut claims: JwtClaims = JwtClaims::new(); + claims.insert("sub".to_string(), "user-123".into()); + let token = manager.generate_jwt(&claims, None).unwrap(); + + let verify_options = ManagerVerifyJwtOptions { + secret: Some("different-secret-value".into()), + ..Default::default() + }; + let err = manager + .validate_jwt::(&token, Some(verify_options)) + .unwrap_err(); + assert!(err.to_string().contains("invalid signature")); +} + +#[test] +fn manager_validate_jwt_enforces_max_payload_size() { + let options = AdvancedTokenManagerOptions { + jwt_max_payload_size: Some(32), + ..Default::default() + }; + let manager = AdvancedTokenManager::new( + Some("averysecuresecretvalue".to_string()), + Some(vec!["salt-a".into(), "salt-b".into()]), + Some(Algorithm::Sha256), + true, + true, + Some(options), + ) + .unwrap(); + + let mut claims: JwtClaims = JwtClaims::new(); + claims.insert("sub".to_string(), "user-123".into()); + claims.insert("large".to_string(), "x".repeat(128).into()); + let token = manager.generate_jwt(&claims, None).unwrap(); + + let err = manager.validate_jwt::(&token, None).unwrap_err(); + assert!(err.to_string().contains("maxPayloadSize")); +} + +#[test] +fn manager_validate_jwt_rejects_disallowed_algorithm() { + let manager = manager(); + let mut claims: JwtClaims = JwtClaims::new(); + claims.insert("sub".to_string(), "user-123".into()); + let sign_options = ManagerSignJwtOptions { + algorithm: Some(JwtAlgorithm::HS512), + ..Default::default() + }; + let token = manager.generate_jwt(&claims, Some(sign_options)).unwrap(); + + let verify_options = ManagerVerifyJwtOptions { + algorithms: Some(vec![JwtAlgorithm::HS256]), + ..Default::default() + }; + let err = manager + .validate_jwt::(&token, Some(verify_options)) + .unwrap_err(); + assert!(err.to_string().contains("is not allowed")); +} diff --git a/tests/jwt_test.rs b/tests/jwt_test.rs index 80384b4..5cff914 100644 --- a/tests/jwt_test.rs +++ b/tests/jwt_test.rs @@ -1,7 +1,26 @@ +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; use hash_token_rust::jwt::{ sign_jwt, verify_jwt, verify_jwt_as, Audience, Issuer, JwtAlgorithm, JwtClaims, SignJwtOptions, VerifyJwtOptions, }; +use hmac::{Hmac, Mac}; +use serde_json::{json, Value}; +use sha2::Sha256; + +fn encode_json(value: Value) -> String { + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&value).unwrap()) +} + +fn signed_hs256_token(payload: Value) -> String { + let header = encode_json(json!({"alg": "HS256", "typ": "JWT"})); + let payload = encode_json(payload); + let signing_input = format!("{}.{}", header, payload); + let mut mac = Hmac::::new_from_slice("secret-value".as_bytes()).unwrap(); + mac.update(signing_input.as_bytes()); + let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); + format!("{}.{}", signing_input, signature) +} #[test] fn sign_and_verify_jwt() { @@ -32,6 +51,34 @@ fn sign_and_verify_jwt() { assert_eq!(verified.get("sub").unwrap(), "user-123"); } +#[test] +fn sign_and_verify_hs256() { + let mut payload = JwtClaims::new(); + payload.insert("sub".to_string(), "user-123".into()); + + let token = sign_jwt( + &payload, + &SignJwtOptions { + secret: "secret-value".to_string(), + algorithm: Some(JwtAlgorithm::HS256), + ..Default::default() + }, + ) + .unwrap(); + + let verified = verify_jwt( + &token, + &VerifyJwtOptions { + secret: "secret-value".to_string(), + algorithms: Some(vec![JwtAlgorithm::HS256]), + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!(verified.get("sub").unwrap(), "user-123"); +} + #[test] fn verify_rejects_invalid_signature() { let mut payload = JwtClaims::new(); @@ -71,7 +118,12 @@ fn verify_rejects_invalid_signature() { }, ) .unwrap_err(); - assert!(err.to_string().contains("invalid signature")); + let message = err.to_string(); + assert!( + message.contains("invalid signature") || message.contains("malformed base64url"), + "unexpected error message: {}", + message + ); } #[test] @@ -127,6 +179,194 @@ fn verify_rejects_alg_none() { assert!(err.to_string().contains("alg \"none\"")); } +#[test] +fn verify_rejects_unexpected_algorithm() { + let token = format!( + "{}.{}.{}", + encode_json(json!({"alg": "RS256", "typ": "JWT"})), + encode_json(json!({"sub": "123"})), + "c2ln" + ); + + let err = verify_jwt( + &token, + &VerifyJwtOptions { + secret: "secret-value".to_string(), + ..Default::default() + }, + ) + .unwrap_err(); + assert!(err.to_string().contains("unsupported algorithm")); + + let lower_alg_token = format!( + "{}.{}.{}", + encode_json(json!({"alg": "hs256", "typ": "JWT"})), + encode_json(json!({"sub": "123"})), + "c2ln" + ); + let err = verify_jwt( + &lower_alg_token, + &VerifyJwtOptions { + secret: "secret-value".to_string(), + ..Default::default() + }, + ) + .unwrap_err(); + assert!(err.to_string().contains("unsupported algorithm")); +} + +#[test] +fn verify_rejects_truncated_signature() { + let mut payload = JwtClaims::new(); + payload.insert("sub".to_string(), "user-123".into()); + let token = sign_jwt( + &payload, + &SignJwtOptions { + secret: "secret-value".to_string(), + ..Default::default() + }, + ) + .unwrap(); + let mut parts: Vec<&str> = token.split('.').collect(); + parts[2] = &parts[2][..parts[2].len() - 4]; + let truncated = parts.join("."); + + let err = verify_jwt( + &truncated, + &VerifyJwtOptions { + secret: "secret-value".to_string(), + ..Default::default() + }, + ) + .unwrap_err(); + let message = err.to_string(); + assert!( + message.contains("invalid signature") || message.contains("malformed base64url"), + "unexpected error message: {}", + message + ); +} + +#[test] +fn verify_enforces_temporal_claims() { + let mut expired_payload = JwtClaims::new(); + expired_payload.insert("sub".to_string(), "user-123".into()); + expired_payload.insert("exp".to_string(), 900.into()); + + let token = sign_jwt( + &expired_payload, + &SignJwtOptions { + secret: "secret-value".to_string(), + clock_timestamp: Some(1000.0), + ..Default::default() + }, + ) + .unwrap(); + + let expired = verify_jwt( + &token, + &VerifyJwtOptions { + secret: "secret-value".to_string(), + clock_timestamp: Some(1001.0), + ..Default::default() + }, + ) + .unwrap_err(); + assert!(expired.to_string().contains("token expired")); + + let mut nbf_payload = JwtClaims::new(); + nbf_payload.insert("sub".to_string(), "user-123".into()); + nbf_payload.insert("nbf".to_string(), 1100.into()); + let token = sign_jwt( + &nbf_payload, + &SignJwtOptions { + secret: "secret-value".to_string(), + clock_timestamp: Some(1000.0), + ..Default::default() + }, + ) + .unwrap(); + let not_active = verify_jwt( + &token, + &VerifyJwtOptions { + secret: "secret-value".to_string(), + clock_timestamp: Some(950.0), + clock_tolerance: Some(100.0), + ..Default::default() + }, + ) + .unwrap_err(); + assert!(not_active.to_string().contains("token not active yet")); + + let mut iat_payload = JwtClaims::new(); + iat_payload.insert("sub".to_string(), "user-123".into()); + iat_payload.insert("iat".to_string(), 1100.into()); + let token = sign_jwt( + &iat_payload, + &SignJwtOptions { + secret: "secret-value".to_string(), + clock_timestamp: Some(1000.0), + ..Default::default() + }, + ) + .unwrap(); + let used_before_issued = verify_jwt( + &token, + &VerifyJwtOptions { + secret: "secret-value".to_string(), + clock_timestamp: Some(999.0), + clock_tolerance: Some(100.0), + ..Default::default() + }, + ) + .unwrap_err(); + assert!(used_before_issued + .to_string() + .contains("token used before issued")); +} + +#[test] +fn verify_rejects_invalid_claim_shapes() { + let token = signed_hs256_token(json!({"sub": ""})); + + let err = verify_jwt( + &token, + &VerifyJwtOptions { + secret: "secret-value".to_string(), + subject: Some("user-123".into()), + ..Default::default() + }, + ) + .unwrap_err(); + assert!(err.to_string().contains("non-empty string")); +} + +#[test] +fn verify_rejects_payload_over_max_size() { + let mut payload = JwtClaims::new(); + payload.insert("sub".to_string(), "user-123".into()); + payload.insert("large".to_string(), "x".repeat(128).into()); + let token = sign_jwt( + &payload, + &SignJwtOptions { + secret: "secret-value".to_string(), + ..Default::default() + }, + ) + .unwrap(); + + let err = verify_jwt( + &token, + &VerifyJwtOptions { + secret: "secret-value".to_string(), + max_payload_size: Some(32), + ..Default::default() + }, + ) + .unwrap_err(); + assert!(err.to_string().contains("maxPayloadSize")); +} + #[test] fn verify_rejects_disallowed_claims() { let mut payload = JwtClaims::new(); From c0db9840fc10c5d667b5d1ecb0d53089f8e6f58b Mon Sep 17 00:00:00 2001 From: dnettoRaw Date: Fri, 29 May 2026 17:21:15 +0200 Subject: [PATCH 2/4] Rebuild minimal native token core --- Cargo.lock | 141 +----- Cargo.toml | 8 +- README.fr.md | 120 +---- README.md | 140 +++--- README.pt.md | 120 +---- SECURITY_NOTES.md | 13 +- examples/manager_integration.rs | 29 -- examples/native_signed.rs | 34 ++ examples/sign_verify.rs | 30 -- examples/with_claims.rs | 37 -- src/advanced_token_manager.rs | 126 ----- src/advanced_token_manager/crypto.rs | 53 --- src/advanced_token_manager/defaults.rs | 17 - src/advanced_token_manager/env.rs | 28 -- src/advanced_token_manager/error.rs | 23 - src/advanced_token_manager/init.rs | 106 ----- src/advanced_token_manager/jwt.rs | 54 --- src/advanced_token_manager/native.rs | 24 - src/advanced_token_manager/normalize.rs | 67 --- src/advanced_token_manager/options.rs | 65 --- src/advanced_token_manager/random.rs | 12 - src/advanced_token_manager/salts.rs | 17 - src/advanced_token_manager/secret.rs | 16 - src/advanced_token_manager/time.rs | 29 -- src/advanced_token_manager/token.rs | 163 ------- src/advanced_token_manager/token/build.rs | 50 -- src/advanced_token_manager/token/parse.rs | 49 -- src/advanced_token_manager/token/validate.rs | 39 -- .../token/validate/scope.rs | 26 -- .../token/validate/temporal.rs | 58 --- src/base64url.rs | 25 + src/crypto.rs | 44 ++ src/error.rs | 23 + src/jwt.rs | 70 --- src/jwt/base64url.rs | 41 -- src/jwt/claims.rs | 12 - src/jwt/claims/apply.rs | 80 ---- src/jwt/claims/audience.rs | 35 -- src/jwt/claims/enforce.rs | 18 - src/jwt/claims/header.rs | 30 -- src/jwt/claims/string.rs | 46 -- src/jwt/claims/validate.rs | 40 -- src/jwt/error.rs | 22 - src/jwt/signing.rs | 54 --- src/jwt/signing/hmac.rs | 21 - src/jwt/time.rs | 29 -- src/jwt/types.rs | 115 ----- src/jwt/verify.rs | 64 --- src/jwt/verify/claims.rs | 93 ---- src/jwt/verify/claims/allowed.rs | 19 - src/jwt/verify/header.rs | 61 --- src/jwt/verify/payload.rs | 39 -- src/jwt/verify/temporal.rs | 63 --- src/jwt/verify/temporal/max_age.rs | 35 -- src/lib.rs | 51 +- src/manager.rs | 97 ++++ src/meta.rs | 29 ++ src/meta/decode.rs | 48 ++ src/meta/encode.rs | 31 ++ src/meta/parse.rs | 29 ++ src/options.rs | 53 +++ src/token.rs | 82 ++++ src/token/build.rs | 66 +++ src/token/parts.rs | 51 ++ src/validate.rs | 30 ++ src/validate/scope.rs | 24 + src/validate/signature.rs | 12 + src/validate/time.rs | 55 +++ tests/advanced_token_manager_test.rs | 290 ------------ tests/jwt_test.rs | 434 ------------------ tests/native_token_test.rs | 405 ++++++++++++++++ 71 files changed, 1267 insertions(+), 3293 deletions(-) delete mode 100644 examples/manager_integration.rs create mode 100644 examples/native_signed.rs delete mode 100644 examples/sign_verify.rs delete mode 100644 examples/with_claims.rs delete mode 100644 src/advanced_token_manager.rs delete mode 100644 src/advanced_token_manager/crypto.rs delete mode 100644 src/advanced_token_manager/defaults.rs delete mode 100644 src/advanced_token_manager/env.rs delete mode 100644 src/advanced_token_manager/error.rs delete mode 100644 src/advanced_token_manager/init.rs delete mode 100644 src/advanced_token_manager/jwt.rs delete mode 100644 src/advanced_token_manager/native.rs delete mode 100644 src/advanced_token_manager/normalize.rs delete mode 100644 src/advanced_token_manager/options.rs delete mode 100644 src/advanced_token_manager/random.rs delete mode 100644 src/advanced_token_manager/salts.rs delete mode 100644 src/advanced_token_manager/secret.rs delete mode 100644 src/advanced_token_manager/time.rs delete mode 100644 src/advanced_token_manager/token.rs delete mode 100644 src/advanced_token_manager/token/build.rs delete mode 100644 src/advanced_token_manager/token/parse.rs delete mode 100644 src/advanced_token_manager/token/validate.rs delete mode 100644 src/advanced_token_manager/token/validate/scope.rs delete mode 100644 src/advanced_token_manager/token/validate/temporal.rs create mode 100644 src/base64url.rs create mode 100644 src/crypto.rs create mode 100644 src/error.rs delete mode 100644 src/jwt.rs delete mode 100644 src/jwt/base64url.rs delete mode 100644 src/jwt/claims.rs delete mode 100644 src/jwt/claims/apply.rs delete mode 100644 src/jwt/claims/audience.rs delete mode 100644 src/jwt/claims/enforce.rs delete mode 100644 src/jwt/claims/header.rs delete mode 100644 src/jwt/claims/string.rs delete mode 100644 src/jwt/claims/validate.rs delete mode 100644 src/jwt/error.rs delete mode 100644 src/jwt/signing.rs delete mode 100644 src/jwt/signing/hmac.rs delete mode 100644 src/jwt/time.rs delete mode 100644 src/jwt/types.rs delete mode 100644 src/jwt/verify.rs delete mode 100644 src/jwt/verify/claims.rs delete mode 100644 src/jwt/verify/claims/allowed.rs delete mode 100644 src/jwt/verify/header.rs delete mode 100644 src/jwt/verify/payload.rs delete mode 100644 src/jwt/verify/temporal.rs delete mode 100644 src/jwt/verify/temporal/max_age.rs create mode 100644 src/manager.rs create mode 100644 src/meta.rs create mode 100644 src/meta/decode.rs create mode 100644 src/meta/encode.rs create mode 100644 src/meta/parse.rs create mode 100644 src/options.rs create mode 100644 src/token.rs create mode 100644 src/token/build.rs create mode 100644 src/token/parts.rs create mode 100644 src/validate.rs create mode 100644 src/validate/scope.rs create mode 100644 src/validate/signature.rs create mode 100644 src/validate/time.rs delete mode 100644 tests/advanced_token_manager_test.rs delete mode 100644 tests/jwt_test.rs create mode 100644 tests/native_token_test.rs diff --git a/Cargo.lock b/Cargo.lock index c58b91d..4551c84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,9 +34,9 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "typenum", @@ -55,9 +55,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.9" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", @@ -65,9 +65,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", @@ -76,24 +76,14 @@ dependencies = [ [[package]] name = "hash_token_rust" -version = "0.2.0" +version = "0.3.0" dependencies = [ "base64", - "hex", "hmac", "rand", - "serde", - "serde_json", "sha2", - "thiserror", ] -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - [[package]] name = "hmac" version = "0.12.1" @@ -103,23 +93,11 @@ dependencies = [ "digest", ] -[[package]] -name = "itoa" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" - [[package]] name = "libc" -version = "0.2.177" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" - -[[package]] -name = "memchr" -version = "2.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "ppv-lite86" @@ -132,27 +110,27 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.42" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha", @@ -178,55 +156,6 @@ dependencies = [ "getrandom", ] -[[package]] -name = "ryu" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.145" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" -dependencies = [ - "itoa", - "memchr", - "ryu", - "serde", - "serde_core", -] - [[package]] name = "sha2" version = "0.10.9" @@ -246,46 +175,26 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.109" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f17c7e013e88258aa9543dcbe81aca68a667a9ac37cd69c9fbc07858bfe0e2f" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "version_check" @@ -301,18 +210,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "zerocopy" -version = "0.8.27" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.27" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 82686f0..3c7cf60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,18 +1,14 @@ [package] name = "hash_token_rust" -version = "0.2.0" +version = "0.3.0" edition = "2021" authors = ["dnettoRaw "] -description = "Rust implementation of the AdvancedTokenManager with JWT helpers" +description = "Minimal native signed tokens for standalone binaries" license = "MIT" repository = "https://github.com/dnettoRaw/hashTokenRust.git" [dependencies] base64 = "0.22" -hex = "0.4" hmac = "0.12" rand = "0.8" sha2 = "0.10" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -thiserror = "1.0" diff --git a/README.fr.md b/README.fr.md index 478d212..dc0ba93 100644 --- a/README.fr.md +++ b/README.fr.md @@ -1,116 +1,28 @@ # hash_token_rust -Gestionnaire de tokens leger en Rust pour binaires standalone, secrets partages, salts, controle d age et support JWT optionnel. +Tokens natifs signes et minimaux pour binaires Rust standalone. -Documentation : +Format principal : -- English: `README.md` -- Portugues: `README.pt.md` -- Francais: `README.fr.md` - -## A Quoi Sert Cette Bibliotheque - -`hash_token_rust` sert a creer et verifier des tokens signes de maniere simple, previsible et facile a auditer. - -Elle couvre deux usages principaux : - -- Tokens HMAC avec secret et salts, geres par `AdvancedTokenManager`. -- JWT natifs signes avec HMAC via `HS256` ou `HS512`. - -Utilisez ce projet quand des programmes standalone doivent echanger des donnees signees sans certificats, cles publiques, cles privees, frameworks ou beaucoup de dependances. - -## Fonctionnement - -Dans le flux natif `AdvancedTokenManager`, le manager conserve un secret partage et une liste de salts. Il genere un nouveau format versionne : `htr1...`. Le payload et les metadata utilisent Base64URL. Les metadata contiennent la version, l.algorithme, l.index de salt, `iat`, `exp` optionnel, `iss` optionnel et `aud` optionnel. La signature authentifie la version, le payload et les metadata avec HMAC plus le salt choisi. - -Dans le flux JWT, la bibliotheque construit un header JSON et un payload JSON, encode les deux segments en Base64URL sans padding, signe le texte `header.payload` avec HMAC et place la signature dans le troisieme segment. Lors de la verification, elle valide la structure, decode les segments, verifie l'algorithme, controle la signature puis valide les claims configurees. - -## Exemple JWT Simple - -```rust -use hash_token_rust::{ - sign_jwt, verify_jwt, JwtAlgorithm, JwtClaims, SignJwtOptions, VerifyJwtOptions, -}; - -let mut claims = JwtClaims::new(); -claims.insert("sub".to_string(), "user-123".into()); - -let token = sign_jwt(&claims, &SignJwtOptions { - secret: "a-very-secure-secret-value".to_string(), - algorithm: Some(JwtAlgorithm::HS256), - expires_in: Some(300.0), - ..Default::default() -})?; - -let verified = verify_jwt(&token, &VerifyJwtOptions { - secret: "a-very-secure-secret-value".to_string(), - algorithms: Some(vec![JwtAlgorithm::HS256]), - ..Default::default() -})?; - -assert_eq!(verified.get("sub").unwrap(), "user-123"); -# Ok::<(), Box>(()) +```text +htr1... ``` -## AdvancedTokenManager - -`AdvancedTokenManager` est l.API principale. Il cree des tokens natifs `htr1` pour la communication entre binaires et expose aussi `generate_jwt` et `validate_jwt`. Par defaut, les JWT utilisent le secret du manager, mais chaque appel peut fournir son propre secret. - -```rust -use hash_token_rust::{AdvancedTokenManager, AdvancedTokenManagerOptions, Algorithm}; - -let mut manager = AdvancedTokenManager::new( - Some("a-very-secure-secret-value".to_string()), - Some(vec!["salt-a".into(), "salt-b".into()]), - Some(Algorithm::Sha256), - false, - true, - Some(AdvancedTokenManagerOptions::default()), -)?; - -let token = manager.generate_token("payload-data", None)?; -let data = manager.validate_token(&token)?; - -assert_eq!(data, Some("payload-data".to_string())); -# Ok::<(), Box>(()) -``` +Le payload est encode, pas chiffre. La signature authentifie le token avec HMAC en utilisant un secret partage et le salt selectionne. -## Options JWT Importantes +## Usage -| Option | Role | -| --- | --- | -| `algorithm` | Definit l'algorithme de signature. La valeur par defaut est `HS256`. | -| `algorithms` | Limite la verification aux algorithmes attendus. | -| `expires_in` | Ajoute `exp` relativement au moment de signature. | -| `not_before` | Ajoute `nbf` relativement au moment de signature. | -| `issued_at` | Definit `iat`; sinon la bibliotheque ajoute le timestamp courant. | -| `clock_tolerance` | Autorise un petit decalage d'horloge pendant la validation temporelle. | -| `max_age` | Rejette les tokens dont `iat` est trop ancien. | -| `audience` | Exige et valide `aud`. | -| `issuer` | Exige et valide `iss`. | -| `subject` | Exige et valide `sub`. | -| `max_payload_size` | Rejette les payloads JWT trop grands avant et apres decodage. | -| `allowed_claims` | Limite les claims personnalisees hors claims standard. | +Utilisez ceci quand vos propres binaires doivent echanger des donnees signees sans cles publiques, certificats, services, frameworks ou beaucoup de dependances. -## Modele De Securite +## Securite -- `alg: none` est rejete. -- Seuls `HS256` et `HS512` exacts sont acceptes. -- Base64URL doit etre canonique et sans padding. -- Les segments vides, le JSON invalide et les claims de type invalide sont rejetes. -- Les signatures JWT et les signatures natives du manager sont compares sans sortie anticipee pour les entrees de meme taille. -- L'arithmetique temporelle utilise des operations checked. -- `exp`, `nbf`, `iat`, `iss`, `aud` et `sub` sont valides quand ils existent et obligatoires quand ils sont configures. - -Utilisez des secrets forts, limitez les algorithmes acceptes lors de la verification et configurez `max_payload_size` sur les endpoints publics. - -## Exemples - -```bash -cargo run --example sign_verify -cargo run --example with_claims -cargo run --example manager_integration -``` +- Signe les donnees ; ne cache pas les donnees. +- Sert pour authenticite, integrite, age, issuer et audience. +- Utilisez un secret fort et des salts rotates volontairement. +- `validate_token` retourne le payload et les metadata valides. +- `validate_payload` existe quand seul le payload est necessaire. +- `generate_token_bytes` et `validate_token_bytes` supportent les payloads non UTF-8. +- Si le secret du payload est requis, ajoutez un mode chiffre separe. ## Developpement @@ -119,5 +31,3 @@ cargo fmt --check cargo clippy --all-targets --all-features cargo test ``` - -Le code est separe par responsabilite : Base64URL, claims, signature, verification, temps, initialisation du manager, parsing de token et integration JWT du manager. diff --git a/README.md b/README.md index 5b3d7b7..e1206f7 100644 --- a/README.md +++ b/README.md @@ -1,117 +1,83 @@ # hash_token_rust -Lightweight Rust token manager for standalone binaries, shared secrets, salts, token age control and optional JWT support. +Minimal native signed tokens for standalone Rust binaries. -Documentation: +The main format is: -- English: `README.md` -- Portugues: `README.pt.md` -- Francais: `README.fr.md` - -## What This Library Is For - -`hash_token_rust` helps applications create and verify two kinds of tokens: - -- Salted HMAC tokens managed by `AdvancedTokenManager`. -- Native JWT tokens signed with HMAC algorithms `HS256` or `HS512`. - -The project is intentionally small. It avoids framework-style abstractions and keeps validation, parsing, signing and verification in explicit modules so the security flow is easy to audit. - -Use it when standalone programs need to exchange signed data without certificates, public keys, private keys, frameworks or a large dependency tree. +```text +htr1... +``` -## How It Works +The payload is encoded, not encrypted. The signature authenticates the token with HMAC using a shared secret and the selected salt. -For native manager tokens, the manager stores a shared secret and a list of salts. It emits a new versioned format: `htr1...`. The payload and metadata are Base64URL encoded. Metadata carries the token version, algorithm, salt index, `iat`, optional `exp`, optional `iss` and optional `aud`. The signature authenticates the version, payload and metadata with HMAC plus the selected salt. +## Use Case -For JWTs, the library builds a JSON header and payload, Base64URL-encodes both segments, signs `header.payload` with HMAC, and verifies the signature before validating claims. It rejects unsigned tokens and accepts only exact `HS256` or `HS512`. +Use this when your own binaries need to exchange signed data without public keys, certificates, services, frameworks, or a large dependency tree. -## Basic JWT Example +## Example ```rust use hash_token_rust::{ - sign_jwt, verify_jwt, JwtAlgorithm, JwtClaims, SignJwtOptions, VerifyJwtOptions, + AdvancedTokenManager, Algorithm, GenerateTokenOptions, ValidateTokenOptions, }; -let mut claims = JwtClaims::new(); -claims.insert("sub".to_string(), "user-123".into()); +let mut manager = AdvancedTokenManager::new( + b"very-secure-secret", + &[b"salt-a".as_slice(), b"salt-b".as_slice()], + Algorithm::Sha256, +)?; -let token = sign_jwt(&claims, &SignJwtOptions { - secret: "a-very-secure-secret-value".to_string(), - algorithm: Some(JwtAlgorithm::HS256), - expires_in: Some(300.0), - ..Default::default() -})?; +let token = manager.generate_token( + "user-id=123", + GenerateTokenOptions { + expires_in: Some(300), + issuer: Some("bin-a"), + audience: Some("bin-b"), + ..Default::default() + }, +)?; -let verified = verify_jwt(&token, &VerifyJwtOptions { - secret: "a-very-secure-secret-value".to_string(), - algorithms: Some(vec![JwtAlgorithm::HS256]), - ..Default::default() -})?; +let verified = manager.validate_token( + &token, + ValidateTokenOptions { + issuer: Some("bin-a"), + audience: Some("bin-b"), + ..Default::default() + }, +)?; -assert_eq!(verified.get("sub").unwrap(), "user-123"); +assert_eq!(verified.payload, "user-id=123"); +assert_eq!(verified.issuer.as_deref(), Some("bin-a")); # Ok::<(), Box>(()) ``` -## AdvancedTokenManager +## Security Notes -`AdvancedTokenManager` is the primary API. It creates native `htr1` tokens for binary-to-binary communication and also exposes `generate_jwt` and `validate_jwt`. JWT calls use the manager secret by default, but each call can override the secret when needed. +- This signs data; it does not hide data. +- Use it for authenticity, integrity, age control, issuer and audience checks. +- Use a strong shared secret and rotate salts deliberately. +- `validate_token` returns validated metadata with the payload. +- `validate_payload` is available when only the payload is needed. +- `generate_token_bytes` and `validate_token_bytes` support non-UTF-8 payloads. +- If payload secrecy is required, add a separate encrypted token mode later. -```rust -use hash_token_rust::{AdvancedTokenManager, AdvancedTokenManagerOptions, Algorithm}; +## Binary Payloads -let mut manager = AdvancedTokenManager::new( - Some("a-very-secure-secret-value".to_string()), - Some(vec!["salt-a".into(), "salt-b".into()]), - Some(Algorithm::Sha256), - false, - true, - Some(AdvancedTokenManagerOptions::default()), +```rust +let token = manager.generate_token_bytes( + &[0, 1, 2, 255], + GenerateTokenOptions::default(), )?; -let token = manager.generate_token("payload-data", None)?; -let data = manager.validate_token(&token)?; +let verified = manager.validate_token_bytes( + &token, + ValidateTokenOptions::default(), +)?; -assert_eq!(data, Some("payload-data".to_string())); +assert_eq!(verified.payload, vec![0, 1, 2, 255]); # Ok::<(), Box>(()) ``` -## Important JWT Options - -| Option | Purpose | -| --- | --- | -| `algorithm` | Selects the signing algorithm, defaulting to `HS256`. | -| `algorithms` | Restricts verification to expected algorithms. | -| `expires_in` | Adds an `exp` claim relative to the signing timestamp. | -| `not_before` | Adds an `nbf` claim relative to the signing timestamp. | -| `issued_at` | Sets `iat`; otherwise the library adds the current timestamp. | -| `clock_tolerance` | Allows small clock drift during temporal validation. | -| `max_age` | Rejects tokens whose `iat` is older than the configured age. | -| `audience` | Requires and validates `aud`. | -| `issuer` | Requires and validates `iss`. | -| `subject` | Requires and validates `sub`. | -| `max_payload_size` | Rejects large JWT payloads before and after decoding. | -| `allowed_claims` | Restricts non-standard custom claims. | - -## Security Model - -- `alg: none` is rejected. -- Only exact `HS256` and `HS512` are accepted. -- Base64URL input must be canonical and unpadded. -- Empty segments, malformed JSON and invalid claim shapes are rejected. -- JWT signatures and native manager signatures are compared without early exit for equal-length inputs. -- Temporal arithmetic uses checked operations. -- `exp`, `nbf`, `iat`, `iss`, `aud` and `sub` are validated when present, and required when configured. - -Use high-entropy secrets and pin accepted algorithms during verification. For public endpoints, set `max_payload_size`. - -## Examples - -```bash -cargo run --example sign_verify -cargo run --example with_claims -cargo run --example manager_integration -``` - ## Development ```bash @@ -119,5 +85,3 @@ cargo fmt --check cargo clippy --all-targets --all-features cargo test ``` - -The code is split by responsibility: Base64URL, claims, signing, verification, time handling, manager initialization, token parsing and manager JWT integration. diff --git a/README.pt.md b/README.pt.md index 25c30ca..eaf0a59 100644 --- a/README.pt.md +++ b/README.pt.md @@ -1,116 +1,28 @@ # hash_token_rust -Gerenciador leve de tokens em Rust para binarios standalone, segredos compartilhados, salts, controle de idade e suporte JWT opcional. +Tokens nativos assinados e mínimos para binários Rust standalone. -Documentacao: +Formato principal: -- English: `README.md` -- Portugues: `README.pt.md` -- Francais: `README.fr.md` - -## Para Que Serve - -`hash_token_rust` serve para criar e validar tokens assinados de forma simples, previsivel e facil de auditar. - -Ele cobre dois usos principais: - -- Tokens HMAC com segredo e salts, gerenciados por `AdvancedTokenManager`. -- JWTs nativos assinados com HMAC usando `HS256` ou `HS512`. - -Use este projeto quando programas standalone precisam trocar dados assinados sem certificados, chaves publicas, chaves privadas, frameworks ou muitas dependencias. - -## Como Funciona - -No fluxo nativo do `AdvancedTokenManager`, o manager guarda um segredo compartilhado e uma lista de salts. Ele gera um formato novo e versionado: `htr1...`. O payload e o metadata usam Base64URL. O metadata carrega versao, algoritmo, indice de salt, `iat`, `exp` opcional, `iss` opcional e `aud` opcional. A assinatura autentica versao, payload e metadata com HMAC mais o salt escolhido. - -No fluxo JWT, a biblioteca monta um header JSON e um payload JSON, codifica os dois com Base64URL sem padding, assina o texto `header.payload` com HMAC e grava a assinatura no terceiro segmento. Na validacao, ela valida a estrutura, decodifica os segmentos, verifica o algoritmo, checa a assinatura e depois valida as claims configuradas. - -## Exemplo JWT Basico - -```rust -use hash_token_rust::{ - sign_jwt, verify_jwt, JwtAlgorithm, JwtClaims, SignJwtOptions, VerifyJwtOptions, -}; - -let mut claims = JwtClaims::new(); -claims.insert("sub".to_string(), "user-123".into()); - -let token = sign_jwt(&claims, &SignJwtOptions { - secret: "a-very-secure-secret-value".to_string(), - algorithm: Some(JwtAlgorithm::HS256), - expires_in: Some(300.0), - ..Default::default() -})?; - -let verified = verify_jwt(&token, &VerifyJwtOptions { - secret: "a-very-secure-secret-value".to_string(), - algorithms: Some(vec![JwtAlgorithm::HS256]), - ..Default::default() -})?; - -assert_eq!(verified.get("sub").unwrap(), "user-123"); -# Ok::<(), Box>(()) +```text +htr1... ``` -## AdvancedTokenManager - -`AdvancedTokenManager` e a API principal. Ele cria tokens nativos `htr1` para comunicacao entre binarios e tambem integra JWT por meio de `generate_jwt` e `validate_jwt`. Por padrao, os JWTs usam o segredo do manager, mas cada chamada pode receber um segredo proprio. - -```rust -use hash_token_rust::{AdvancedTokenManager, AdvancedTokenManagerOptions, Algorithm}; - -let mut manager = AdvancedTokenManager::new( - Some("a-very-secure-secret-value".to_string()), - Some(vec!["salt-a".into(), "salt-b".into()]), - Some(Algorithm::Sha256), - false, - true, - Some(AdvancedTokenManagerOptions::default()), -)?; - -let token = manager.generate_token("payload-data", None)?; -let data = manager.validate_token(&token)?; - -assert_eq!(data, Some("payload-data".to_string())); -# Ok::<(), Box>(()) -``` +O payload é codificado, não criptografado. A assinatura autentica o token com HMAC usando um segredo compartilhado e o salt selecionado. -## Opcoes Importantes De JWT +## Uso -| Opcao | Funcao | -| --- | --- | -| `algorithm` | Define o algoritmo de assinatura. O padrao e `HS256`. | -| `algorithms` | Restringe a verificacao aos algoritmos esperados. | -| `expires_in` | Adiciona `exp` relativo ao momento da assinatura. | -| `not_before` | Adiciona `nbf` relativo ao momento da assinatura. | -| `issued_at` | Define `iat`; se ausente, a biblioteca adiciona o timestamp atual. | -| `clock_tolerance` | Permite pequena diferenca de relogio na validacao temporal. | -| `max_age` | Rejeita tokens cujo `iat` seja antigo demais. | -| `audience` | Exige e valida `aud`. | -| `issuer` | Exige e valida `iss`. | -| `subject` | Exige e valida `sub`. | -| `max_payload_size` | Rejeita payloads JWT grandes antes e depois do decode. | -| `allowed_claims` | Restringe claims customizadas fora das claims padrao. | +Use quando seus próprios binários precisam trocar dados assinados sem chaves públicas, certificados, serviços, frameworks ou muitas dependências. -## Modelo De Seguranca +## Segurança -- `alg: none` e rejeitado. -- Apenas `HS256` e `HS512` exatos sao aceitos. -- Base64URL deve ser canonico e sem padding. -- Segmentos vazios, JSON invalido e claims com tipo invalido sao rejeitados. -- Assinaturas JWT e assinaturas nativas do manager sao comparados sem early exit para entradas de mesmo tamanho. -- Aritmetica temporal usa operacoes checked. -- `exp`, `nbf`, `iat`, `iss`, `aud` e `sub` sao validados quando existem e obrigatorios quando configurados. - -Use segredos fortes, limite os algoritmos aceitos na verificacao e configure `max_payload_size` em endpoints publicos. - -## Exemplos - -```bash -cargo run --example sign_verify -cargo run --example with_claims -cargo run --example manager_integration -``` +- Assina dados; não esconde dados. +- Serve para autenticidade, integridade, idade, issuer e audience. +- Use segredo forte e salts rotacionados com intenção. +- `validate_token` retorna payload e metadata validados. +- `validate_payload` existe quando só o payload importa. +- `generate_token_bytes` e `validate_token_bytes` suportam payloads que nao sao UTF-8. +- Se precisar de sigilo do payload, adicione um modo criptografado separado. ## Desenvolvimento @@ -119,5 +31,3 @@ cargo fmt --check cargo clippy --all-targets --all-features cargo test ``` - -O codigo e separado por responsabilidade: Base64URL, claims, assinatura, verificacao, tempo, inicializacao do manager, parsing de token e integracao JWT do manager. diff --git a/SECURITY_NOTES.md b/SECURITY_NOTES.md index 375a0e9..9043f1c 100644 --- a/SECURITY_NOTES.md +++ b/SECURITY_NOTES.md @@ -1,9 +1,8 @@ # Security Notes -- JWT verification rejects `alg: none` and accepts only exact `HS256` or `HS512`. -- Signatures and legacy token checksums are compared without early exit for equal-length inputs. -- Base64URL segments must use the unpadded URL-safe alphabet. Empty or malformed segments are rejected. -- `exp`, `nbf`, `iat`, `iss`, `aud` and `sub` are validated when present, and required when configured in verify options. -- `clock_tolerance` is non-negative and temporal arithmetic uses checked operations. -- `max_payload_size` is checked before and after payload decoding to limit unauthenticated allocation. -- Use high-entropy secrets. `AdvancedTokenManager::new` requires at least 16 characters for its main secret. +- Native `htr1` tokens are signed, not encrypted. +- Payloads are Base64URL encoded and readable by anyone who has the token. +- HMAC authenticates version, payload and metadata using the shared secret plus selected salt. +- Verification checks algorithm, salt index, expiration, max age, issuer and audience when configured. +- Signature comparison is constant-time for equal-length signatures. +- Use an encrypted token mode for payload secrecy. diff --git a/examples/manager_integration.rs b/examples/manager_integration.rs deleted file mode 100644 index 3ca574b..0000000 --- a/examples/manager_integration.rs +++ /dev/null @@ -1,29 +0,0 @@ -use hash_token_rust::{ - AdvancedTokenManager, AdvancedTokenManagerOptions, Algorithm, JwtAlgorithm, JwtClaims, - ManagerVerifyJwtOptions, -}; - -fn main() -> Result<(), Box> { - let manager = AdvancedTokenManager::new( - Some("a-very-secure-secret-value".to_string()), - Some(vec!["salt-a".into(), "salt-b".into()]), - Some(Algorithm::Sha256), - false, - true, - Some(AdvancedTokenManagerOptions { - jwt_default_algorithms: Some(vec![JwtAlgorithm::HS256]), - jwt_max_payload_size: Some(1024), - ..Default::default() - }), - )?; - - let mut claims = JwtClaims::new(); - claims.insert("sub".to_string(), "user-123".into()); - - let token = manager.generate_jwt(&claims, None)?; - let verified: JwtClaims = - manager.validate_jwt(&token, Some(ManagerVerifyJwtOptions::default()))?; - - println!("subject={}", verified["sub"]); - Ok(()) -} diff --git a/examples/native_signed.rs b/examples/native_signed.rs new file mode 100644 index 0000000..4c76f6b --- /dev/null +++ b/examples/native_signed.rs @@ -0,0 +1,34 @@ +use hash_token_rust::{ + AdvancedTokenManager, Algorithm, GenerateTokenOptions, ValidateTokenOptions, +}; + +fn main() -> Result<(), Box> { + let mut manager = AdvancedTokenManager::new( + b"very-secure-secret", + &[b"salt-a".as_slice(), b"salt-b".as_slice()], + Algorithm::Sha256, + )?; + + let token = manager.generate_token( + "user-id=123", + GenerateTokenOptions { + expires_in: Some(300), + issuer: Some("bin-a"), + audience: Some("bin-b"), + ..Default::default() + }, + )?; + + let verified = manager.validate_token( + &token, + ValidateTokenOptions { + issuer: Some("bin-a"), + audience: Some("bin-b"), + ..Default::default() + }, + )?; + + println!("payload={}", verified.payload); + println!("salt_index={}", verified.salt_index); + Ok(()) +} diff --git a/examples/sign_verify.rs b/examples/sign_verify.rs deleted file mode 100644 index 40afc41..0000000 --- a/examples/sign_verify.rs +++ /dev/null @@ -1,30 +0,0 @@ -use hash_token_rust::{ - sign_jwt, verify_jwt, JwtAlgorithm, JwtClaims, SignJwtOptions, VerifyJwtOptions, -}; - -fn main() -> Result<(), Box> { - let mut claims = JwtClaims::new(); - claims.insert("sub".to_string(), "user-123".into()); - - let token = sign_jwt( - &claims, - &SignJwtOptions { - secret: "a-very-secure-secret-value".to_string(), - algorithm: Some(JwtAlgorithm::HS256), - expires_in: Some(300.0), - ..Default::default() - }, - )?; - - let verified = verify_jwt( - &token, - &VerifyJwtOptions { - secret: "a-very-secure-secret-value".to_string(), - algorithms: Some(vec![JwtAlgorithm::HS256]), - ..Default::default() - }, - )?; - - println!("subject={}", verified["sub"]); - Ok(()) -} diff --git a/examples/with_claims.rs b/examples/with_claims.rs deleted file mode 100644 index 7013cec..0000000 --- a/examples/with_claims.rs +++ /dev/null @@ -1,37 +0,0 @@ -use hash_token_rust::{ - sign_jwt, verify_jwt, Audience, Issuer, JwtAlgorithm, JwtClaims, SignJwtOptions, - VerifyJwtOptions, -}; - -fn main() -> Result<(), Box> { - let mut claims = JwtClaims::new(); - claims.insert("role".to_string(), "admin".into()); - - let token = sign_jwt( - &claims, - &SignJwtOptions { - secret: "a-very-secure-secret-value".to_string(), - algorithm: Some(JwtAlgorithm::HS512), - expires_in: Some(600.0), - audience: Some(Audience::Single("internal-api".into())), - issuer: Some("auth-service".into()), - subject: Some("user-123".into()), - ..Default::default() - }, - )?; - - let verified = verify_jwt( - &token, - &VerifyJwtOptions { - secret: "a-very-secure-secret-value".to_string(), - algorithms: Some(vec![JwtAlgorithm::HS512]), - audience: Some(Audience::Single("internal-api".into())), - issuer: Some(Issuer::Single("auth-service".into())), - subject: Some("user-123".into()), - ..Default::default() - }, - )?; - - println!("claims={verified:?}"); - Ok(()) -} diff --git a/src/advanced_token_manager.rs b/src/advanced_token_manager.rs deleted file mode 100644 index 78c5a88..0000000 --- a/src/advanced_token_manager.rs +++ /dev/null @@ -1,126 +0,0 @@ -mod crypto; -mod defaults; -mod env; -mod error; -mod init; -mod jwt; -mod native; -mod normalize; -mod options; -mod random; -mod salts; -mod secret; -mod time; -mod token; - -use std::sync::Arc; - -pub use error::{AdvancedTokenError, TokenValidationError}; -pub use options::{ - AdvancedTokenManagerOptions, GenerateTokenOptions, ManagerSignJwtOptions, - ManagerVerifyJwtOptions, ValidateTokenOptions, -}; - -use crate::jwt::JwtAlgorithm; - -const DEFAULT_SECRET_LENGTH: usize = 32; -const DEFAULT_SALT_COUNT: usize = 10; -const DEFAULT_SALT_LENGTH: usize = 16; -const MIN_SECRET_LENGTH: usize = 16; -const MIN_SALT_COUNT: usize = 2; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Algorithm { - Sha256, - Sha512, -} - -pub trait AdvancedTokenManagerLogger: Send + Sync { - fn warn(&self, message: &str); - fn error(&self, message: &str); -} - -#[derive(Clone)] -struct DefaultLogger; - -impl AdvancedTokenManagerLogger for DefaultLogger { - fn warn(&self, message: &str) { - eprintln!("{}", message); - } - - fn error(&self, message: &str) { - eprintln!("{}", message); - } -} - -pub struct ManagerConfig { - pub secret: String, - pub salts: Vec, -} - -pub struct AdvancedTokenManager { - secret: String, - salts: Vec, - algorithm: Algorithm, - last_salt_index: Option, - logger: Arc, - throw_on_validation_failure: bool, - jwt_default_algorithms: Option>, - jwt_max_payload_size: Option, - jwt_allowed_claims: Option>, -} - -impl AdvancedTokenManager { - #[allow(clippy::too_many_arguments)] - pub fn new( - secret: Option, - salts: Option>, - algorithm: Option, - allow_auto_generate: bool, - no_env: bool, - options: Option, - ) -> Result { - let options = options.unwrap_or_default(); - let logger = options - .logger - .clone() - .unwrap_or_else(|| Arc::new(DefaultLogger)); - let defaults = init::resolve_defaults(&options)?; - let jwt_options = init::resolve_jwt_options(options)?; - - let secret = init::initialize_secret( - secret, - allow_auto_generate, - no_env, - defaults.secret_length, - &*logger, - )?; - let salts = init::initialize_salts( - salts, - allow_auto_generate, - no_env, - defaults.salt_count, - defaults.salt_length, - &*logger, - )?; - - Ok(Self { - secret, - salts, - algorithm: algorithm.unwrap_or(Algorithm::Sha256), - last_salt_index: None, - logger, - throw_on_validation_failure: jwt_options.throw_on_validation_failure, - jwt_default_algorithms: jwt_options.default_algorithms, - jwt_max_payload_size: jwt_options.max_payload_size, - jwt_allowed_claims: jwt_options.allowed_claims, - }) - } - - pub fn get_config(&self) -> ManagerConfig { - ManagerConfig { - secret: self.secret.clone(), - salts: self.salts.clone(), - } - } -} diff --git a/src/advanced_token_manager/crypto.rs b/src/advanced_token_manager/crypto.rs deleted file mode 100644 index f05e63f..0000000 --- a/src/advanced_token_manager/crypto.rs +++ /dev/null @@ -1,53 +0,0 @@ -use hmac::{Hmac, Mac}; -use sha2::{Sha256, Sha512}; - -use super::{Algorithm, TokenValidationError}; - -type HmacSha256 = Hmac; -type HmacSha512 = Hmac; - -impl Algorithm { - pub(super) fn name(self) -> &'static str { - match self { - Algorithm::Sha256 => "HS256", - Algorithm::Sha512 => "HS512", - } - } - - pub(super) fn to_hmac( - self, - secret: &[u8], - input: &[u8], - ) -> Result, TokenValidationError> { - match self { - Algorithm::Sha256 => compute_hmac_sha256(secret, input), - Algorithm::Sha512 => compute_hmac_sha512(secret, input), - } - } -} - -fn compute_hmac_sha256(secret: &[u8], input: &[u8]) -> Result, TokenValidationError> { - let mut mac = HmacSha256::new_from_slice(secret) - .map_err(|_| TokenValidationError::new("Invalid HMAC key."))?; - mac.update(input); - Ok(mac.finalize().into_bytes().to_vec()) -} - -fn compute_hmac_sha512(secret: &[u8], input: &[u8]) -> Result, TokenValidationError> { - let mut mac = HmacSha512::new_from_slice(secret) - .map_err(|_| TokenValidationError::new("Invalid HMAC key."))?; - mac.update(input); - Ok(mac.finalize().into_bytes().to_vec()) -} - -pub(super) fn constant_time_compare(expected: &[u8], provided: &[u8]) -> bool { - if expected.len() != provided.len() { - return false; - } - - let mut diff: u8 = 0; - for (a, b) in expected.iter().zip(provided) { - diff |= a ^ b; - } - diff == 0 -} diff --git a/src/advanced_token_manager/defaults.rs b/src/advanced_token_manager/defaults.rs deleted file mode 100644 index 6f5838e..0000000 --- a/src/advanced_token_manager/defaults.rs +++ /dev/null @@ -1,17 +0,0 @@ -use super::AdvancedTokenError; - -pub(super) fn resolve_length_option( - name: &str, - provided: Option, - fallback: usize, - minimum: usize, -) -> Result { - match provided { - None => Ok(fallback), - Some(value) if value < minimum => Err(AdvancedTokenError::Message(format!( - "{} must be an integer greater than or equal to {}.", - name, minimum - ))), - Some(value) => Ok(value), - } -} diff --git a/src/advanced_token_manager/env.rs b/src/advanced_token_manager/env.rs deleted file mode 100644 index fa484f3..0000000 --- a/src/advanced_token_manager/env.rs +++ /dev/null @@ -1,28 +0,0 @@ -use std::env; - -pub(super) fn resolve_secret_candidate(secret: Option, no_env: bool) -> Option { - let provided = secret.map(|value| value.trim().to_string()); - if no_env || provided.is_some() { - provided - } else { - env::var("TOKEN_SECRET") - .ok() - .map(|value| value.trim().to_string()) - } -} - -pub(super) fn resolve_salt_candidates( - salts: Option>, - no_env: bool, -) -> Option> { - if no_env || salts.as_ref().is_some_and(|values| !values.is_empty()) { - salts - } else { - env::var("TOKEN_SALTS").ok().map(|value| { - value - .split(',') - .map(|entry| entry.trim().to_string()) - .collect() - }) - } -} diff --git a/src/advanced_token_manager/error.rs b/src/advanced_token_manager/error.rs deleted file mode 100644 index 7e74cd1..0000000 --- a/src/advanced_token_manager/error.rs +++ /dev/null @@ -1,23 +0,0 @@ -use thiserror::Error; - -use crate::jwt::JwtError; - -#[derive(Debug, Error)] -pub enum AdvancedTokenError { - #[error("{0}")] - Message(String), - #[error(transparent)] - Jwt(#[from] JwtError), -} - -#[derive(Debug, Error, Clone)] -pub enum TokenValidationError { - #[error("{0}")] - Message(String), -} - -impl TokenValidationError { - pub(super) fn new(message: impl Into) -> Self { - Self::Message(message.into()) - } -} diff --git a/src/advanced_token_manager/init.rs b/src/advanced_token_manager/init.rs deleted file mode 100644 index 30208bd..0000000 --- a/src/advanced_token_manager/init.rs +++ /dev/null @@ -1,106 +0,0 @@ -use super::defaults::resolve_length_option; -use super::env::{resolve_salt_candidates, resolve_secret_candidate}; -use super::normalize::{normalize_algorithms, normalize_allowed_claims, normalize_positive_usize}; -use super::random::generate_random_key; -use super::salts::validate_salts; -use super::secret::{short_secret_error, validate_secret}; -use super::{ - AdvancedTokenError, AdvancedTokenManagerLogger, AdvancedTokenManagerOptions, - DEFAULT_SALT_COUNT, DEFAULT_SALT_LENGTH, DEFAULT_SECRET_LENGTH, MIN_SALT_COUNT, - MIN_SECRET_LENGTH, -}; -use crate::jwt::JwtAlgorithm; - -pub(super) struct ManagerDefaults { - pub secret_length: usize, - pub salt_count: usize, - pub salt_length: usize, -} - -pub(super) struct JwtManagerOptions { - pub default_algorithms: Option>, - pub throw_on_validation_failure: bool, - pub max_payload_size: Option, - pub allowed_claims: Option>, -} - -pub(super) fn resolve_defaults( - options: &AdvancedTokenManagerOptions, -) -> Result { - Ok(ManagerDefaults { - secret_length: resolve_length_option( - "defaultSecretLength", - options.default_secret_length, - DEFAULT_SECRET_LENGTH, - MIN_SECRET_LENGTH, - )?, - salt_count: resolve_length_option( - "defaultSaltCount", - options.default_salt_count, - DEFAULT_SALT_COUNT, - MIN_SALT_COUNT, - )?, - salt_length: resolve_length_option( - "defaultSaltLength", - options.default_salt_length, - DEFAULT_SALT_LENGTH, - 1, - )?, - }) -} - -pub(super) fn resolve_jwt_options( - options: AdvancedTokenManagerOptions, -) -> Result { - Ok(JwtManagerOptions { - default_algorithms: normalize_algorithms(options.jwt_default_algorithms)?, - throw_on_validation_failure: options.throw_on_validation_failure.unwrap_or(false), - max_payload_size: normalize_positive_usize( - "jwtMaxPayloadSize", - options.jwt_max_payload_size, - )?, - allowed_claims: normalize_allowed_claims(options.jwt_allowed_claims)?, - }) -} - -pub(super) fn initialize_secret( - secret: Option, - allow_auto_generate: bool, - no_env: bool, - default_length: usize, - logger: &dyn AdvancedTokenManagerLogger, -) -> Result { - let candidate = resolve_secret_candidate(secret, no_env); - match candidate { - Some(secret) => validate_secret(secret), - None if allow_auto_generate => { - let generated = generate_random_key(default_length); - logger.warn("⚠️ Secret generated automatically. Store it securely."); - Ok(generated) - } - None => Err(short_secret_error()), - } -} - -pub(super) fn initialize_salts( - salts: Option>, - allow_auto_generate: bool, - no_env: bool, - default_count: usize, - default_length: usize, - logger: &dyn AdvancedTokenManagerLogger, -) -> Result, AdvancedTokenError> { - match resolve_salt_candidates(salts, no_env) { - Some(values) => validate_salts(values), - None if allow_auto_generate => { - let salts = (0..default_count) - .map(|_| generate_random_key(default_length)) - .collect(); - logger.warn("⚠️ Salts generated automatically. Store them securely."); - Ok(salts) - } - None => Err(AdvancedTokenError::Message( - "Salt array cannot be empty or less than 2.".to_string(), - )), - } -} diff --git a/src/advanced_token_manager/jwt.rs b/src/advanced_token_manager/jwt.rs deleted file mode 100644 index 4621984..0000000 --- a/src/advanced_token_manager/jwt.rs +++ /dev/null @@ -1,54 +0,0 @@ -use serde::de::DeserializeOwned; - -use super::{ - AdvancedTokenError, AdvancedTokenManager, ManagerSignJwtOptions, ManagerVerifyJwtOptions, -}; -use crate::jwt::{sign_jwt, verify_jwt_as, JwtClaims, SignJwtOptions, VerifyJwtOptions}; - -impl AdvancedTokenManager { - pub fn generate_jwt( - &self, - payload: &JwtClaims, - options: Option, - ) -> Result { - let options = options.unwrap_or_default(); - let sign_options = SignJwtOptions { - secret: options.secret.unwrap_or_else(|| self.secret.clone()), - algorithm: options.algorithm, - header: options.header, - expires_in: options.expires_in, - not_before: options.not_before, - audience: options.audience, - issuer: options.issuer, - subject: options.subject, - issued_at: options.issued_at, - clock_timestamp: options.clock_timestamp, - }; - Ok(sign_jwt(payload, &sign_options)?) - } - - pub fn validate_jwt( - &self, - token: &str, - options: Option, - ) -> Result { - let options = options.unwrap_or_default(); - let verify_options = VerifyJwtOptions { - secret: options.secret.unwrap_or_else(|| self.secret.clone()), - algorithms: options - .algorithms - .or_else(|| self.jwt_default_algorithms.clone()), - clock_tolerance: options.clock_tolerance, - audience: options.audience, - issuer: options.issuer, - subject: options.subject, - max_age: options.max_age, - clock_timestamp: options.clock_timestamp, - max_payload_size: options.max_payload_size.or(self.jwt_max_payload_size), - allowed_claims: options - .allowed_claims - .or_else(|| self.jwt_allowed_claims.clone()), - }; - Ok(verify_jwt_as(token, &verify_options)?) - } -} diff --git a/src/advanced_token_manager/native.rs b/src/advanced_token_manager/native.rs deleted file mode 100644 index c0e59b2..0000000 --- a/src/advanced_token_manager/native.rs +++ /dev/null @@ -1,24 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Deserialize, Serialize)] -pub(super) struct NativeTokenMeta { - pub v: u8, - pub alg: String, - pub salt: usize, - pub iat: i64, - #[serde(skip_serializing_if = "Option::is_none")] - pub exp: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub iss: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub aud: Option, -} - -pub(super) struct NativeTokenParts { - pub payload: String, - pub meta: NativeTokenMeta, - pub signing_input: String, - pub signature: String, -} - -pub(super) const TOKEN_VERSION: &str = "htr1"; diff --git a/src/advanced_token_manager/normalize.rs b/src/advanced_token_manager/normalize.rs deleted file mode 100644 index 95d48b1..0000000 --- a/src/advanced_token_manager/normalize.rs +++ /dev/null @@ -1,67 +0,0 @@ -use super::AdvancedTokenError; -use crate::jwt::JwtAlgorithm; - -pub(super) fn normalize_positive_usize( - name: &str, - value: Option, -) -> Result, AdvancedTokenError> { - match value { - None => Ok(None), - Some(0) => Err(AdvancedTokenError::Message(format!( - "{} must be a positive number.", - name - ))), - Some(value) => Ok(Some(value)), - } -} - -pub(super) fn normalize_allowed_claims( - allowed: Option>, -) -> Result>, AdvancedTokenError> { - match allowed { - None => Ok(None), - Some(values) => normalize_unique_strings(values, "jwtAllowedClaims"), - } -} - -pub(super) fn normalize_algorithms( - algorithms: Option>, -) -> Result>, AdvancedTokenError> { - match algorithms { - None => Ok(None), - Some(values) if values.is_empty() => Err(AdvancedTokenError::Message( - "jwtDefaultAlgorithms must be a non-empty array when provided.".to_string(), - )), - Some(values) => Ok(Some(unique_algorithms(values))), - } -} - -fn normalize_unique_strings( - values: Vec, - name: &str, -) -> Result>, AdvancedTokenError> { - let mut unique = Vec::new(); - for value in values { - let trimmed = value.trim().to_string(); - if trimmed.is_empty() { - return Err(AdvancedTokenError::Message(format!( - "{} must be an array of non-empty strings.", - name - ))); - } - if !unique.contains(&trimmed) { - unique.push(trimmed); - } - } - Ok(Some(unique)) -} - -fn unique_algorithms(values: Vec) -> Vec { - let mut unique = Vec::new(); - for value in values { - if !unique.contains(&value) { - unique.push(value); - } - } - unique -} diff --git a/src/advanced_token_manager/options.rs b/src/advanced_token_manager/options.rs deleted file mode 100644 index 1465db1..0000000 --- a/src/advanced_token_manager/options.rs +++ /dev/null @@ -1,65 +0,0 @@ -use std::sync::Arc; - -use serde_json::{Map, Value}; - -use crate::advanced_token_manager::AdvancedTokenManagerLogger; -use crate::jwt::{Audience, Issuer, JwtAlgorithm}; - -#[derive(Default, Clone)] -pub struct AdvancedTokenManagerOptions { - pub logger: Option>, - pub jwt_default_algorithms: Option>, - pub default_secret_length: Option, - pub default_salt_count: Option, - pub default_salt_length: Option, - pub throw_on_validation_failure: Option, - pub jwt_max_payload_size: Option, - pub jwt_allowed_claims: Option>, -} - -#[derive(Default, Clone)] -pub struct GenerateTokenOptions { - pub salt_index: Option, - pub expires_in: Option, - pub issuer: Option, - pub audience: Option, - pub issued_at: Option, -} - -#[derive(Default, Clone)] -pub struct ValidateTokenOptions { - pub throw_on_failure: Option, - pub max_age: Option, - pub issuer: Option, - pub audience: Option, - pub clock_tolerance: Option, - pub clock_timestamp: Option, -} - -#[derive(Default, Clone)] -pub struct ManagerSignJwtOptions { - pub secret: Option, - pub algorithm: Option, - pub header: Option>, - pub expires_in: Option, - pub not_before: Option, - pub audience: Option, - pub issuer: Option, - pub subject: Option, - pub issued_at: Option, - pub clock_timestamp: Option, -} - -#[derive(Default, Clone)] -pub struct ManagerVerifyJwtOptions { - pub secret: Option, - pub algorithms: Option>, - pub clock_tolerance: Option, - pub audience: Option, - pub issuer: Option, - pub subject: Option, - pub max_age: Option, - pub clock_timestamp: Option, - pub max_payload_size: Option, - pub allowed_claims: Option>, -} diff --git a/src/advanced_token_manager/random.rs b/src/advanced_token_manager/random.rs deleted file mode 100644 index a6f283f..0000000 --- a/src/advanced_token_manager/random.rs +++ /dev/null @@ -1,12 +0,0 @@ -use rand::distributions::{Distribution, Uniform}; -use rand::rngs::OsRng; - -const CHARACTERS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - -pub(super) fn generate_random_key(length: usize) -> String { - let distribution = Uniform::from(0..CHARACTERS.len()); - let mut rng = OsRng; - (0..length) - .map(|_| CHARACTERS[distribution.sample(&mut rng)] as char) - .collect() -} diff --git a/src/advanced_token_manager/salts.rs b/src/advanced_token_manager/salts.rs deleted file mode 100644 index 489030c..0000000 --- a/src/advanced_token_manager/salts.rs +++ /dev/null @@ -1,17 +0,0 @@ -use super::{AdvancedTokenError, MIN_SALT_COUNT}; - -pub(super) fn validate_salts(values: Vec) -> Result, AdvancedTokenError> { - let sanitized: Vec = values - .into_iter() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .collect(); - if sanitized.len() < MIN_SALT_COUNT { - Err(AdvancedTokenError::Message(format!( - "Salt array cannot be empty or less than {}.", - MIN_SALT_COUNT - ))) - } else { - Ok(sanitized) - } -} diff --git a/src/advanced_token_manager/secret.rs b/src/advanced_token_manager/secret.rs deleted file mode 100644 index 4cc78cc..0000000 --- a/src/advanced_token_manager/secret.rs +++ /dev/null @@ -1,16 +0,0 @@ -use super::{AdvancedTokenError, MIN_SECRET_LENGTH}; - -pub(super) fn validate_secret(secret: String) -> Result { - if secret.len() < MIN_SECRET_LENGTH { - Err(short_secret_error()) - } else { - Ok(secret) - } -} - -pub(super) fn short_secret_error() -> AdvancedTokenError { - AdvancedTokenError::Message(format!( - "Secret must be at least {} characters long.", - MIN_SECRET_LENGTH - )) -} diff --git a/src/advanced_token_manager/time.rs b/src/advanced_token_manager/time.rs deleted file mode 100644 index 490d5e1..0000000 --- a/src/advanced_token_manager/time.rs +++ /dev/null @@ -1,29 +0,0 @@ -use std::time::{SystemTime, UNIX_EPOCH}; - -use super::TokenValidationError; - -pub(super) fn current_timestamp(clock: Option) -> Result { - if let Some(value) = clock { - if !value.is_finite() { - return Err(TokenValidationError::new( - "clockTimestamp must be a finite number.", - )); - } - return Ok(value.floor() as i64); - } - - let duration = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|_| TokenValidationError::new("System time before UNIX_EPOCH."))?; - Ok(duration.as_secs() as i64) -} - -pub(super) fn positive_seconds(value: f64, name: &str) -> Result { - if !value.is_finite() || value <= 0.0 { - return Err(TokenValidationError::new(format!( - "{} must be a positive number of seconds.", - name - ))); - } - Ok(value.floor() as i64) -} diff --git a/src/advanced_token_manager/token.rs b/src/advanced_token_manager/token.rs deleted file mode 100644 index 48cb061..0000000 --- a/src/advanced_token_manager/token.rs +++ /dev/null @@ -1,163 +0,0 @@ -mod build; -mod parse; -mod validate; - -use rand::Rng; - -use super::native::NativeTokenMeta; -use super::{ - AdvancedTokenManager, GenerateTokenOptions, TokenValidationError, ValidateTokenOptions, -}; - -impl AdvancedTokenManager { - pub fn generate_token( - &mut self, - input: &str, - salt_index: Option, - ) -> Result { - self.generate_token_with_options( - input, - Some(GenerateTokenOptions { - salt_index, - ..Default::default() - }), - ) - } - - pub fn generate_token_with_options( - &mut self, - input: &str, - options: Option, - ) -> Result { - let options = options.unwrap_or_default(); - let index = self.resolve_salt_index(options.salt_index)?; - let meta = self.build_meta(index, &options)?; - let signing_input = self.create_signature_input(input, &meta)?; - let signature = self.sign_native_input(&signing_input, index)?; - build::build_token(input, &meta, &signature) - } - - pub fn validate_token(&self, token: &str) -> Result, TokenValidationError> { - self.validate_token_with_options(token, None) - } - - pub fn validate_token_with_options( - &self, - token: &str, - options: Option, - ) -> Result, TokenValidationError> { - let options = options.unwrap_or_default(); - let should_throw = options - .throw_on_failure - .unwrap_or(self.throw_on_validation_failure); - - match self.validate_token_internal(token, &options) { - Ok(value) => Ok(Some(value)), - Err(error) if should_throw => Err(error), - Err(error) => { - self.logger - .error(&format!("Error validating token: {}", error)); - Ok(None) - } - } - } - - pub fn validate_token_lenient(&self, token: &str) -> Option { - self.validate_token(token).ok().flatten() - } - - pub fn extract_data(&self, token: &str) -> Result, TokenValidationError> { - self.validate_token(token) - } -} - -impl AdvancedTokenManager { - fn validate_token_internal( - &self, - token: &str, - options: &ValidateTokenOptions, - ) -> Result { - let parts = parse::parse_token(token)?; - self.validate_salt_index(parts.meta.salt)?; - validate::validate_metadata(&parts.meta, options)?; - validate::verify_scope(&parts.meta, options)?; - validate::verify_signature(self, &parts)?; - Ok(parts.payload) - } - - fn build_meta( - &mut self, - index: usize, - options: &GenerateTokenOptions, - ) -> Result { - let issued_at = super::time::current_timestamp(options.issued_at)?; - Ok(NativeTokenMeta { - v: 1, - alg: self.algorithm.name().to_string(), - salt: index, - iat: issued_at, - exp: build::expiration(issued_at, options.expires_in)?, - iss: options.issuer.clone(), - aud: options.audience.clone(), - }) - } - - fn resolve_salt_index( - &mut self, - salt_index: Option, - ) -> Result { - match salt_index { - Some(index) => { - self.validate_salt_index(index)?; - Ok(index) - } - None => Ok(self.get_random_salt_index()), - } - } - - pub(super) fn validate_salt_index(&self, index: usize) -> Result<(), TokenValidationError> { - if index < self.salts.len() { - Ok(()) - } else { - Err(TokenValidationError::new(format!( - "Invalid salt index: {}", - index - ))) - } - } - - pub(super) fn create_signature_input( - &self, - payload: &str, - meta: &NativeTokenMeta, - ) -> Result { - build::signing_input(payload, meta) - } - - pub(super) fn sign_native_input( - &self, - signing_input: &str, - salt_index: usize, - ) -> Result { - let mut material = - String::with_capacity(signing_input.len() + self.salts[salt_index].len()); - material.push_str(signing_input); - material.push_str(&self.salts[salt_index]); - let digest = self - .algorithm - .to_hmac(self.secret.as_bytes(), material.as_bytes())?; - Ok(hex::encode(digest)) - } - - fn get_random_salt_index(&mut self) -> usize { - let len = self.salts.len(); - let mut rng = rand::thread_rng(); - loop { - let index = rng.gen_range(0..len); - if Some(index) != self.last_salt_index { - self.last_salt_index = Some(index); - return index; - } - } - } -} diff --git a/src/advanced_token_manager/token/build.rs b/src/advanced_token_manager/token/build.rs deleted file mode 100644 index a4c3123..0000000 --- a/src/advanced_token_manager/token/build.rs +++ /dev/null @@ -1,50 +0,0 @@ -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; - -use crate::advanced_token_manager::native::{NativeTokenMeta, TOKEN_VERSION}; -use crate::advanced_token_manager::time::positive_seconds; -use crate::advanced_token_manager::TokenValidationError; - -pub(super) fn build_token( - payload: &str, - meta: &NativeTokenMeta, - signature: &str, -) -> Result { - let encoded_payload = URL_SAFE_NO_PAD.encode(payload.as_bytes()); - let encoded_meta = encode_meta(meta)?; - Ok(format!( - "{}.{}.{}.{}", - TOKEN_VERSION, encoded_payload, encoded_meta, signature - )) -} - -pub(super) fn signing_input( - payload: &str, - meta: &NativeTokenMeta, -) -> Result { - let encoded_payload = URL_SAFE_NO_PAD.encode(payload.as_bytes()); - let encoded_meta = encode_meta(meta)?; - Ok(format!( - "{}.{}.{}", - TOKEN_VERSION, encoded_payload, encoded_meta - )) -} - -pub(super) fn expiration( - issued_at: i64, - expires_in: Option, -) -> Result, TokenValidationError> { - match expires_in { - Some(value) => Ok(Some( - issued_at - .checked_add(positive_seconds(value, "expiresIn")?) - .ok_or_else(|| TokenValidationError::new("Token temporal claim overflow."))?, - )), - None => Ok(None), - } -} - -fn encode_meta(meta: &NativeTokenMeta) -> Result { - let json = serde_json::to_vec(meta) - .map_err(|_| TokenValidationError::new("Failed to serialize token metadata."))?; - Ok(URL_SAFE_NO_PAD.encode(json)) -} diff --git a/src/advanced_token_manager/token/parse.rs b/src/advanced_token_manager/token/parse.rs deleted file mode 100644 index 7b139ac..0000000 --- a/src/advanced_token_manager/token/parse.rs +++ /dev/null @@ -1,49 +0,0 @@ -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; - -use crate::advanced_token_manager::native::{NativeTokenMeta, NativeTokenParts, TOKEN_VERSION}; -use crate::advanced_token_manager::TokenValidationError; - -pub(super) fn parse_token(token: &str) -> Result { - let segments = split_token(token)?; - let payload = decode_payload(segments.payload)?; - let meta = decode_meta(segments.meta)?; - Ok(NativeTokenParts { - payload, - meta, - signing_input: format!("{}.{}.{}", TOKEN_VERSION, segments.payload, segments.meta), - signature: segments.signature.to_string(), - }) -} - -struct Segments<'a> { - payload: &'a str, - meta: &'a str, - signature: &'a str, -} - -fn split_token(token: &str) -> Result, TokenValidationError> { - let parts: Vec<&str> = token.split('.').collect(); - if parts.len() != 4 || parts[0] != TOKEN_VERSION || parts.iter().any(|part| part.is_empty()) { - return Err(TokenValidationError::new("Invalid native token structure.")); - } - Ok(Segments { - payload: parts[1], - meta: parts[2], - signature: parts[3], - }) -} - -fn decode_payload(encoded: &str) -> Result { - let bytes = URL_SAFE_NO_PAD - .decode(encoded) - .map_err(|_| TokenValidationError::new("Invalid native token payload."))?; - String::from_utf8(bytes).map_err(|_| TokenValidationError::new("Payload is not valid UTF-8.")) -} - -fn decode_meta(encoded: &str) -> Result { - let bytes = URL_SAFE_NO_PAD - .decode(encoded) - .map_err(|_| TokenValidationError::new("Invalid native token metadata."))?; - serde_json::from_slice(&bytes) - .map_err(|_| TokenValidationError::new("Metadata is not valid JSON.")) -} diff --git a/src/advanced_token_manager/token/validate.rs b/src/advanced_token_manager/token/validate.rs deleted file mode 100644 index f1b87f2..0000000 --- a/src/advanced_token_manager/token/validate.rs +++ /dev/null @@ -1,39 +0,0 @@ -mod scope; -mod temporal; - -use crate::advanced_token_manager::crypto::constant_time_compare; -use crate::advanced_token_manager::native::{NativeTokenMeta, NativeTokenParts}; -use crate::advanced_token_manager::{ - AdvancedTokenManager, TokenValidationError, ValidateTokenOptions, -}; - -pub(super) fn validate_metadata( - meta: &NativeTokenMeta, - options: &ValidateTokenOptions, -) -> Result<(), TokenValidationError> { - if meta.v != 1 { - return Err(TokenValidationError::new( - "Unsupported native token version.", - )); - } - temporal::validate_temporal_metadata(meta, options) -} - -pub(super) fn verify_scope( - meta: &NativeTokenMeta, - options: &ValidateTokenOptions, -) -> Result<(), TokenValidationError> { - scope::verify_scope(meta, options) -} - -pub(super) fn verify_signature( - manager: &AdvancedTokenManager, - parts: &NativeTokenParts, -) -> Result<(), TokenValidationError> { - let expected = manager.sign_native_input(&parts.signing_input, parts.meta.salt)?; - if constant_time_compare(expected.as_bytes(), parts.signature.as_bytes()) { - Ok(()) - } else { - Err(TokenValidationError::new("Checksum mismatch.")) - } -} diff --git a/src/advanced_token_manager/token/validate/scope.rs b/src/advanced_token_manager/token/validate/scope.rs deleted file mode 100644 index 59bd6ae..0000000 --- a/src/advanced_token_manager/token/validate/scope.rs +++ /dev/null @@ -1,26 +0,0 @@ -use crate::advanced_token_manager::native::NativeTokenMeta; -use crate::advanced_token_manager::{TokenValidationError, ValidateTokenOptions}; - -pub(super) fn verify_scope( - meta: &NativeTokenMeta, - options: &ValidateTokenOptions, -) -> Result<(), TokenValidationError> { - require_match("issuer", meta.iss.as_deref(), options.issuer.as_deref())?; - require_match("audience", meta.aud.as_deref(), options.audience.as_deref()) -} - -fn require_match( - name: &str, - actual: Option<&str>, - expected: Option<&str>, -) -> Result<(), TokenValidationError> { - match (actual, expected) { - (_, None) => Ok(()), - (Some(actual), Some(expected)) if actual == expected => Ok(()), - (None, Some(_)) => Err(TokenValidationError::new(format!( - "Missing required {}.", - name - ))), - (Some(_), Some(_)) => Err(TokenValidationError::new(format!("{} mismatch.", name))), - } -} diff --git a/src/advanced_token_manager/token/validate/temporal.rs b/src/advanced_token_manager/token/validate/temporal.rs deleted file mode 100644 index ea3ab64..0000000 --- a/src/advanced_token_manager/token/validate/temporal.rs +++ /dev/null @@ -1,58 +0,0 @@ -use crate::advanced_token_manager::native::NativeTokenMeta; -use crate::advanced_token_manager::time::{current_timestamp, positive_seconds}; -use crate::advanced_token_manager::{TokenValidationError, ValidateTokenOptions}; - -pub(super) fn validate_temporal_metadata( - meta: &NativeTokenMeta, - options: &ValidateTokenOptions, -) -> Result<(), TokenValidationError> { - let now = current_timestamp(options.clock_timestamp)?; - let tolerance = normalize_tolerance(options.clock_tolerance)?; - validate_expiration(meta, now, tolerance)?; - validate_max_age(meta, options, now, tolerance) -} - -fn normalize_tolerance(value: Option) -> Result { - match value { - Some(value) if value.is_finite() && value >= 0.0 => Ok(value.floor() as i64), - Some(_) => Err(TokenValidationError::new( - "clockTolerance must be a non-negative number.", - )), - None => Ok(0), - } -} - -fn validate_expiration( - meta: &NativeTokenMeta, - now: i64, - tolerance: i64, -) -> Result<(), TokenValidationError> { - if let Some(exp) = meta.exp { - let exp = exp - .checked_add(tolerance) - .ok_or_else(|| TokenValidationError::new("Token temporal claim overflow."))?; - if now > exp { - return Err(TokenValidationError::new("Token expired.")); - } - } - Ok(()) -} - -fn validate_max_age( - meta: &NativeTokenMeta, - options: &ValidateTokenOptions, - now: i64, - tolerance: i64, -) -> Result<(), TokenValidationError> { - if let Some(max_age) = options.max_age { - let max_age = positive_seconds(max_age, "maxAge")?; - let age = now - .checked_sub(meta.iat) - .and_then(|value| value.checked_sub(tolerance)) - .ok_or_else(|| TokenValidationError::new("Token temporal claim overflow."))?; - if age > max_age { - return Err(TokenValidationError::new("Token exceeds maxAge.")); - } - } - Ok(()) -} diff --git a/src/base64url.rs b/src/base64url.rs new file mode 100644 index 0000000..0531464 --- /dev/null +++ b/src/base64url.rs @@ -0,0 +1,25 @@ +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; + +use crate::error::TokenError; + +pub(crate) fn encode(input: &[u8]) -> String { + URL_SAFE_NO_PAD.encode(input) +} + +pub(crate) fn decode(input: &str, name: &str) -> Result, TokenError> { + if input.is_empty() || !input.bytes().all(is_allowed) { + return Err(TokenError::new(format!("Invalid {} encoding.", name))); + } + let decoded = URL_SAFE_NO_PAD + .decode(input) + .map_err(|_| TokenError::new(format!("Malformed {} encoding.", name)))?; + if encode(&decoded) != input { + return Err(TokenError::new(format!("Non-canonical {} encoding.", name))); + } + Ok(decoded) +} + +fn is_allowed(byte: u8) -> bool { + matches!(byte, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_') +} diff --git a/src/crypto.rs b/src/crypto.rs new file mode 100644 index 0000000..339df30 --- /dev/null +++ b/src/crypto.rs @@ -0,0 +1,44 @@ +use hmac::{Hmac, Mac}; +use sha2::{Sha256, Sha512}; + +use crate::error::TokenError; +use crate::manager::Algorithm; + +pub(crate) fn sign( + algorithm: Algorithm, + secret: &[u8], + salt: &[u8], + input: &[u8], +) -> Result, TokenError> { + match algorithm { + Algorithm::Sha256 => hmac_sha256(secret, salt, input), + Algorithm::Sha512 => hmac_sha512(secret, salt, input), + } +} + +pub(crate) fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { + if left.len() != right.len() { + return false; + } + let mut diff = 0u8; + for (a, b) in left.iter().zip(right) { + diff |= a ^ b; + } + diff == 0 +} + +fn hmac_sha256(secret: &[u8], salt: &[u8], input: &[u8]) -> Result, TokenError> { + let mut mac = + Hmac::::new_from_slice(secret).map_err(|_| TokenError::new("Invalid HMAC key."))?; + mac.update(input); + mac.update(salt); + Ok(mac.finalize().into_bytes().to_vec()) +} + +fn hmac_sha512(secret: &[u8], salt: &[u8], input: &[u8]) -> Result, TokenError> { + let mut mac = + Hmac::::new_from_slice(secret).map_err(|_| TokenError::new("Invalid HMAC key."))?; + mac.update(input); + mac.update(salt); + Ok(mac.finalize().into_bytes().to_vec()) +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..6db7518 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,23 @@ +use std::error::Error; +use std::fmt::{self, Display}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TokenError { + message: String, +} + +impl TokenError { + pub(crate) fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl Display for TokenError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl Error for TokenError {} diff --git a/src/jwt.rs b/src/jwt.rs deleted file mode 100644 index 140580d..0000000 --- a/src/jwt.rs +++ /dev/null @@ -1,70 +0,0 @@ -mod base64url; -mod claims; -mod error; -mod signing; -mod time; -mod types; -mod verify; - -use serde::de::DeserializeOwned; -use serde_json::Value; - -pub use error::JwtError; -pub use types::{Audience, Issuer, JwtAlgorithm, JwtClaims, SignJwtOptions, VerifyJwtOptions}; - -use claims::{ - apply_audience, apply_expires_in, apply_issued_at, apply_issuer, apply_not_before, - apply_subject, -}; -use signing::create_signature; -use time::current_timestamp; -use verify::verify_token; - -pub fn sign_jwt(payload: &JwtClaims, options: &SignJwtOptions) -> Result { - if options.secret.trim().is_empty() { - return Err(JwtError::new( - "JWT: a non-empty secret is required to sign.", - )); - } - - let algorithm = options.algorithm.unwrap_or(JwtAlgorithm::HS256); - let header = claims::build_header(options, algorithm)?; - let timestamp = current_timestamp(options.clock_timestamp)?; - let mut claims = payload.clone(); - - apply_issued_at(&mut claims, options.issued_at, timestamp)?; - apply_expires_in(&mut claims, options.expires_in, timestamp)?; - apply_not_before(&mut claims, options.not_before, timestamp)?; - apply_audience(&mut claims, options.audience.clone())?; - apply_issuer(&mut claims, options.issuer.clone())?; - apply_subject(&mut claims, options.subject.clone())?; - - let encoded_header = encode_json_object(&header, "header")?; - let encoded_payload = encode_json_object(&claims, "payload")?; - let signing_input = format!("{}.{}", &encoded_header, &encoded_payload); - let signature = create_signature(algorithm, &options.secret, &signing_input)?; - - Ok(format!( - "{}.{}.{}", - encoded_header, encoded_payload, signature - )) -} - -pub fn verify_jwt(token: &str, options: &VerifyJwtOptions) -> Result { - verify_token(token, options) -} - -pub fn verify_jwt_as( - token: &str, - options: &VerifyJwtOptions, -) -> Result { - let claims = verify_jwt(token, options)?; - serde_json::from_value(Value::Object(claims)) - .map_err(|_| JwtError::new("JWT: payload could not be deserialized into target type.")) -} - -fn encode_json_object(claims: &JwtClaims, part: &str) -> Result { - let json = serde_json::to_vec(&Value::Object(claims.clone())) - .map_err(|_| JwtError::new(format!("JWT: failed to serialize {}.", part)))?; - Ok(base64url::encode(json)) -} diff --git a/src/jwt/base64url.rs b/src/jwt/base64url.rs deleted file mode 100644 index e5030b0..0000000 --- a/src/jwt/base64url.rs +++ /dev/null @@ -1,41 +0,0 @@ -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use base64::Engine; - -use super::JwtError; - -const BASE64URL_ALLOWED: &[u8] = - b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - -pub(super) fn encode>(data: T) -> String { - URL_SAFE_NO_PAD.encode(data) -} - -pub(super) fn decode(input: &str, part: &str) -> Result, JwtError> { - if !input.bytes().all(|byte| BASE64URL_ALLOWED.contains(&byte)) { - return Err(JwtError::new(format!( - "JWT: invalid base64url encoding in {}.", - part - ))); - } - - let decoded = URL_SAFE_NO_PAD - .decode(input) - .map_err(|_| JwtError::new(format!("JWT: malformed base64url segment in {}.", part)))?; - reject_non_canonical(input, &decoded, part)?; - Ok(decoded) -} - -pub(super) fn decoded_len_upper_bound(input: &str) -> usize { - input.len().saturating_mul(3).saturating_add(3) / 4 -} - -fn reject_non_canonical(input: &str, decoded: &[u8], part: &str) -> Result<(), JwtError> { - if encode(decoded) == input.trim_end_matches('=') { - Ok(()) - } else { - Err(JwtError::new(format!( - "JWT: malformed base64url segment in {}.", - part - ))) - } -} diff --git a/src/jwt/claims.rs b/src/jwt/claims.rs deleted file mode 100644 index 2413afc..0000000 --- a/src/jwt/claims.rs +++ /dev/null @@ -1,12 +0,0 @@ -mod apply; -mod audience; -mod enforce; -mod header; -mod string; -mod validate; - -pub(super) use apply::{apply_audience, apply_expires_in, apply_issued_at, apply_not_before}; -pub(super) use audience::validate_audience_value; -pub(super) use header::build_header; -pub(super) use string::{apply_issuer, apply_subject, normalize_string}; -pub(super) use validate::{numeric_claim, string_claim}; diff --git a/src/jwt/claims/apply.rs b/src/jwt/claims/apply.rs deleted file mode 100644 index 955a486..0000000 --- a/src/jwt/claims/apply.rs +++ /dev/null @@ -1,80 +0,0 @@ -use serde_json::Value; - -use super::audience::audience_value; -use super::enforce::enforce_claim; -use super::validate::ensure_numeric; -use crate::jwt::time::normalize_number; -use crate::jwt::{Audience, JwtClaims, JwtError}; - -pub(crate) fn apply_issued_at( - claims: &mut JwtClaims, - issued_at: Option, - timestamp: i64, -) -> Result<(), JwtError> { - if let Some(value) = issued_at { - enforce_claim(claims, "iat", Value::from(normalize_number(value, "iat")?)) - } else if let Some(existing) = claims.get("iat") { - ensure_numeric(existing, "iat") - } else { - claims.insert("iat".to_string(), Value::from(timestamp)); - Ok(()) - } -} - -pub(crate) fn apply_expires_in( - claims: &mut JwtClaims, - expires_in: Option, - timestamp: i64, -) -> Result<(), JwtError> { - if let Some(value) = expires_in { - let exp = checked_duration_claim(value, timestamp, "expiresIn")?; - enforce_claim(claims, "exp", Value::from(exp)) - } else if let Some(existing) = claims.get("exp") { - ensure_numeric(existing, "exp") - } else { - Ok(()) - } -} - -pub(crate) fn apply_not_before( - claims: &mut JwtClaims, - not_before: Option, - timestamp: i64, -) -> Result<(), JwtError> { - if let Some(value) = not_before { - if !value.is_finite() { - return Err(JwtError::new("JWT: notBefore must be a number of seconds.")); - } - enforce_claim(claims, "nbf", Value::from(timestamp + value.floor() as i64)) - } else if let Some(existing) = claims.get("nbf") { - ensure_numeric(existing, "nbf") - } else { - Ok(()) - } -} - -pub(crate) fn apply_audience( - claims: &mut JwtClaims, - audience: Option, -) -> Result<(), JwtError> { - if let Some(audience) = audience { - let audiences = audience.into_vec()?; - enforce_claim(claims, "aud", audience_value(audiences)) - } else if let Some(existing) = claims.get("aud") { - super::audience::validate_audience_value(existing).map(|_| ()) - } else { - Ok(()) - } -} - -fn checked_duration_claim(value: f64, timestamp: i64, name: &str) -> Result { - if !value.is_finite() || value <= 0.0 { - return Err(JwtError::new(format!( - "JWT: {} must be a positive number of seconds.", - name - ))); - } - timestamp - .checked_add(value.floor() as i64) - .ok_or_else(|| JwtError::new("JWT: temporal claim overflow.")) -} diff --git a/src/jwt/claims/audience.rs b/src/jwt/claims/audience.rs deleted file mode 100644 index b02d480..0000000 --- a/src/jwt/claims/audience.rs +++ /dev/null @@ -1,35 +0,0 @@ -use serde_json::Value; - -use super::validate::string_claim; -use crate::jwt::JwtError; - -pub(super) fn audience_value(audiences: Vec) -> Value { - if audiences.len() == 1 { - Value::String(audiences[0].clone()) - } else { - Value::Array(audiences.into_iter().map(Value::String).collect()) - } -} - -pub(crate) fn validate_audience_value(value: &Value) -> Result, JwtError> { - if value.is_array() { - normalize_audience_array(value) - } else { - Ok(vec![string_claim(value, "aud")?]) - } -} - -fn normalize_audience_array(value: &Value) -> Result, JwtError> { - let items = value - .as_array() - .ok_or_else(|| JwtError::new("JWT: audience must be an array of strings."))?; - if items.is_empty() { - return Err(JwtError::new("JWT: audience array must not be empty.")); - } - - let mut normalized = Vec::with_capacity(items.len()); - for item in items { - normalized.push(string_claim(item, "aud")?); - } - Ok(normalized) -} diff --git a/src/jwt/claims/enforce.rs b/src/jwt/claims/enforce.rs deleted file mode 100644 index d4cb8bc..0000000 --- a/src/jwt/claims/enforce.rs +++ /dev/null @@ -1,18 +0,0 @@ -use serde_json::Value; - -use crate::jwt::{JwtClaims, JwtError}; - -pub(super) fn enforce_claim( - claims: &mut JwtClaims, - key: &str, - value: Value, -) -> Result<(), JwtError> { - match claims.get(key) { - Some(existing) if *existing != value => Err(JwtError::claim_conflict(key)), - Some(_) => Ok(()), - None => { - claims.insert(key.to_string(), value); - Ok(()) - } - } -} diff --git a/src/jwt/claims/header.rs b/src/jwt/claims/header.rs deleted file mode 100644 index 6687acb..0000000 --- a/src/jwt/claims/header.rs +++ /dev/null @@ -1,30 +0,0 @@ -use serde_json::Value; - -use crate::jwt::{JwtAlgorithm, JwtClaims, JwtError, SignJwtOptions}; - -pub(crate) fn build_header( - options: &SignJwtOptions, - algorithm: JwtAlgorithm, -) -> Result { - let mut header = options.header.clone().unwrap_or_default(); - validate_header_override(&header, algorithm)?; - header.insert("alg".to_string(), Value::String(algorithm.to_string())); - header.insert("typ".to_string(), Value::String("JWT".to_string())); - Ok(header) -} - -fn validate_header_override(header: &JwtClaims, algorithm: JwtAlgorithm) -> Result<(), JwtError> { - if header - .get("alg") - .is_some_and(|alg| alg.as_str() != Some(&algorithm.to_string())) - { - return Err(JwtError::new("JWT: header algorithm mismatch.")); - } - if header - .get("typ") - .is_some_and(|typ| typ.as_str() != Some("JWT")) - { - return Err(JwtError::new("JWT: header type must be \"JWT\".")); - } - Ok(()) -} diff --git a/src/jwt/claims/string.rs b/src/jwt/claims/string.rs deleted file mode 100644 index bbaf824..0000000 --- a/src/jwt/claims/string.rs +++ /dev/null @@ -1,46 +0,0 @@ -use serde_json::Value; - -use super::enforce::enforce_claim; -use super::validate::ensure_string; -use crate::jwt::{JwtClaims, JwtError}; - -pub(crate) fn normalize_string(value: String, context: &str) -> Result { - let trimmed = value.trim().to_string(); - if trimmed.is_empty() { - return Err(JwtError::new(format!( - "JWT: {} must be a non-empty string.", - context - ))); - } - Ok(trimmed) -} - -pub(crate) fn apply_issuer(claims: &mut JwtClaims, issuer: Option) -> Result<(), JwtError> { - apply_string_claim(claims, "iss", issuer, "Issuer") -} - -pub(crate) fn apply_subject( - claims: &mut JwtClaims, - subject: Option, -) -> Result<(), JwtError> { - apply_string_claim(claims, "sub", subject, "Subject") -} - -fn apply_string_claim( - claims: &mut JwtClaims, - key: &str, - value: Option, - context: &str, -) -> Result<(), JwtError> { - if let Some(value) = value { - enforce_claim( - claims, - key, - Value::String(normalize_string(value, context)?), - ) - } else if let Some(existing) = claims.get(key) { - ensure_string(existing, key) - } else { - Ok(()) - } -} diff --git a/src/jwt/claims/validate.rs b/src/jwt/claims/validate.rs deleted file mode 100644 index b921efa..0000000 --- a/src/jwt/claims/validate.rs +++ /dev/null @@ -1,40 +0,0 @@ -use serde_json::Value; - -use crate::jwt::JwtError; - -pub(crate) fn numeric_claim(value: &Value, claim: &str) -> Result { - ensure_numeric(value, claim)?; - value - .as_i64() - .ok_or_else(|| JwtError::new(format!("JWT: Claim \"{}\" must be a finite number.", claim))) -} - -pub(crate) fn string_claim(value: &Value, claim: &str) -> Result { - ensure_string(value, claim)?; - value.as_str().map(ToString::to_string).ok_or_else(|| { - JwtError::new(format!( - "JWT: Claim \"{}\" must be a non-empty string.", - claim - )) - }) -} - -pub(crate) fn ensure_numeric(value: &Value, claim: &str) -> Result<(), JwtError> { - match value { - Value::Number(number) if number.as_i64().is_some() => Ok(()), - _ => Err(JwtError::new(format!( - "JWT: Claim \"{}\" must be a finite number.", - claim - ))), - } -} - -pub(crate) fn ensure_string(value: &Value, claim: &str) -> Result<(), JwtError> { - match value { - Value::String(text) if !text.is_empty() => Ok(()), - _ => Err(JwtError::new(format!( - "JWT: Claim \"{}\" must be a non-empty string.", - claim - ))), - } -} diff --git a/src/jwt/error.rs b/src/jwt/error.rs deleted file mode 100644 index 6f08d1e..0000000 --- a/src/jwt/error.rs +++ /dev/null @@ -1,22 +0,0 @@ -use thiserror::Error; - -#[derive(Debug, Error)] -#[error("{message}")] -pub struct JwtError { - message: String, -} - -impl JwtError { - pub fn new(message: impl Into) -> Self { - Self { - message: message.into(), - } - } - - pub(super) fn claim_conflict(claim: &str) -> Self { - Self::new(format!( - "JWT: claim \"{}\" already present with a different value.", - claim - )) - } -} diff --git a/src/jwt/signing.rs b/src/jwt/signing.rs deleted file mode 100644 index 0490b4a..0000000 --- a/src/jwt/signing.rs +++ /dev/null @@ -1,54 +0,0 @@ -mod hmac; - -use super::base64url; -use super::{JwtAlgorithm, JwtError}; - -pub(super) fn create_signature( - algorithm: JwtAlgorithm, - secret: &str, - signing_input: &str, -) -> Result { - let bytes = create_signature_buffer(algorithm, secret, signing_input)?; - Ok(base64url::encode(bytes)) -} - -pub(super) fn verify_signature( - algorithm: JwtAlgorithm, - secret: &str, - encoded_header: &str, - encoded_payload: &str, - encoded_signature: &str, -) -> Result<(), JwtError> { - let signing_input = format!("{}.{}", encoded_header, encoded_payload); - let provided_signature = base64url::decode(encoded_signature, "signature")?; - let expected_signature = create_signature_buffer(algorithm, secret, &signing_input)?; - constant_time_compare(&expected_signature, &provided_signature) -} - -fn create_signature_buffer( - algorithm: JwtAlgorithm, - secret: &str, - signing_input: &str, -) -> Result, JwtError> { - match algorithm { - JwtAlgorithm::HS256 => hmac::compute_hmac_sha256(secret, signing_input), - JwtAlgorithm::HS512 => hmac::compute_hmac_sha512(secret, signing_input), - } -} - -fn constant_time_compare(expected: &[u8], provided: &[u8]) -> Result<(), JwtError> { - if expected.len() != provided.len() { - return Err(JwtError::new("JWT: invalid signature.")); - } - - let mut diff: u8 = 0; - for (a, b) in expected.iter().zip(provided) { - diff |= a ^ b; - } - - if diff == 0 { - Ok(()) - } else { - Err(JwtError::new("JWT: invalid signature.")) - } -} diff --git a/src/jwt/signing/hmac.rs b/src/jwt/signing/hmac.rs deleted file mode 100644 index 099a5d1..0000000 --- a/src/jwt/signing/hmac.rs +++ /dev/null @@ -1,21 +0,0 @@ -use hmac::{Hmac, Mac}; -use sha2::{Sha256, Sha512}; - -use crate::jwt::JwtError; - -type HmacSha256 = Hmac; -type HmacSha512 = Hmac; - -pub(super) fn compute_hmac_sha256(secret: &str, signing_input: &str) -> Result, JwtError> { - let mut mac = HmacSha256::new_from_slice(secret.as_bytes()) - .map_err(|_| JwtError::new("JWT: failed to create HMAC instance."))?; - mac.update(signing_input.as_bytes()); - Ok(mac.finalize().into_bytes().to_vec()) -} - -pub(super) fn compute_hmac_sha512(secret: &str, signing_input: &str) -> Result, JwtError> { - let mut mac = HmacSha512::new_from_slice(secret.as_bytes()) - .map_err(|_| JwtError::new("JWT: failed to create HMAC instance."))?; - mac.update(signing_input.as_bytes()); - Ok(mac.finalize().into_bytes().to_vec()) -} diff --git a/src/jwt/time.rs b/src/jwt/time.rs deleted file mode 100644 index 3ee7efb..0000000 --- a/src/jwt/time.rs +++ /dev/null @@ -1,29 +0,0 @@ -use std::time::{SystemTime, UNIX_EPOCH}; - -use super::JwtError; - -pub(super) fn current_timestamp(clock: Option) -> Result { - if let Some(value) = clock { - if !value.is_finite() { - return Err(JwtError::new( - "JWT: clockTimestamp must be a finite number.", - )); - } - return Ok(value.floor() as i64); - } - - let duration = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|_| JwtError::new("JWT: system time before UNIX_EPOCH."))?; - Ok(duration.as_secs() as i64) -} - -pub(super) fn normalize_number(value: f64, claim: &str) -> Result { - if !value.is_finite() { - return Err(JwtError::new(format!( - "JWT: Claim \"{}\" must be a finite number.", - claim - ))); - } - Ok(value.floor() as i64) -} diff --git a/src/jwt/types.rs b/src/jwt/types.rs deleted file mode 100644 index 4a14ab3..0000000 --- a/src/jwt/types.rs +++ /dev/null @@ -1,115 +0,0 @@ -use std::fmt::{self, Display}; -use std::str::FromStr; - -use serde_json::{Map, Value}; - -use super::claims::normalize_string; -use super::JwtError; - -pub type JwtClaims = Map; - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum JwtAlgorithm { - HS256, - HS512, -} - -impl Display for JwtAlgorithm { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - JwtAlgorithm::HS256 => write!(f, "HS256"), - JwtAlgorithm::HS512 => write!(f, "HS512"), - } - } -} - -impl FromStr for JwtAlgorithm { - type Err = JwtError; - - fn from_str(s: &str) -> Result { - match s { - "HS256" => Ok(JwtAlgorithm::HS256), - "HS512" => Ok(JwtAlgorithm::HS512), - other => Err(JwtError::new(format!( - "JWT: unsupported algorithm: {}.", - other - ))), - } - } -} - -#[derive(Clone, Debug, Default)] -pub struct SignJwtOptions { - pub secret: String, - pub algorithm: Option, - pub header: Option>, - pub expires_in: Option, - pub not_before: Option, - pub audience: Option, - pub issuer: Option, - pub subject: Option, - pub issued_at: Option, - pub clock_timestamp: Option, -} - -#[derive(Clone, Debug, Default)] -pub struct VerifyJwtOptions { - pub secret: String, - pub algorithms: Option>, - pub clock_tolerance: Option, - pub audience: Option, - pub issuer: Option, - pub subject: Option, - pub max_age: Option, - pub clock_timestamp: Option, - pub max_payload_size: Option, - pub allowed_claims: Option>, -} - -#[derive(Clone, Debug)] -pub enum Audience { - Single(String), - Multiple(Vec), -} - -impl Audience { - pub(super) fn into_vec(self) -> Result, JwtError> { - match self { - Audience::Single(value) => Ok(vec![normalize_string(value, "Audience")?]), - Audience::Multiple(values) => normalize_non_empty_strings(values, "Audience"), - } - } -} - -#[derive(Clone, Debug)] -pub enum Issuer { - Single(String), - Multiple(Vec), -} - -impl Issuer { - pub(super) fn into_vec(self) -> Result, JwtError> { - match self { - Issuer::Single(value) => Ok(vec![normalize_string(value, "Issuer")?]), - Issuer::Multiple(values) => normalize_non_empty_strings(values, "Issuer"), - } - } -} - -fn normalize_non_empty_strings( - values: Vec, - context: &str, -) -> Result, JwtError> { - if values.is_empty() { - return Err(JwtError::new(format!( - "JWT: {} array must not be empty.", - context.to_lowercase() - ))); - } - - let mut normalized = Vec::with_capacity(values.len()); - for value in values { - normalized.push(normalize_string(value, context)?); - } - Ok(normalized) -} diff --git a/src/jwt/verify.rs b/src/jwt/verify.rs deleted file mode 100644 index f7faebc..0000000 --- a/src/jwt/verify.rs +++ /dev/null @@ -1,64 +0,0 @@ -mod claims; -mod header; -mod payload; -mod temporal; - -use super::signing::verify_signature; -use super::{JwtClaims, JwtError, VerifyJwtOptions}; - -pub(super) fn verify_token(token: &str, options: &VerifyJwtOptions) -> Result { - validate_verify_inputs(token, options)?; - let segments = split_token(token)?; - let algorithm = header::decode_algorithm(segments.header, options)?; - let payload = payload::decode_payload(segments.payload, options)?; - - claims::enforce_allowed_claims(&payload, options)?; - verify_signature( - algorithm, - &options.secret, - segments.header, - segments.payload, - segments.signature, - )?; - claims::validate_registered_claims(&payload, options)?; - Ok(payload) -} - -pub(super) fn decode_json_object(bytes: &[u8], part: &str) -> Result { - let value: serde_json::Value = serde_json::from_slice(bytes) - .map_err(|_| JwtError::new(format!("JWT: invalid {} JSON.", part)))?; - value - .as_object() - .cloned() - .ok_or_else(|| JwtError::new(format!("JWT: {} must be a JSON object.", part))) -} - -struct TokenSegments<'a> { - header: &'a str, - payload: &'a str, - signature: &'a str, -} - -fn validate_verify_inputs(token: &str, options: &VerifyJwtOptions) -> Result<(), JwtError> { - if token.trim().is_empty() { - return Err(JwtError::new("JWT: token must be a non-empty string.")); - } - if options.secret.trim().is_empty() { - return Err(JwtError::new( - "JWT: a non-empty secret is required to verify.", - )); - } - Ok(()) -} - -fn split_token(token: &str) -> Result, JwtError> { - let parts: Vec<&str> = token.split('.').collect(); - if parts.len() != 3 || parts.iter().any(|part| part.is_empty()) { - return Err(JwtError::new("JWT: invalid token structure.")); - } - Ok(TokenSegments { - header: parts[0], - payload: parts[1], - signature: parts[2], - }) -} diff --git a/src/jwt/verify/claims.rs b/src/jwt/verify/claims.rs deleted file mode 100644 index 6542b47..0000000 --- a/src/jwt/verify/claims.rs +++ /dev/null @@ -1,93 +0,0 @@ -mod allowed; - -use crate::jwt::claims::{string_claim, validate_audience_value}; -use crate::jwt::verify::temporal::validate_temporal_claims; -use crate::jwt::{JwtClaims, JwtError, VerifyJwtOptions}; - -const STANDARD_CLAIMS: [&str; 6] = ["iss", "sub", "aud", "exp", "nbf", "iat"]; - -pub(super) fn enforce_allowed_claims( - payload: &JwtClaims, - options: &VerifyJwtOptions, -) -> Result<(), JwtError> { - if let Some(allowed_claims) = &options.allowed_claims { - let allowed = allowed::normalize_allowed_claims(allowed_claims)?; - for key in payload.keys() { - if !STANDARD_CLAIMS.contains(&key.as_str()) && !allowed.contains(key) { - return Err(JwtError::new(format!( - "JWT: claim \"{}\" is not allowed.", - key - ))); - } - } - } - Ok(()) -} - -pub(super) fn validate_registered_claims( - payload: &JwtClaims, - options: &VerifyJwtOptions, -) -> Result<(), JwtError> { - validate_temporal_claims(payload, options)?; - validate_audience(payload, options)?; - validate_issuer(payload, options)?; - validate_subject(payload, options) -} - -fn validate_audience(payload: &JwtClaims, options: &VerifyJwtOptions) -> Result<(), JwtError> { - if payload.get("aud").is_none() && options.audience.is_none() { - return Ok(()); - } - - let token_audience = payload - .get("aud") - .ok_or_else(|| JwtError::new("JWT: missing required audience claim.")) - .and_then(validate_audience_value)?; - if let Some(audience) = options.audience.clone() { - let expected = audience.into_vec()?; - if !expected - .iter() - .any(|value| token_audience.iter().any(|aud| aud == value)) - { - return Err(JwtError::new("JWT: audience mismatch.")); - } - } - Ok(()) -} - -fn validate_issuer(payload: &JwtClaims, options: &VerifyJwtOptions) -> Result<(), JwtError> { - if payload.get("iss").is_none() && options.issuer.is_none() { - return Ok(()); - } - - let issuer = payload - .get("iss") - .ok_or_else(|| JwtError::new("JWT: missing required issuer claim.")) - .and_then(|value| string_claim(value, "iss"))?; - if let Some(issuer_option) = options.issuer.clone() { - let allowed = issuer_option.into_vec()?; - if !allowed.contains(&issuer) { - return Err(JwtError::new("JWT: issuer mismatch.")); - } - } - Ok(()) -} - -fn validate_subject(payload: &JwtClaims, options: &VerifyJwtOptions) -> Result<(), JwtError> { - if payload.get("sub").is_none() && options.subject.is_none() { - return Ok(()); - } - - let subject = payload - .get("sub") - .ok_or_else(|| JwtError::new("JWT: missing required subject claim.")) - .and_then(|value| string_claim(value, "sub"))?; - if options - .subject - .as_ref() - .is_some_and(|expected| subject != expected.trim()) - { - return Err(JwtError::new("JWT: subject mismatch.")); - } - Ok(()) -} diff --git a/src/jwt/verify/claims/allowed.rs b/src/jwt/verify/claims/allowed.rs deleted file mode 100644 index fbd3b98..0000000 --- a/src/jwt/verify/claims/allowed.rs +++ /dev/null @@ -1,19 +0,0 @@ -use std::collections::HashSet; - -use crate::jwt::JwtError; - -pub(super) fn normalize_allowed_claims( - allowed_claims: &[String], -) -> Result, JwtError> { - let mut normalized = HashSet::new(); - for claim in allowed_claims { - let trimmed = claim.trim(); - if trimmed.is_empty() { - return Err(JwtError::new( - "JWT: allowedClaims must be an array of non-empty strings.", - )); - } - normalized.insert(trimmed.to_string()); - } - Ok(normalized) -} diff --git a/src/jwt/verify/header.rs b/src/jwt/verify/header.rs deleted file mode 100644 index bc835ed..0000000 --- a/src/jwt/verify/header.rs +++ /dev/null @@ -1,61 +0,0 @@ -use std::str::FromStr; - -use serde_json::Value; - -use super::decode_json_object; -use crate::jwt::base64url; -use crate::jwt::{JwtAlgorithm, JwtClaims, JwtError, VerifyJwtOptions}; - -pub(super) fn decode_algorithm( - encoded_header: &str, - options: &VerifyJwtOptions, -) -> Result { - let header_bytes = base64url::decode(encoded_header, "header")?; - let header = decode_json_object(&header_bytes, "header")?; - validate_header_type(&header)?; - let algorithm = parse_algorithm(&header)?; - enforce_allowed_algorithm(algorithm, options)?; - Ok(algorithm) -} - -fn parse_algorithm(header: &JwtClaims) -> Result { - let alg_value = header - .get("alg") - .and_then(Value::as_str) - .ok_or_else(|| JwtError::new("JWT: missing algorithm."))?; - if alg_value.eq_ignore_ascii_case("none") { - return Err(JwtError::new( - "JWT: unsigned tokens (alg \"none\") are not allowed.", - )); - } - JwtAlgorithm::from_str(alg_value) -} - -fn validate_header_type(header: &JwtClaims) -> Result<(), JwtError> { - if let Some(typ_value) = header.get("typ") { - let typ = typ_value - .as_str() - .ok_or_else(|| JwtError::new("JWT: header type must be a string."))?; - if typ != "JWT" { - return Err(JwtError::new("JWT: header type must be \"JWT\".")); - } - } - Ok(()) -} - -fn enforce_allowed_algorithm( - algorithm: JwtAlgorithm, - options: &VerifyJwtOptions, -) -> Result<(), JwtError> { - if options - .algorithms - .as_ref() - .is_some_and(|allowed| !allowed.contains(&algorithm)) - { - return Err(JwtError::new(format!( - "JWT: algorithm {} is not allowed.", - algorithm - ))); - } - Ok(()) -} diff --git a/src/jwt/verify/payload.rs b/src/jwt/verify/payload.rs deleted file mode 100644 index e32f34b..0000000 --- a/src/jwt/verify/payload.rs +++ /dev/null @@ -1,39 +0,0 @@ -use super::decode_json_object; -use crate::jwt::base64url; -use crate::jwt::{JwtClaims, JwtError, VerifyJwtOptions}; - -pub(super) fn decode_payload( - encoded_payload: &str, - options: &VerifyJwtOptions, -) -> Result { - reject_large_payload_before_decode(encoded_payload, options)?; - let payload_bytes = base64url::decode(encoded_payload, "payload")?; - reject_large_payload_after_decode(payload_bytes.len(), options)?; - decode_json_object(&payload_bytes, "payload") -} - -fn reject_large_payload_before_decode( - encoded_payload: &str, - options: &VerifyJwtOptions, -) -> Result<(), JwtError> { - if options - .max_payload_size - .is_some_and(|max_size| base64url::decoded_len_upper_bound(encoded_payload) > max_size) - { - return Err(JwtError::new("JWT: payload exceeds maxPayloadSize.")); - } - Ok(()) -} - -fn reject_large_payload_after_decode( - payload_size: usize, - options: &VerifyJwtOptions, -) -> Result<(), JwtError> { - if options - .max_payload_size - .is_some_and(|max_size| payload_size > max_size) - { - return Err(JwtError::new("JWT: payload exceeds maxPayloadSize.")); - } - Ok(()) -} diff --git a/src/jwt/verify/temporal.rs b/src/jwt/verify/temporal.rs deleted file mode 100644 index ddd0e4b..0000000 --- a/src/jwt/verify/temporal.rs +++ /dev/null @@ -1,63 +0,0 @@ -mod max_age; - -use crate::jwt::claims::numeric_claim; -use crate::jwt::time::current_timestamp; -use crate::jwt::{JwtClaims, JwtError, VerifyJwtOptions}; - -pub(super) fn validate_temporal_claims( - payload: &JwtClaims, - options: &VerifyJwtOptions, -) -> Result<(), JwtError> { - let now = current_timestamp(options.clock_timestamp)?; - let tolerance = normalize_tolerance(options.clock_tolerance)?; - validate_exp(payload, now, tolerance)?; - validate_nbf(payload, now, tolerance)?; - validate_iat(payload, now, tolerance)?; - max_age::validate_max_age(payload, options, now, tolerance) -} - -fn normalize_tolerance(clock_tolerance: Option) -> Result { - match clock_tolerance { - Some(value) if value.is_finite() && value >= 0.0 => Ok(value.floor() as i64), - Some(_) => Err(JwtError::new( - "JWT: clockTolerance must be a non-negative number.", - )), - None => Ok(0), - } -} - -fn validate_exp(payload: &JwtClaims, now: i64, tolerance: i64) -> Result<(), JwtError> { - if let Some(exp) = payload.get("exp") { - let expires_with_tolerance = numeric_claim(exp, "exp")? - .checked_add(tolerance) - .ok_or_else(|| JwtError::new("JWT: temporal claim overflow."))?; - if now > expires_with_tolerance { - return Err(JwtError::new("JWT: token expired.")); - } - } - Ok(()) -} - -fn validate_nbf(payload: &JwtClaims, now: i64, tolerance: i64) -> Result<(), JwtError> { - if let Some(nbf) = payload.get("nbf") { - let now_with_tolerance = now - .checked_add(tolerance) - .ok_or_else(|| JwtError::new("JWT: temporal claim overflow."))?; - if now_with_tolerance < numeric_claim(nbf, "nbf")? { - return Err(JwtError::new("JWT: token not active yet.")); - } - } - Ok(()) -} - -fn validate_iat(payload: &JwtClaims, now: i64, tolerance: i64) -> Result<(), JwtError> { - if let Some(iat) = payload.get("iat") { - let issued_without_tolerance = numeric_claim(iat, "iat")? - .checked_sub(tolerance) - .ok_or_else(|| JwtError::new("JWT: temporal claim overflow."))?; - if issued_without_tolerance > now { - return Err(JwtError::new("JWT: token used before issued.")); - } - } - Ok(()) -} diff --git a/src/jwt/verify/temporal/max_age.rs b/src/jwt/verify/temporal/max_age.rs deleted file mode 100644 index 60cd346..0000000 --- a/src/jwt/verify/temporal/max_age.rs +++ /dev/null @@ -1,35 +0,0 @@ -use serde_json::Value; - -use crate::jwt::{JwtClaims, JwtError, VerifyJwtOptions}; - -pub(super) fn validate_max_age( - payload: &JwtClaims, - options: &VerifyJwtOptions, - now: i64, - tolerance: i64, -) -> Result<(), JwtError> { - if let Some(max_age) = options.max_age { - let max_age = normalize_max_age(max_age)?; - let iat = payload - .get("iat") - .and_then(Value::as_i64) - .ok_or_else(|| JwtError::new("JWT: cannot apply maxAge without an \"iat\" claim."))?; - let age = now - .checked_sub(iat) - .and_then(|value| value.checked_sub(tolerance)) - .ok_or_else(|| JwtError::new("JWT: temporal claim overflow."))?; - if age > max_age { - return Err(JwtError::new("JWT: token exceeds maxAge.")); - } - } - Ok(()) -} - -fn normalize_max_age(max_age: f64) -> Result { - if !max_age.is_finite() || max_age <= 0.0 { - return Err(JwtError::new( - "JWT: maxAge must be a positive number of seconds.", - )); - } - Ok(max_age.floor() as i64) -} diff --git a/src/lib.rs b/src/lib.rs index 7b95c9f..4bcc7ea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,37 +1,14 @@ -pub mod advanced_token_manager; -pub mod jwt; - -pub use advanced_token_manager::{ - AdvancedTokenError, AdvancedTokenManager, AdvancedTokenManagerLogger, - AdvancedTokenManagerOptions, Algorithm, GenerateTokenOptions, ManagerConfig, - ManagerSignJwtOptions, ManagerVerifyJwtOptions, TokenValidationError, ValidateTokenOptions, -}; - -pub use jwt::{ - sign_jwt, verify_jwt, verify_jwt_as, Audience, Issuer, JwtAlgorithm, JwtClaims, JwtError, - SignJwtOptions, VerifyJwtOptions, -}; - -pub const LIBRARY_VERSION: &str = "0.2.0"; - -#[cfg(test)] -mod docs { - use super::*; - - #[test] - fn example_usage() { - let mut manager = AdvancedTokenManager::new( - Some("my-very-secure-key".to_string()), - Some(vec!["salt1".to_string(), "salt2".to_string()]), - Some(Algorithm::Sha256), - true, - true, - Some(AdvancedTokenManagerOptions::default()), - ) - .unwrap(); - - let token = manager.generate_token("my-data", None).unwrap(); - let validated = manager.validate_token(&token).unwrap(); - assert_eq!(validated, Some("my-data".to_string())); - } -} +mod base64url; +mod crypto; +mod error; +mod manager; +mod meta; +mod options; +mod token; +mod validate; + +pub use error::TokenError; +pub use manager::{AdvancedTokenManager, Algorithm}; +pub use options::{GenerateTokenOptions, ValidateTokenOptions, VerifiedBytes, VerifiedToken}; + +pub const LIBRARY_VERSION: &str = "0.3.0"; diff --git a/src/manager.rs b/src/manager.rs new file mode 100644 index 0000000..a6c045d --- /dev/null +++ b/src/manager.rs @@ -0,0 +1,97 @@ +use rand::Rng; + +use crate::crypto; +use crate::error::TokenError; + +const MIN_SECRET_LENGTH: usize = 16; +const MIN_SALT_COUNT: usize = 1; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Algorithm { + Sha256, + Sha512, +} + +impl Algorithm { + pub(crate) fn name(self) -> &'static str { + match self { + Algorithm::Sha256 => "HS256", + Algorithm::Sha512 => "HS512", + } + } +} + +pub struct AdvancedTokenManager { + pub(crate) secret: Vec, + pub(crate) salts: Vec>, + pub(crate) algorithm: Algorithm, + last_salt_index: Option, +} + +impl AdvancedTokenManager { + pub fn new(secret: &[u8], salts: &[&[u8]], algorithm: Algorithm) -> Result { + validate_secret(secret)?; + validate_salts(salts)?; + Ok(Self { + secret: secret.to_vec(), + salts: salts.iter().map(|salt| salt.to_vec()).collect(), + algorithm, + last_salt_index: None, + }) + } + + pub(crate) fn select_salt(&mut self, requested: Option) -> Result { + match requested { + Some(index) => self.validate_salt_index(index).map(|_| index), + None => Ok(self.random_salt_index()), + } + } + + pub(crate) fn validate_salt_index(&self, index: usize) -> Result<(), TokenError> { + if index < self.salts.len() { + Ok(()) + } else { + Err(TokenError::new("Invalid salt index.")) + } + } + + pub(crate) fn sign( + &self, + signing_input: &[u8], + salt_index: usize, + ) -> Result, TokenError> { + crypto::sign( + self.algorithm, + &self.secret, + &self.salts[salt_index], + signing_input, + ) + } + + fn random_salt_index(&mut self) -> usize { + let mut rng = rand::thread_rng(); + loop { + let index = rng.gen_range(0..self.salts.len()); + if Some(index) != self.last_salt_index || self.salts.len() == 1 { + self.last_salt_index = Some(index); + return index; + } + } + } +} + +fn validate_secret(secret: &[u8]) -> Result<(), TokenError> { + if secret.len() < MIN_SECRET_LENGTH { + Err(TokenError::new("Secret must be at least 16 bytes.")) + } else { + Ok(()) + } +} + +fn validate_salts(salts: &[&[u8]]) -> Result<(), TokenError> { + if salts.len() < MIN_SALT_COUNT || salts.iter().any(|salt| salt.is_empty()) { + Err(TokenError::new("At least one non-empty salt is required.")) + } else { + Ok(()) + } +} diff --git a/src/meta.rs b/src/meta.rs new file mode 100644 index 0000000..21aaadf --- /dev/null +++ b/src/meta.rs @@ -0,0 +1,29 @@ +mod decode; +mod encode; +mod parse; + +pub(crate) use decode::decode_optional; +pub(crate) use encode::encode_optional; +pub(crate) use parse::{parse_optional_u64, parse_u64, parse_usize, required}; + +use crate::error::TokenError; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Meta { + pub algorithm: String, + pub salt_index: usize, + pub issued_at: u64, + pub expires_at: Option, + pub issuer: Option, + pub audience: Option, +} + +impl Meta { + pub(crate) fn encode(&self) -> Result { + encode::meta(self) + } + + pub(crate) fn decode(encoded: &str) -> Result { + decode::meta(encoded) + } +} diff --git a/src/meta/decode.rs b/src/meta/decode.rs new file mode 100644 index 0000000..12cd9aa --- /dev/null +++ b/src/meta/decode.rs @@ -0,0 +1,48 @@ +use crate::base64url; +use crate::error::TokenError; +use crate::meta::Meta; + +pub(crate) fn meta(encoded: &str) -> Result { + let bytes = base64url::decode(encoded, "metadata")?; + let text = + std::str::from_utf8(&bytes).map_err(|_| TokenError::new("Metadata is not UTF-8."))?; + parse_meta(text) +} + +pub(crate) fn decode_optional(value: &str, name: &str) -> Result, TokenError> { + if value.is_empty() { + return Ok(None); + } + let bytes = base64url::decode(value, name)?; + String::from_utf8(bytes) + .map(Some) + .map_err(|_| TokenError::new(format!("{} is not UTF-8.", name))) +} + +fn parse_meta(text: &str) -> Result { + let mut fields = text.split('|'); + let meta = Meta { + algorithm: super::required(next(&mut fields)?, "algorithm")?.to_string(), + salt_index: super::parse_usize(next(&mut fields)?, "salt")?, + issued_at: super::parse_u64(next(&mut fields)?, "iat")?, + expires_at: super::parse_optional_u64(next(&mut fields)?, "exp")?, + issuer: super::decode_optional(next(&mut fields)?, "issuer")?, + audience: super::decode_optional(next(&mut fields)?, "audience")?, + }; + reject_extra(fields)?; + Ok(meta) +} + +fn next<'a>(fields: &mut impl Iterator) -> Result<&'a str, TokenError> { + fields + .next() + .ok_or_else(|| TokenError::new("Invalid metadata field count.")) +} + +fn reject_extra<'a>(mut fields: impl Iterator) -> Result<(), TokenError> { + if fields.next().is_some() { + Err(TokenError::new("Invalid metadata field count.")) + } else { + Ok(()) + } +} diff --git a/src/meta/encode.rs b/src/meta/encode.rs new file mode 100644 index 0000000..9a5be67 --- /dev/null +++ b/src/meta/encode.rs @@ -0,0 +1,31 @@ +use crate::base64url; +use crate::error::TokenError; +use crate::meta::Meta; + +pub(crate) fn meta(meta: &Meta) -> Result { + let mut text = String::with_capacity(96); + push_field(&mut text, &meta.algorithm); + push_field(&mut text, &meta.salt_index.to_string()); + push_field(&mut text, &meta.issued_at.to_string()); + push_field( + &mut text, + &meta + .expires_at + .map(|value| value.to_string()) + .unwrap_or_default(), + ); + push_field(&mut text, &super::encode_optional(meta.issuer.as_deref())); + text.push_str(&super::encode_optional(meta.audience.as_deref())); + Ok(base64url::encode(text.as_bytes())) +} + +pub(crate) fn encode_optional(value: Option<&str>) -> String { + value + .map(|text| base64url::encode(text.as_bytes())) + .unwrap_or_default() +} + +fn push_field(output: &mut String, value: &str) { + output.push_str(value); + output.push('|'); +} diff --git a/src/meta/parse.rs b/src/meta/parse.rs new file mode 100644 index 0000000..a8df91b --- /dev/null +++ b/src/meta/parse.rs @@ -0,0 +1,29 @@ +use crate::error::TokenError; + +pub(crate) fn required<'a>(value: &'a str, name: &str) -> Result<&'a str, TokenError> { + if value.is_empty() { + Err(TokenError::new(format!("Missing {}.", name))) + } else { + Ok(value) + } +} + +pub(crate) fn parse_usize(value: &str, name: &str) -> Result { + required(value, name)? + .parse() + .map_err(|_| TokenError::new(format!("Invalid {}.", name))) +} + +pub(crate) fn parse_u64(value: &str, name: &str) -> Result { + required(value, name)? + .parse() + .map_err(|_| TokenError::new(format!("Invalid {}.", name))) +} + +pub(crate) fn parse_optional_u64(value: &str, name: &str) -> Result, TokenError> { + if value.is_empty() { + Ok(None) + } else { + parse_u64(value, name).map(Some) + } +} diff --git a/src/options.rs b/src/options.rs new file mode 100644 index 0000000..5f01715 --- /dev/null +++ b/src/options.rs @@ -0,0 +1,53 @@ +#[derive(Clone, Debug, Default)] +pub struct GenerateTokenOptions<'a> { + pub salt_index: Option, + pub expires_in: Option, + pub issuer: Option<&'a str>, + pub audience: Option<&'a str>, + pub issued_at: Option, +} + +#[derive(Clone, Debug, Default)] +pub struct ValidateTokenOptions<'a> { + pub max_age: Option, + pub issuer: Option<&'a str>, + pub audience: Option<&'a str>, + pub clock_tolerance: Option, + pub clock_timestamp: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VerifiedToken { + pub payload: String, + pub issued_at: u64, + pub expires_at: Option, + pub issuer: Option, + pub audience: Option, + pub salt_index: usize, + pub algorithm: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VerifiedBytes { + pub payload: Vec, + pub issued_at: u64, + pub expires_at: Option, + pub issuer: Option, + pub audience: Option, + pub salt_index: usize, + pub algorithm: String, +} + +impl VerifiedBytes { + pub(crate) fn new(payload: Vec, meta: crate::meta::Meta) -> Self { + Self { + payload, + issued_at: meta.issued_at, + expires_at: meta.expires_at, + issuer: meta.issuer, + audience: meta.audience, + salt_index: meta.salt_index, + algorithm: meta.algorithm, + } + } +} diff --git a/src/token.rs b/src/token.rs new file mode 100644 index 0000000..5fadac9 --- /dev/null +++ b/src/token.rs @@ -0,0 +1,82 @@ +mod build; +mod parts; + +use crate::base64url; +use crate::error::TokenError; +use crate::manager::AdvancedTokenManager; +use crate::options::{GenerateTokenOptions, ValidateTokenOptions, VerifiedBytes, VerifiedToken}; +use crate::validate; + +pub(crate) const VERSION: &str = "htr1"; + +impl AdvancedTokenManager { + pub fn generate_token( + &mut self, + payload: &str, + options: GenerateTokenOptions<'_>, + ) -> Result { + self.generate_token_bytes(payload.as_bytes(), options) + } + + pub fn generate_token_bytes( + &mut self, + payload: &[u8], + options: GenerateTokenOptions<'_>, + ) -> Result { + build::token(self, payload, &options) + } + + pub fn validate_token( + &self, + token: &str, + options: ValidateTokenOptions<'_>, + ) -> Result { + let verified = self.validate_token_bytes(token, options)?; + let VerifiedBytes { + payload, + issued_at, + expires_at, + issuer, + audience, + salt_index, + algorithm, + } = verified; + let payload = + String::from_utf8(payload).map_err(|_| TokenError::new("Payload is not UTF-8."))?; + Ok(VerifiedToken { + payload, + issued_at, + expires_at, + issuer, + audience, + salt_index, + algorithm, + }) + } + + pub fn validate_payload( + &self, + token: &str, + options: ValidateTokenOptions<'_>, + ) -> Result { + self.validate_token(token, options) + .map(|verified| verified.payload) + } + + pub fn validate_token_bytes( + &self, + token: &str, + options: ValidateTokenOptions<'_>, + ) -> Result { + let parts = parts::split(token)?; + let payload = base64url::decode(parts.payload, "payload")?; + let meta = crate::meta::Meta::decode(parts.meta)?; + validate::metadata(self, &meta, &options)?; + let expected = self.sign( + parts::signing_input(parts.payload, parts.meta).as_bytes(), + meta.salt_index, + )?; + validate::signature(&expected, parts.signature)?; + Ok(VerifiedBytes::new(payload, meta)) + } +} diff --git a/src/token/build.rs b/src/token/build.rs new file mode 100644 index 0000000..4ee803e --- /dev/null +++ b/src/token/build.rs @@ -0,0 +1,66 @@ +use crate::base64url; +use crate::error::TokenError; +use crate::manager::AdvancedTokenManager; +use crate::meta::Meta; +use crate::options::GenerateTokenOptions; +use crate::token::parts::signing_input; +use crate::token::VERSION; + +pub(crate) fn token( + manager: &mut AdvancedTokenManager, + payload: &[u8], + options: &GenerateTokenOptions<'_>, +) -> Result { + let salt_index = manager.select_salt(options.salt_index)?; + let meta = meta(manager, salt_index, options)?; + let encoded_payload = base64url::encode(payload); + let encoded_meta = meta.encode()?; + let signature = manager.sign( + signing_input(&encoded_payload, &encoded_meta).as_bytes(), + salt_index, + )?; + assemble( + &encoded_payload, + &encoded_meta, + &base64url::encode(&signature), + ) +} + +fn meta( + manager: &AdvancedTokenManager, + salt_index: usize, + options: &GenerateTokenOptions<'_>, +) -> Result { + let issued_at = options.issued_at.map_or_else(crate::validate::now, Ok)?; + Ok(Meta { + algorithm: manager.algorithm.name().to_string(), + salt_index, + issued_at, + expires_at: expiration(issued_at, options.expires_in)?, + issuer: options.issuer.map(str::to_string), + audience: options.audience.map(str::to_string), + }) +} + +fn assemble(payload: &str, meta: &str, signature: &str) -> Result { + let mut token = + String::with_capacity(VERSION.len() + payload.len() + meta.len() + signature.len() + 3); + token.push_str(VERSION); + token.push('.'); + token.push_str(payload); + token.push('.'); + token.push_str(meta); + token.push('.'); + token.push_str(signature); + Ok(token) +} + +fn expiration(issued_at: u64, expires_in: Option) -> Result, TokenError> { + expires_in + .map(|seconds| { + issued_at + .checked_add(seconds) + .ok_or_else(|| TokenError::new("Token expiration overflow.")) + }) + .transpose() +} diff --git a/src/token/parts.rs b/src/token/parts.rs new file mode 100644 index 0000000..8a5970f --- /dev/null +++ b/src/token/parts.rs @@ -0,0 +1,51 @@ +use crate::error::TokenError; +use crate::token::VERSION; + +pub(crate) struct Parts<'a> { + pub payload: &'a str, + pub meta: &'a str, + pub signature: &'a str, +} + +pub(crate) fn split(token: &str) -> Result, TokenError> { + let mut fields = token.split('.'); + let version = fields.next().unwrap_or_default(); + let payload = fields.next().unwrap_or_default(); + let meta = fields.next().unwrap_or_default(); + let signature = fields.next().unwrap_or_default(); + reject_bad_shape(version, payload, meta, signature, fields.next())?; + Ok(Parts { + payload, + meta, + signature, + }) +} + +pub(crate) fn signing_input(payload: &str, meta: &str) -> String { + let mut input = String::with_capacity(VERSION.len() + payload.len() + meta.len() + 2); + input.push_str(VERSION); + input.push('.'); + input.push_str(payload); + input.push('.'); + input.push_str(meta); + input +} + +fn reject_bad_shape( + version: &str, + payload: &str, + meta: &str, + signature: &str, + extra: Option<&str>, +) -> Result<(), TokenError> { + if version != VERSION + || payload.is_empty() + || meta.is_empty() + || signature.is_empty() + || extra.is_some() + { + Err(TokenError::new("Invalid token structure.")) + } else { + Ok(()) + } +} diff --git a/src/validate.rs b/src/validate.rs new file mode 100644 index 0000000..e229690 --- /dev/null +++ b/src/validate.rs @@ -0,0 +1,30 @@ +mod scope; +mod signature; +mod time; + +use crate::error::TokenError; +use crate::manager::AdvancedTokenManager; +use crate::meta::Meta; +use crate::options::ValidateTokenOptions; + +pub(crate) use signature::signature; +pub(crate) use time::now; + +pub(crate) fn metadata( + manager: &AdvancedTokenManager, + meta: &Meta, + options: &ValidateTokenOptions<'_>, +) -> Result<(), TokenError> { + manager.validate_salt_index(meta.salt_index)?; + validate_algorithm(manager, meta)?; + time::validate_time(meta, options)?; + scope::validate_scope(meta, options) +} + +fn validate_algorithm(manager: &AdvancedTokenManager, meta: &Meta) -> Result<(), TokenError> { + if meta.algorithm == manager.algorithm.name() { + Ok(()) + } else { + Err(TokenError::new("Algorithm mismatch.")) + } +} diff --git a/src/validate/scope.rs b/src/validate/scope.rs new file mode 100644 index 0000000..72eef9b --- /dev/null +++ b/src/validate/scope.rs @@ -0,0 +1,24 @@ +use crate::error::TokenError; +use crate::meta::Meta; +use crate::options::ValidateTokenOptions; + +pub(crate) fn validate_scope( + meta: &Meta, + options: &ValidateTokenOptions<'_>, +) -> Result<(), TokenError> { + match_required("issuer", meta.issuer.as_deref(), options.issuer)?; + match_required("audience", meta.audience.as_deref(), options.audience) +} + +fn match_required( + name: &str, + actual: Option<&str>, + expected: Option<&str>, +) -> Result<(), TokenError> { + match (actual, expected) { + (_, None) => Ok(()), + (Some(actual), Some(expected)) if actual == expected => Ok(()), + (None, Some(_)) => Err(TokenError::new(format!("Missing {}.", name))), + (Some(_), Some(_)) => Err(TokenError::new(format!("{} mismatch.", name))), + } +} diff --git a/src/validate/signature.rs b/src/validate/signature.rs new file mode 100644 index 0000000..dc87b95 --- /dev/null +++ b/src/validate/signature.rs @@ -0,0 +1,12 @@ +use crate::base64url; +use crate::crypto; +use crate::error::TokenError; + +pub(crate) fn signature(expected: &[u8], encoded: &str) -> Result<(), TokenError> { + let provided = base64url::decode(encoded, "signature")?; + if crypto::constant_time_eq(expected, &provided) { + Ok(()) + } else { + Err(TokenError::new("Invalid token signature.")) + } +} diff --git a/src/validate/time.rs b/src/validate/time.rs new file mode 100644 index 0000000..d453be3 --- /dev/null +++ b/src/validate/time.rs @@ -0,0 +1,55 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::error::TokenError; +use crate::meta::Meta; +use crate::options::ValidateTokenOptions; + +pub(crate) fn now() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .map_err(|_| TokenError::new("System time is before UNIX_EPOCH.")) +} + +pub(crate) fn validate_time( + meta: &Meta, + options: &ValidateTokenOptions<'_>, +) -> Result<(), TokenError> { + let now = match options.clock_timestamp { + Some(value) => value, + None => now()?, + }; + let tolerance = options.clock_tolerance.unwrap_or(0); + validate_expiration(meta, now, tolerance)?; + validate_max_age(meta, now, tolerance, options.max_age) +} + +fn validate_expiration(meta: &Meta, now: u64, tolerance: u64) -> Result<(), TokenError> { + if let Some(exp) = meta.expires_at { + let exp = exp + .checked_add(tolerance) + .ok_or_else(|| TokenError::new("Token expiration overflow."))?; + if now > exp { + return Err(TokenError::new("Token expired.")); + } + } + Ok(()) +} + +fn validate_max_age( + meta: &Meta, + now: u64, + tolerance: u64, + max_age: Option, +) -> Result<(), TokenError> { + if let Some(max_age) = max_age { + let age = now + .checked_sub(meta.issued_at) + .and_then(|value| value.checked_sub(tolerance)) + .ok_or_else(|| TokenError::new("Token timestamp is outside valid range."))?; + if age > max_age { + return Err(TokenError::new("Token exceeds maxAge.")); + } + } + Ok(()) +} diff --git a/tests/advanced_token_manager_test.rs b/tests/advanced_token_manager_test.rs deleted file mode 100644 index 2f82eb4..0000000 --- a/tests/advanced_token_manager_test.rs +++ /dev/null @@ -1,290 +0,0 @@ -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; -use hash_token_rust::advanced_token_manager::{ - AdvancedTokenManager, AdvancedTokenManagerOptions, Algorithm, GenerateTokenOptions, - ManagerSignJwtOptions, ManagerVerifyJwtOptions, ValidateTokenOptions, -}; -use hash_token_rust::jwt::{Audience, JwtAlgorithm, JwtClaims}; - -fn manager() -> AdvancedTokenManager { - AdvancedTokenManager::new( - Some("averysecuresecretvalue".to_string()), - Some(vec![ - "alpha".to_string(), - "beta".to_string(), - "gamma".to_string(), - ]), - Some(Algorithm::Sha256), - true, - true, - Some(AdvancedTokenManagerOptions::default()), - ) - .unwrap() -} - -#[test] -fn generate_and_validate_token() { - let mut manager = manager(); - let token = manager.generate_token("payload-data", None).unwrap(); - let result = manager.validate_token(&token).unwrap(); - assert_eq!(result, Some("payload-data".to_string())); -} - -#[test] -fn validate_token_lenient_failure_returns_none() { - let mut manager = manager(); - let token = manager.generate_token("payload", None).unwrap(); - let tampered = format!("{}x", token); - assert!(manager.validate_token(&tampered).unwrap().is_none()); - assert!(manager.validate_token_lenient(&tampered).is_none()); -} - -#[test] -fn validate_token_throws_when_configured() { - let mut manager = AdvancedTokenManager::new( - Some("averysecuresecretvalue".to_string()), - Some(vec!["salt-a".into(), "salt-b".into()]), - Some(Algorithm::Sha256), - true, - true, - Some(AdvancedTokenManagerOptions { - throw_on_validation_failure: Some(true), - ..Default::default() - }), - ) - .unwrap(); - - let token = manager.generate_token("payload", None).unwrap(); - let broken = format!("{}x", token); - let err = manager.validate_token(&broken).unwrap_err(); - let message = err.to_string(); - assert!( - message.contains("Checksum mismatch") || message.contains("Invalid base64 token"), - "unexpected error message: {}", - message - ); -} - -#[test] -fn generate_token_with_explicit_salt_index() { - let mut manager = manager(); - let token = manager.generate_token("payload", Some(1)).unwrap(); - let parts: Vec<&str> = token.split('.').collect(); - assert_eq!(parts[0], "htr1"); - let meta = URL_SAFE_NO_PAD.decode(parts[2]).unwrap(); - let meta: serde_json::Value = serde_json::from_slice(&meta).unwrap(); - assert_eq!(meta["salt"], 1); -} - -#[test] -fn native_token_enforces_expiration() { - let mut manager = manager(); - let token = manager - .generate_token_with_options( - "payload", - Some(GenerateTokenOptions { - expires_in: Some(10.0), - issued_at: Some(1000.0), - ..Default::default() - }), - ) - .unwrap(); - - let err = manager - .validate_token_with_options( - &token, - Some(ValidateTokenOptions { - throw_on_failure: Some(true), - clock_timestamp: Some(1011.0), - ..Default::default() - }), - ) - .unwrap_err(); - assert!(err.to_string().contains("Token expired")); -} - -#[test] -fn native_token_enforces_issuer_and_audience() { - let mut manager = manager(); - let token = manager - .generate_token_with_options( - "payload", - Some(GenerateTokenOptions { - issuer: Some("bin-1".into()), - audience: Some("bin-2".into()), - issued_at: Some(1000.0), - ..Default::default() - }), - ) - .unwrap(); - - let valid = manager - .validate_token_with_options( - &token, - Some(ValidateTokenOptions { - issuer: Some("bin-1".into()), - audience: Some("bin-2".into()), - clock_timestamp: Some(1001.0), - ..Default::default() - }), - ) - .unwrap(); - assert_eq!(valid, Some("payload".to_string())); - - let err = manager - .validate_token_with_options( - &token, - Some(ValidateTokenOptions { - throw_on_failure: Some(true), - audience: Some("bin-3".into()), - clock_timestamp: Some(1001.0), - ..Default::default() - }), - ) - .unwrap_err(); - assert!(err.to_string().contains("audience mismatch")); -} - -#[test] -fn manager_generates_and_validates_jwt() { - let manager = manager(); - let mut claims: JwtClaims = JwtClaims::new(); - claims.insert("sub".to_string(), "user-123".into()); - claims.insert("role".to_string(), "admin".into()); - - let token = manager - .generate_jwt(&claims, Some(ManagerSignJwtOptions::default())) - .unwrap(); - - let verified: JwtClaims = manager - .validate_jwt::(&token, Some(ManagerVerifyJwtOptions::default())) - .unwrap(); - - assert_eq!(verified.get("sub").unwrap(), "user-123"); - assert_eq!(verified.get("role").unwrap(), "admin"); -} - -#[test] -fn manager_applies_default_jwt_algorithms() { - let options = AdvancedTokenManagerOptions { - jwt_default_algorithms: Some(vec![JwtAlgorithm::HS256]), - ..Default::default() - }; - let manager = AdvancedTokenManager::new( - Some("averysecuresecretvalue".to_string()), - Some(vec!["salt-a".into(), "salt-b".into()]), - Some(Algorithm::Sha256), - true, - true, - Some(options.clone()), - ) - .unwrap(); - - let mut claims: JwtClaims = JwtClaims::new(); - claims.insert("sub".to_string(), "user-123".into()); - let token = manager.generate_jwt(&claims, None).unwrap(); - - let verify_options = ManagerVerifyJwtOptions { - algorithms: Some(vec![JwtAlgorithm::HS256]), - ..Default::default() - }; - manager - .validate_jwt::(&token, Some(verify_options)) - .unwrap(); -} - -#[test] -fn validate_token_with_options_no_throw() { - let mut manager = manager(); - let token = manager.generate_token("payload", None).unwrap(); - let tampered = format!("{}x", token); - let result = manager - .validate_token_with_options( - &tampered, - Some(ValidateTokenOptions { - throw_on_failure: Some(false), - ..Default::default() - }), - ) - .unwrap(); - assert!(result.is_none()); -} - -#[test] -fn configure_audience_for_jwt_verification() { - let manager = manager(); - let mut claims: JwtClaims = JwtClaims::new(); - claims.insert("sub".to_string(), "user-123".into()); - claims.insert("aud".to_string(), "service-a".into()); - - let token = manager.generate_jwt(&claims, None).unwrap(); - - let verify_options = ManagerVerifyJwtOptions { - audience: Some(Audience::Single("service-a".into())), - ..Default::default() - }; - let validated: JwtClaims = manager.validate_jwt(&token, Some(verify_options)).unwrap(); - assert_eq!(validated.get("sub").unwrap(), "user-123"); -} - -#[test] -fn manager_validate_jwt_rejects_wrong_secret() { - let manager = manager(); - let mut claims: JwtClaims = JwtClaims::new(); - claims.insert("sub".to_string(), "user-123".into()); - let token = manager.generate_jwt(&claims, None).unwrap(); - - let verify_options = ManagerVerifyJwtOptions { - secret: Some("different-secret-value".into()), - ..Default::default() - }; - let err = manager - .validate_jwt::(&token, Some(verify_options)) - .unwrap_err(); - assert!(err.to_string().contains("invalid signature")); -} - -#[test] -fn manager_validate_jwt_enforces_max_payload_size() { - let options = AdvancedTokenManagerOptions { - jwt_max_payload_size: Some(32), - ..Default::default() - }; - let manager = AdvancedTokenManager::new( - Some("averysecuresecretvalue".to_string()), - Some(vec!["salt-a".into(), "salt-b".into()]), - Some(Algorithm::Sha256), - true, - true, - Some(options), - ) - .unwrap(); - - let mut claims: JwtClaims = JwtClaims::new(); - claims.insert("sub".to_string(), "user-123".into()); - claims.insert("large".to_string(), "x".repeat(128).into()); - let token = manager.generate_jwt(&claims, None).unwrap(); - - let err = manager.validate_jwt::(&token, None).unwrap_err(); - assert!(err.to_string().contains("maxPayloadSize")); -} - -#[test] -fn manager_validate_jwt_rejects_disallowed_algorithm() { - let manager = manager(); - let mut claims: JwtClaims = JwtClaims::new(); - claims.insert("sub".to_string(), "user-123".into()); - let sign_options = ManagerSignJwtOptions { - algorithm: Some(JwtAlgorithm::HS512), - ..Default::default() - }; - let token = manager.generate_jwt(&claims, Some(sign_options)).unwrap(); - - let verify_options = ManagerVerifyJwtOptions { - algorithms: Some(vec![JwtAlgorithm::HS256]), - ..Default::default() - }; - let err = manager - .validate_jwt::(&token, Some(verify_options)) - .unwrap_err(); - assert!(err.to_string().contains("is not allowed")); -} diff --git a/tests/jwt_test.rs b/tests/jwt_test.rs deleted file mode 100644 index 5cff914..0000000 --- a/tests/jwt_test.rs +++ /dev/null @@ -1,434 +0,0 @@ -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use base64::Engine; -use hash_token_rust::jwt::{ - sign_jwt, verify_jwt, verify_jwt_as, Audience, Issuer, JwtAlgorithm, JwtClaims, SignJwtOptions, - VerifyJwtOptions, -}; -use hmac::{Hmac, Mac}; -use serde_json::{json, Value}; -use sha2::Sha256; - -fn encode_json(value: Value) -> String { - URL_SAFE_NO_PAD.encode(serde_json::to_vec(&value).unwrap()) -} - -fn signed_hs256_token(payload: Value) -> String { - let header = encode_json(json!({"alg": "HS256", "typ": "JWT"})); - let payload = encode_json(payload); - let signing_input = format!("{}.{}", header, payload); - let mut mac = Hmac::::new_from_slice("secret-value".as_bytes()).unwrap(); - mac.update(signing_input.as_bytes()); - let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); - format!("{}.{}", signing_input, signature) -} - -#[test] -fn sign_and_verify_jwt() { - let mut payload = JwtClaims::new(); - payload.insert("sub".to_string(), "user-123".into()); - payload.insert("aud".to_string(), "service".into()); - - let token = sign_jwt( - &payload, - &SignJwtOptions { - secret: "secret-value".to_string(), - algorithm: Some(JwtAlgorithm::HS512), - ..Default::default() - }, - ) - .unwrap(); - - let verified = verify_jwt( - &token, - &VerifyJwtOptions { - secret: "secret-value".to_string(), - algorithms: Some(vec![JwtAlgorithm::HS512]), - ..Default::default() - }, - ) - .unwrap(); - - assert_eq!(verified.get("sub").unwrap(), "user-123"); -} - -#[test] -fn sign_and_verify_hs256() { - let mut payload = JwtClaims::new(); - payload.insert("sub".to_string(), "user-123".into()); - - let token = sign_jwt( - &payload, - &SignJwtOptions { - secret: "secret-value".to_string(), - algorithm: Some(JwtAlgorithm::HS256), - ..Default::default() - }, - ) - .unwrap(); - - let verified = verify_jwt( - &token, - &VerifyJwtOptions { - secret: "secret-value".to_string(), - algorithms: Some(vec![JwtAlgorithm::HS256]), - ..Default::default() - }, - ) - .unwrap(); - - assert_eq!(verified.get("sub").unwrap(), "user-123"); -} - -#[test] -fn verify_rejects_invalid_signature() { - let mut payload = JwtClaims::new(); - payload.insert("sub".to_string(), "user-123".into()); - let token = sign_jwt( - &payload, - &SignJwtOptions { - secret: "secret-value".to_string(), - ..Default::default() - }, - ) - .unwrap(); - - let tampered = format!("{}tampered", token); - let err = verify_jwt( - &tampered, - &VerifyJwtOptions { - secret: "secret-value".to_string(), - ..Default::default() - }, - ) - .unwrap_err(); - let message = err.to_string(); - assert!( - message.contains("invalid signature") - || message.contains("invalid token structure") - || message.contains("malformed base64url"), - "unexpected error message: {}", - message - ); - - let err = verify_jwt( - &token, - &VerifyJwtOptions { - secret: "wrong-secret".to_string(), - ..Default::default() - }, - ) - .unwrap_err(); - let message = err.to_string(); - assert!( - message.contains("invalid signature") || message.contains("malformed base64url"), - "unexpected error message: {}", - message - ); -} - -#[test] -fn verify_enforces_audience_and_issuer() { - let mut payload = JwtClaims::new(); - payload.insert("sub".to_string(), "user-123".into()); - payload.insert("aud".to_string(), "service-a".into()); - payload.insert("iss".to_string(), "issuer-a".into()); - - let token = sign_jwt( - &payload, - &SignJwtOptions { - secret: "secret-value".to_string(), - ..Default::default() - }, - ) - .unwrap(); - - let err = verify_jwt( - &token, - &VerifyJwtOptions { - secret: "secret-value".to_string(), - audience: Some(Audience::Single("other".into())), - ..Default::default() - }, - ) - .unwrap_err(); - assert!(err.to_string().contains("audience mismatch")); - - let err = verify_jwt( - &token, - &VerifyJwtOptions { - secret: "secret-value".to_string(), - issuer: Some(Issuer::Single("other".into())), - ..Default::default() - }, - ) - .unwrap_err(); - assert!(err.to_string().contains("issuer mismatch")); -} - -#[test] -fn verify_rejects_alg_none() { - let token = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjMifQ.c2ln"; - let err = verify_jwt( - token, - &VerifyJwtOptions { - secret: "secret-value".to_string(), - ..Default::default() - }, - ) - .unwrap_err(); - assert!(err.to_string().contains("alg \"none\"")); -} - -#[test] -fn verify_rejects_unexpected_algorithm() { - let token = format!( - "{}.{}.{}", - encode_json(json!({"alg": "RS256", "typ": "JWT"})), - encode_json(json!({"sub": "123"})), - "c2ln" - ); - - let err = verify_jwt( - &token, - &VerifyJwtOptions { - secret: "secret-value".to_string(), - ..Default::default() - }, - ) - .unwrap_err(); - assert!(err.to_string().contains("unsupported algorithm")); - - let lower_alg_token = format!( - "{}.{}.{}", - encode_json(json!({"alg": "hs256", "typ": "JWT"})), - encode_json(json!({"sub": "123"})), - "c2ln" - ); - let err = verify_jwt( - &lower_alg_token, - &VerifyJwtOptions { - secret: "secret-value".to_string(), - ..Default::default() - }, - ) - .unwrap_err(); - assert!(err.to_string().contains("unsupported algorithm")); -} - -#[test] -fn verify_rejects_truncated_signature() { - let mut payload = JwtClaims::new(); - payload.insert("sub".to_string(), "user-123".into()); - let token = sign_jwt( - &payload, - &SignJwtOptions { - secret: "secret-value".to_string(), - ..Default::default() - }, - ) - .unwrap(); - let mut parts: Vec<&str> = token.split('.').collect(); - parts[2] = &parts[2][..parts[2].len() - 4]; - let truncated = parts.join("."); - - let err = verify_jwt( - &truncated, - &VerifyJwtOptions { - secret: "secret-value".to_string(), - ..Default::default() - }, - ) - .unwrap_err(); - let message = err.to_string(); - assert!( - message.contains("invalid signature") || message.contains("malformed base64url"), - "unexpected error message: {}", - message - ); -} - -#[test] -fn verify_enforces_temporal_claims() { - let mut expired_payload = JwtClaims::new(); - expired_payload.insert("sub".to_string(), "user-123".into()); - expired_payload.insert("exp".to_string(), 900.into()); - - let token = sign_jwt( - &expired_payload, - &SignJwtOptions { - secret: "secret-value".to_string(), - clock_timestamp: Some(1000.0), - ..Default::default() - }, - ) - .unwrap(); - - let expired = verify_jwt( - &token, - &VerifyJwtOptions { - secret: "secret-value".to_string(), - clock_timestamp: Some(1001.0), - ..Default::default() - }, - ) - .unwrap_err(); - assert!(expired.to_string().contains("token expired")); - - let mut nbf_payload = JwtClaims::new(); - nbf_payload.insert("sub".to_string(), "user-123".into()); - nbf_payload.insert("nbf".to_string(), 1100.into()); - let token = sign_jwt( - &nbf_payload, - &SignJwtOptions { - secret: "secret-value".to_string(), - clock_timestamp: Some(1000.0), - ..Default::default() - }, - ) - .unwrap(); - let not_active = verify_jwt( - &token, - &VerifyJwtOptions { - secret: "secret-value".to_string(), - clock_timestamp: Some(950.0), - clock_tolerance: Some(100.0), - ..Default::default() - }, - ) - .unwrap_err(); - assert!(not_active.to_string().contains("token not active yet")); - - let mut iat_payload = JwtClaims::new(); - iat_payload.insert("sub".to_string(), "user-123".into()); - iat_payload.insert("iat".to_string(), 1100.into()); - let token = sign_jwt( - &iat_payload, - &SignJwtOptions { - secret: "secret-value".to_string(), - clock_timestamp: Some(1000.0), - ..Default::default() - }, - ) - .unwrap(); - let used_before_issued = verify_jwt( - &token, - &VerifyJwtOptions { - secret: "secret-value".to_string(), - clock_timestamp: Some(999.0), - clock_tolerance: Some(100.0), - ..Default::default() - }, - ) - .unwrap_err(); - assert!(used_before_issued - .to_string() - .contains("token used before issued")); -} - -#[test] -fn verify_rejects_invalid_claim_shapes() { - let token = signed_hs256_token(json!({"sub": ""})); - - let err = verify_jwt( - &token, - &VerifyJwtOptions { - secret: "secret-value".to_string(), - subject: Some("user-123".into()), - ..Default::default() - }, - ) - .unwrap_err(); - assert!(err.to_string().contains("non-empty string")); -} - -#[test] -fn verify_rejects_payload_over_max_size() { - let mut payload = JwtClaims::new(); - payload.insert("sub".to_string(), "user-123".into()); - payload.insert("large".to_string(), "x".repeat(128).into()); - let token = sign_jwt( - &payload, - &SignJwtOptions { - secret: "secret-value".to_string(), - ..Default::default() - }, - ) - .unwrap(); - - let err = verify_jwt( - &token, - &VerifyJwtOptions { - secret: "secret-value".to_string(), - max_payload_size: Some(32), - ..Default::default() - }, - ) - .unwrap_err(); - assert!(err.to_string().contains("maxPayloadSize")); -} - -#[test] -fn verify_rejects_disallowed_claims() { - let mut payload = JwtClaims::new(); - payload.insert("sub".to_string(), "user-123".into()); - payload.insert("custom".to_string(), 42.into()); - - let token = sign_jwt( - &payload, - &SignJwtOptions { - secret: "secret-value".to_string(), - ..Default::default() - }, - ) - .unwrap(); - - let err = verify_jwt( - &token, - &VerifyJwtOptions { - secret: "secret-value".to_string(), - allowed_claims: Some(vec!["other".into()]), - ..Default::default() - }, - ) - .unwrap_err(); - assert!(err.to_string().contains("is not allowed")); -} - -#[test] -fn deserialize_verified_payload() { - #[derive(serde::Deserialize, Debug, PartialEq)] - struct Claims { - sub: String, - role: String, - } - - let mut payload = JwtClaims::new(); - payload.insert("sub".to_string(), "user-123".into()); - payload.insert("role".to_string(), "admin".into()); - - let token = sign_jwt( - &payload, - &SignJwtOptions { - secret: "secret-value".to_string(), - ..Default::default() - }, - ) - .unwrap(); - - let claims: Claims = verify_jwt_as( - &token, - &VerifyJwtOptions { - secret: "secret-value".to_string(), - ..Default::default() - }, - ) - .unwrap(); - - assert_eq!( - claims, - Claims { - sub: "user-123".into(), - role: "admin".into() - } - ); -} diff --git a/tests/native_token_test.rs b/tests/native_token_test.rs new file mode 100644 index 0000000..cb67eac --- /dev/null +++ b/tests/native_token_test.rs @@ -0,0 +1,405 @@ +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use hash_token_rust::{ + AdvancedTokenManager, Algorithm, GenerateTokenOptions, TokenError, ValidateTokenOptions, +}; + +fn manager() -> AdvancedTokenManager { + AdvancedTokenManager::new( + b"very-secure-secret", + &[b"salt-a".as_slice(), b"salt-b".as_slice()], + Algorithm::Sha256, + ) + .unwrap() +} + +#[test] +fn signs_and_validates_payload() { + let mut manager = manager(); + let token = manager + .generate_token( + "user-id=123", + GenerateTokenOptions { + issued_at: Some(1000), + salt_index: Some(0), + ..Default::default() + }, + ) + .unwrap(); + + let verified = manager + .validate_token( + &token, + ValidateTokenOptions { + clock_timestamp: Some(1001), + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!(verified.payload, "user-id=123"); + assert_eq!(verified.issued_at, 1000); + assert_eq!(verified.salt_index, 0); +} + +#[test] +fn token_has_native_shape_and_salt_index() { + let mut manager = manager(); + let token = manager + .generate_token( + "payload", + GenerateTokenOptions { + salt_index: Some(1), + issued_at: Some(1000), + ..Default::default() + }, + ) + .unwrap(); + let parts: Vec<&str> = token.split('.').collect(); + assert_eq!(parts[0], "htr1"); + + let meta = String::from_utf8(URL_SAFE_NO_PAD.decode(parts[2]).unwrap()).unwrap(); + assert!(meta.contains("HS256|1|1000|")); +} + +#[test] +fn rejects_tampered_payload() { + let mut manager = manager(); + let token = manager + .generate_token("payload", GenerateTokenOptions::default()) + .unwrap(); + let mut parts: Vec<&str> = token.split('.').collect(); + parts[1] = "dGFtcGVyZWQ"; + let tampered = parts.join("."); + + let err = manager + .validate_token(&tampered, ValidateTokenOptions::default()) + .unwrap_err(); + assert!(err.to_string().contains("signature")); +} + +#[test] +fn enforces_expiration_and_max_age() { + let mut manager = manager(); + let token = manager + .generate_token( + "payload", + GenerateTokenOptions { + issued_at: Some(1000), + expires_in: Some(10), + ..Default::default() + }, + ) + .unwrap(); + + let expired = manager + .validate_token( + &token, + ValidateTokenOptions { + clock_timestamp: Some(1011), + ..Default::default() + }, + ) + .unwrap_err(); + assert!(expired.to_string().contains("expired")); + + let too_old = manager + .validate_token( + &token, + ValidateTokenOptions { + clock_timestamp: Some(1006), + max_age: Some(5), + ..Default::default() + }, + ) + .unwrap_err(); + assert!(too_old.to_string().contains("maxAge")); +} + +#[test] +fn enforces_issuer_and_audience() { + let mut manager = manager(); + let token = manager + .generate_token( + "payload", + GenerateTokenOptions { + issuer: Some("bin-a"), + audience: Some("bin-b"), + issued_at: Some(1000), + ..Default::default() + }, + ) + .unwrap(); + + manager + .validate_token( + &token, + ValidateTokenOptions { + issuer: Some("bin-a"), + audience: Some("bin-b"), + clock_timestamp: Some(1001), + ..Default::default() + }, + ) + .unwrap(); + + let err = manager + .validate_token( + &token, + ValidateTokenOptions { + audience: Some("bin-c"), + clock_timestamp: Some(1001), + ..Default::default() + }, + ) + .unwrap_err(); + assert!(err.to_string().contains("audience mismatch")); +} + +#[test] +fn validates_payload_helper() { + let mut manager = manager(); + let token = manager + .generate_token( + "payload", + GenerateTokenOptions { + issued_at: Some(1000), + ..Default::default() + }, + ) + .unwrap(); + let payload = manager + .validate_payload( + &token, + ValidateTokenOptions { + clock_timestamp: Some(1000), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(payload, "payload"); +} + +#[test] +fn signs_and_validates_binary_payload() { + let mut manager = manager(); + let bytes = [0, 159, 146, 150, 255]; + let token = manager + .generate_token_bytes( + &bytes, + GenerateTokenOptions { + issued_at: Some(1000), + ..Default::default() + }, + ) + .unwrap(); + + let verified = manager + .validate_token_bytes( + &token, + ValidateTokenOptions { + clock_timestamp: Some(1001), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(verified.payload, bytes); + + let err = manager + .validate_token( + &token, + ValidateTokenOptions { + clock_timestamp: Some(1001), + ..Default::default() + }, + ) + .unwrap_err(); + assert!(err.to_string().contains("UTF-8")); +} + +#[test] +fn rejects_bad_structure_and_base64() { + let manager = manager(); + + assert!(manager + .validate_token("htr1.a.b.c.extra", ValidateTokenOptions::default()) + .unwrap_err() + .to_string() + .contains("structure")); + assert!(manager + .validate_token("htr1..b.c", ValidateTokenOptions::default()) + .unwrap_err() + .to_string() + .contains("structure")); + assert!(manager + .validate_token("htr1.abc=.b.c", ValidateTokenOptions::default()) + .unwrap_err() + .to_string() + .contains("payload")); +} + +#[test] +fn rejects_invalid_metadata() { + let manager = manager(); + let payload = URL_SAFE_NO_PAD.encode("payload"); + let bad_meta = URL_SAFE_NO_PAD.encode("HS256|0|1000"); + let token = format!("htr1.{}.{}.c2ln", payload, bad_meta); + + assert!(manager + .validate_token(&token, ValidateTokenOptions::default()) + .unwrap_err() + .to_string() + .contains("metadata")); +} + +#[test] +fn rejects_wrong_secret_and_salt() { + let mut manager = manager(); + let token = manager + .generate_token( + "payload", + GenerateTokenOptions { + issued_at: Some(1000), + salt_index: Some(1), + ..Default::default() + }, + ) + .unwrap(); + + let other_secret = AdvancedTokenManager::new( + b"other-secure-secret", + &[b"salt-a".as_slice(), b"salt-b".as_slice()], + Algorithm::Sha256, + ) + .unwrap(); + assert!(other_secret + .validate_token( + &token, + ValidateTokenOptions { + clock_timestamp: Some(1000), + ..Default::default() + }, + ) + .unwrap_err() + .to_string() + .contains("signature")); + + let missing_salt = AdvancedTokenManager::new( + b"very-secure-secret", + &[b"salt-a".as_slice()], + Algorithm::Sha256, + ) + .unwrap(); + assert!(missing_salt + .validate_token( + &token, + ValidateTokenOptions { + clock_timestamp: Some(1000), + ..Default::default() + }, + ) + .unwrap_err() + .to_string() + .contains("salt")); +} + +#[test] +fn rejects_tampered_metadata_and_signature() { + let mut manager = manager(); + let token = manager + .generate_token( + "payload", + GenerateTokenOptions { + issued_at: Some(1000), + ..Default::default() + }, + ) + .unwrap(); + let mut parts: Vec<&str> = token.split('.').collect(); + let meta = String::from_utf8(URL_SAFE_NO_PAD.decode(parts[2]).unwrap()).unwrap(); + let changed = meta.replacen("HS256", "HS512", 1); + let encoded_changed = URL_SAFE_NO_PAD.encode(changed); + parts[2] = &encoded_changed; + let tampered_meta = parts.join("."); + + assert!(manager + .validate_token( + &tampered_meta, + ValidateTokenOptions { + clock_timestamp: Some(1000), + ..Default::default() + }, + ) + .unwrap_err() + .to_string() + .contains("Algorithm")); + + let truncated = token.rsplit_once('.').unwrap().0.to_string() + ".abc"; + assert!(manager + .validate_token(&truncated, ValidateTokenOptions::default()) + .unwrap_err() + .to_string() + .contains("signature")); +} + +#[test] +fn rejects_missing_scope_when_required() { + let mut manager = manager(); + let token = manager + .generate_token( + "payload", + GenerateTokenOptions { + issued_at: Some(1000), + ..Default::default() + }, + ) + .unwrap(); + + assert!(manager + .validate_token( + &token, + ValidateTokenOptions { + issuer: Some("bin-a"), + clock_timestamp: Some(1000), + ..Default::default() + }, + ) + .unwrap_err() + .to_string() + .contains("Missing issuer")); +} + +#[test] +fn clock_tolerance_allows_expiration_drift() { + let mut manager = manager(); + let token = manager + .generate_token( + "payload", + GenerateTokenOptions { + issued_at: Some(1000), + expires_in: Some(10), + ..Default::default() + }, + ) + .unwrap(); + + manager + .validate_token( + &token, + ValidateTokenOptions { + clock_timestamp: Some(1012), + clock_tolerance: Some(2), + ..Default::default() + }, + ) + .unwrap(); +} + +#[test] +fn rejects_short_secret() { + let err: TokenError = + match AdvancedTokenManager::new(b"short", &[b"salt".as_slice()], Algorithm::Sha256) { + Ok(_) => panic!("short secret should be rejected"), + Err(error) => error, + }; + assert!(err.to_string().contains("Secret")); +} From 7ca2b7f0fc58afa968b3684cc0e4a7a0a96a1ab3 Mon Sep 17 00:00:00 2001 From: dnettoRaw Date: Fri, 29 May 2026 17:23:52 +0200 Subject: [PATCH 3/4] Add sealed encrypted token mode --- Cargo.lock | 89 ++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + README.fr.md | 12 +++-- README.md | 40 ++++++++++++++-- README.pt.md | 12 +++-- SECURITY_NOTES.md | 6 ++- examples/native_signed.rs | 19 ++++++++ src/crypto.rs | 11 +++++ src/lib.rs | 1 + src/sealed.rs | 54 +++++++++++++++++++++ src/sealed/build.rs | 97 +++++++++++++++++++++++++++++++++++++ src/sealed/open.rs | 62 ++++++++++++++++++++++++ src/sealed/parts.rs | 51 ++++++++++++++++++++ src/token.rs | 2 +- src/token/build.rs | 5 +- tests/native_token_test.rs | 98 ++++++++++++++++++++++++++++++++++++++ 16 files changed, 541 insertions(+), 19 deletions(-) create mode 100644 src/sealed.rs create mode 100644 src/sealed/build.rs create mode 100644 src/sealed/open.rs create mode 100644 src/sealed/parts.rs diff --git a/Cargo.lock b/Cargo.lock index 4551c84..0a22d99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,16 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "base64" version = "0.22.1" @@ -23,6 +33,41 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -39,6 +84,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core", "typenum", ] @@ -79,6 +125,7 @@ name = "hash_token_rust" version = "0.3.0" dependencies = [ "base64", + "chacha20poly1305", "hmac", "rand", "sha2", @@ -93,12 +140,38 @@ dependencies = [ "digest", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -196,6 +269,16 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "version_check" version = "0.9.5" @@ -227,3 +310,9 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" diff --git a/Cargo.toml b/Cargo.toml index 3c7cf60..3df7be4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ repository = "https://github.com/dnettoRaw/hashTokenRust.git" [dependencies] base64 = "0.22" +chacha20poly1305 = "0.10" hmac = "0.12" rand = "0.8" sha2 = "0.10" diff --git a/README.fr.md b/README.fr.md index dc0ba93..0919249 100644 --- a/README.fr.md +++ b/README.fr.md @@ -1,14 +1,15 @@ # hash_token_rust -Tokens natifs signes et minimaux pour binaires Rust standalone. +Tokens natifs signes et scelles, minimaux, pour binaires Rust standalone. Format principal : ```text htr1... +hte1... ``` -Le payload est encode, pas chiffre. La signature authentifie le token avec HMAC en utilisant un secret partage et le salt selectionne. +`htr1` signe les donnees, mais ne les cache pas. `hte1` chiffre et authentifie le payload avec ChaCha20-Poly1305 en utilisant une cle derivee du secret partage et du salt selectionne. ## Usage @@ -16,13 +17,14 @@ Utilisez ceci quand vos propres binaires doivent echanger des donnees signees sa ## Securite -- Signe les donnees ; ne cache pas les donnees. -- Sert pour authenticite, integrite, age, issuer et audience. +- `htr1` signe les donnees ; ne les cache pas. +- `hte1` scelle les donnees ; chiffre et authentifie le payload. +- Utilisez les tokens signes pour l'authenticite et les tokens scelles pour le secret du payload. - Utilisez un secret fort et des salts rotates volontairement. - `validate_token` retourne le payload et les metadata valides. - `validate_payload` existe quand seul le payload est necessaire. - `generate_token_bytes` et `validate_token_bytes` supportent les payloads non UTF-8. -- Si le secret du payload est requis, ajoutez un mode chiffre separe. +- `seal_token_bytes` et `open_token_bytes` supportent les payloads chiffres non UTF-8. ## Developpement diff --git a/README.md b/README.md index e1206f7..233af85 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,15 @@ # hash_token_rust -Minimal native signed tokens for standalone Rust binaries. +Minimal native signed and sealed tokens for standalone Rust binaries. The main format is: ```text htr1... +hte1... ``` -The payload is encoded, not encrypted. The signature authenticates the token with HMAC using a shared secret and the selected salt. +`htr1` signs data but does not hide it. `hte1` encrypts and authenticates data with ChaCha20-Poly1305 using a key derived from the shared secret and selected salt. ## Use Case @@ -51,15 +52,44 @@ assert_eq!(verified.issuer.as_deref(), Some("bin-a")); # Ok::<(), Box>(()) ``` +## Sealed Payloads + +Use sealed tokens when the payload must not be readable by whoever sees the token. + +```rust +let token = manager.seal_token( + "email=user@example.com", + GenerateTokenOptions { + expires_in: Some(300), + issuer: Some("bin-a"), + audience: Some("bin-b"), + ..Default::default() + }, +)?; + +let verified = manager.open_token( + &token, + ValidateTokenOptions { + issuer: Some("bin-a"), + audience: Some("bin-b"), + ..Default::default() + }, +)?; + +assert_eq!(verified.payload, "email=user@example.com"); +# Ok::<(), Box>(()) +``` + ## Security Notes -- This signs data; it does not hide data. -- Use it for authenticity, integrity, age control, issuer and audience checks. +- `htr1` signs data; it does not hide data. +- `hte1` seals data; it encrypts and authenticates the payload. +- Use signed tokens for authenticity and sealed tokens for payload secrecy. - Use a strong shared secret and rotate salts deliberately. - `validate_token` returns validated metadata with the payload. - `validate_payload` is available when only the payload is needed. - `generate_token_bytes` and `validate_token_bytes` support non-UTF-8 payloads. -- If payload secrecy is required, add a separate encrypted token mode later. +- `seal_token_bytes` and `open_token_bytes` support encrypted non-UTF-8 payloads. ## Binary Payloads diff --git a/README.pt.md b/README.pt.md index eaf0a59..4cdc191 100644 --- a/README.pt.md +++ b/README.pt.md @@ -1,14 +1,15 @@ # hash_token_rust -Tokens nativos assinados e mínimos para binários Rust standalone. +Tokens nativos assinados e selados, mínimos, para binários Rust standalone. Formato principal: ```text htr1... +hte1... ``` -O payload é codificado, não criptografado. A assinatura autentica o token com HMAC usando um segredo compartilhado e o salt selecionado. +`htr1` assina dados, mas nao esconde. `hte1` criptografa e autentica o payload com ChaCha20-Poly1305 usando chave derivada do segredo compartilhado e do salt selecionado. ## Uso @@ -16,13 +17,14 @@ Use quando seus próprios binários precisam trocar dados assinados sem chaves p ## Segurança -- Assina dados; não esconde dados. -- Serve para autenticidade, integridade, idade, issuer e audience. +- `htr1` assina dados; nao esconde dados. +- `hte1` sela dados; criptografa e autentica o payload. +- Use tokens assinados para autenticidade e tokens selados para sigilo do payload. - Use segredo forte e salts rotacionados com intenção. - `validate_token` retorna payload e metadata validados. - `validate_payload` existe quando só o payload importa. - `generate_token_bytes` e `validate_token_bytes` suportam payloads que nao sao UTF-8. -- Se precisar de sigilo do payload, adicione um modo criptografado separado. +- `seal_token_bytes` e `open_token_bytes` suportam payloads criptografados que nao sao UTF-8. ## Desenvolvimento diff --git a/SECURITY_NOTES.md b/SECURITY_NOTES.md index 9043f1c..1e5e9ff 100644 --- a/SECURITY_NOTES.md +++ b/SECURITY_NOTES.md @@ -1,8 +1,10 @@ # Security Notes - Native `htr1` tokens are signed, not encrypted. -- Payloads are Base64URL encoded and readable by anyone who has the token. +- Native `hte1` tokens are encrypted and authenticated with ChaCha20-Poly1305. +- `htr1` payloads are Base64URL encoded and readable by anyone who has the token. +- `hte1` payloads are not readable without the shared secret and selected salt. - HMAC authenticates version, payload and metadata using the shared secret plus selected salt. - Verification checks algorithm, salt index, expiration, max age, issuer and audience when configured. - Signature comparison is constant-time for equal-length signatures. -- Use an encrypted token mode for payload secrecy. +- Use sealed `hte1` tokens for payload secrecy. diff --git a/examples/native_signed.rs b/examples/native_signed.rs index 4c76f6b..bdaef52 100644 --- a/examples/native_signed.rs +++ b/examples/native_signed.rs @@ -30,5 +30,24 @@ fn main() -> Result<(), Box> { println!("payload={}", verified.payload); println!("salt_index={}", verified.salt_index); + + let sealed = manager.seal_token( + "email=user@example.com", + GenerateTokenOptions { + expires_in: Some(300), + issuer: Some("bin-a"), + audience: Some("bin-b"), + ..Default::default() + }, + )?; + let opened = manager.open_token( + &sealed, + ValidateTokenOptions { + issuer: Some("bin-a"), + audience: Some("bin-b"), + ..Default::default() + }, + )?; + println!("sealed_payload={}", opened.payload); Ok(()) } diff --git a/src/crypto.rs b/src/crypto.rs index 339df30..c6ac735 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -27,6 +27,17 @@ pub(crate) fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { diff == 0 } +pub(crate) fn sealing_key( + algorithm: Algorithm, + secret: &[u8], + salt: &[u8], +) -> Result<[u8; 32], TokenError> { + let digest = sign(algorithm, secret, salt, b"hash-token-rust:sealed:v1")?; + let mut key = [0u8; 32]; + key.copy_from_slice(&digest[..32]); + Ok(key) +} + fn hmac_sha256(secret: &[u8], salt: &[u8], input: &[u8]) -> Result, TokenError> { let mut mac = Hmac::::new_from_slice(secret).map_err(|_| TokenError::new("Invalid HMAC key."))?; diff --git a/src/lib.rs b/src/lib.rs index 4bcc7ea..ab85e53 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ mod error; mod manager; mod meta; mod options; +mod sealed; mod token; mod validate; diff --git a/src/sealed.rs b/src/sealed.rs new file mode 100644 index 0000000..6638c4f --- /dev/null +++ b/src/sealed.rs @@ -0,0 +1,54 @@ +mod build; +mod open; +mod parts; + +use crate::error::TokenError; +use crate::manager::AdvancedTokenManager; +use crate::options::{GenerateTokenOptions, ValidateTokenOptions, VerifiedBytes, VerifiedToken}; + +pub(crate) const VERSION: &str = "hte1"; + +impl AdvancedTokenManager { + pub fn seal_token( + &mut self, + payload: &str, + options: GenerateTokenOptions<'_>, + ) -> Result { + self.seal_token_bytes(payload.as_bytes(), options) + } + + pub fn seal_token_bytes( + &mut self, + payload: &[u8], + options: GenerateTokenOptions<'_>, + ) -> Result { + build::token(self, payload, &options) + } + + pub fn open_token( + &self, + token: &str, + options: ValidateTokenOptions<'_>, + ) -> Result { + let verified = self.open_token_bytes(token, options)?; + let payload = String::from_utf8(verified.payload) + .map_err(|_| TokenError::new("Payload is not UTF-8."))?; + Ok(VerifiedToken { + payload, + issued_at: verified.issued_at, + expires_at: verified.expires_at, + issuer: verified.issuer, + audience: verified.audience, + salt_index: verified.salt_index, + algorithm: verified.algorithm, + }) + } + + pub fn open_token_bytes( + &self, + token: &str, + options: ValidateTokenOptions<'_>, + ) -> Result { + open::token(self, token, &options) + } +} diff --git a/src/sealed/build.rs b/src/sealed/build.rs new file mode 100644 index 0000000..6fce1e1 --- /dev/null +++ b/src/sealed/build.rs @@ -0,0 +1,97 @@ +use chacha20poly1305::aead::{Aead, Payload}; +use chacha20poly1305::{ChaCha20Poly1305, KeyInit, Nonce}; +use rand::RngCore; + +use crate::base64url; +use crate::crypto; +use crate::error::TokenError; +use crate::manager::AdvancedTokenManager; +use crate::meta::Meta; +use crate::options::GenerateTokenOptions; +use crate::sealed::parts::aad; +use crate::sealed::VERSION; + +pub(crate) fn token( + manager: &mut AdvancedTokenManager, + payload: &[u8], + options: &GenerateTokenOptions<'_>, +) -> Result { + let salt_index = manager.select_salt(options.salt_index)?; + let meta = meta(manager, salt_index, options)?; + let encoded_meta = meta.encode()?; + let nonce = nonce(); + let encoded_nonce = base64url::encode(&nonce); + let ciphertext = seal( + manager, + salt_index, + payload, + &encoded_meta, + &encoded_nonce, + &nonce, + )?; + assemble( + &base64url::encode(&ciphertext), + &encoded_meta, + &encoded_nonce, + ) +} + +fn meta( + manager: &AdvancedTokenManager, + salt_index: usize, + options: &GenerateTokenOptions<'_>, +) -> Result { + let issued_at = options.issued_at.map_or_else(crate::validate::now, Ok)?; + Ok(Meta { + algorithm: manager.algorithm.name().to_string(), + salt_index, + issued_at, + expires_at: crate::token::build::expiration(issued_at, options.expires_in)?, + issuer: options.issuer.map(str::to_string), + audience: options.audience.map(str::to_string), + }) +} + +fn seal( + manager: &AdvancedTokenManager, + salt_index: usize, + payload: &[u8], + meta: &str, + encoded_nonce: &str, + nonce: &[u8; 12], +) -> Result, TokenError> { + let key = crypto::sealing_key( + manager.algorithm, + &manager.secret, + &manager.salts[salt_index], + )?; + let cipher = ChaCha20Poly1305::new((&key).into()); + cipher + .encrypt( + Nonce::from_slice(nonce), + Payload { + msg: payload, + aad: aad(meta, encoded_nonce).as_bytes(), + }, + ) + .map_err(|_| TokenError::new("Failed to seal token.")) +} + +fn nonce() -> [u8; 12] { + let mut nonce = [0u8; 12]; + rand::rngs::OsRng.fill_bytes(&mut nonce); + nonce +} + +fn assemble(ciphertext: &str, meta: &str, nonce: &str) -> Result { + let mut token = + String::with_capacity(VERSION.len() + ciphertext.len() + meta.len() + nonce.len() + 3); + token.push_str(VERSION); + token.push('.'); + token.push_str(ciphertext); + token.push('.'); + token.push_str(meta); + token.push('.'); + token.push_str(nonce); + Ok(token) +} diff --git a/src/sealed/open.rs b/src/sealed/open.rs new file mode 100644 index 0000000..4cae9e4 --- /dev/null +++ b/src/sealed/open.rs @@ -0,0 +1,62 @@ +use chacha20poly1305::aead::{Aead, Payload}; +use chacha20poly1305::{ChaCha20Poly1305, KeyInit, Nonce}; + +use crate::base64url; +use crate::crypto; +use crate::error::TokenError; +use crate::manager::AdvancedTokenManager; +use crate::meta::Meta; +use crate::options::{ValidateTokenOptions, VerifiedBytes}; +use crate::sealed::parts::{aad, split}; +use crate::validate; + +pub(crate) fn token( + manager: &AdvancedTokenManager, + token: &str, + options: &ValidateTokenOptions<'_>, +) -> Result { + let parts = split(token)?; + let meta = Meta::decode(parts.meta)?; + validate::metadata(manager, &meta, options)?; + let payload = open( + manager, + parts.ciphertext, + parts.meta, + parts.nonce, + meta.salt_index, + )?; + Ok(VerifiedBytes::new(payload, meta)) +} + +fn open( + manager: &AdvancedTokenManager, + ciphertext: &str, + meta: &str, + nonce: &str, + salt_index: usize, +) -> Result, TokenError> { + let ciphertext = base64url::decode(ciphertext, "ciphertext")?; + let nonce_bytes = decode_nonce(nonce)?; + let key = crypto::sealing_key( + manager.algorithm, + &manager.secret, + &manager.salts[salt_index], + )?; + let cipher = ChaCha20Poly1305::new((&key).into()); + cipher + .decrypt( + Nonce::from_slice(&nonce_bytes), + Payload { + msg: &ciphertext, + aad: aad(meta, nonce).as_bytes(), + }, + ) + .map_err(|_| TokenError::new("Failed to open sealed token.")) +} + +fn decode_nonce(encoded: &str) -> Result<[u8; 12], TokenError> { + let nonce = base64url::decode(encoded, "nonce")?; + nonce + .try_into() + .map_err(|_| TokenError::new("Invalid nonce length.")) +} diff --git a/src/sealed/parts.rs b/src/sealed/parts.rs new file mode 100644 index 0000000..0ce018a --- /dev/null +++ b/src/sealed/parts.rs @@ -0,0 +1,51 @@ +use crate::error::TokenError; +use crate::sealed::VERSION; + +pub(crate) struct Parts<'a> { + pub ciphertext: &'a str, + pub meta: &'a str, + pub nonce: &'a str, +} + +pub(crate) fn split(token: &str) -> Result, TokenError> { + let mut fields = token.split('.'); + let version = fields.next().unwrap_or_default(); + let ciphertext = fields.next().unwrap_or_default(); + let meta = fields.next().unwrap_or_default(); + let nonce = fields.next().unwrap_or_default(); + reject_bad_shape(version, ciphertext, meta, nonce, fields.next())?; + Ok(Parts { + ciphertext, + meta, + nonce, + }) +} + +pub(crate) fn aad(meta: &str, nonce: &str) -> String { + let mut input = String::with_capacity(VERSION.len() + meta.len() + nonce.len() + 2); + input.push_str(VERSION); + input.push('.'); + input.push_str(meta); + input.push('.'); + input.push_str(nonce); + input +} + +fn reject_bad_shape( + version: &str, + ciphertext: &str, + meta: &str, + nonce: &str, + extra: Option<&str>, +) -> Result<(), TokenError> { + if version != VERSION + || ciphertext.is_empty() + || meta.is_empty() + || nonce.is_empty() + || extra.is_some() + { + Err(TokenError::new("Invalid sealed token structure.")) + } else { + Ok(()) + } +} diff --git a/src/token.rs b/src/token.rs index 5fadac9..b8d3ed5 100644 --- a/src/token.rs +++ b/src/token.rs @@ -1,4 +1,4 @@ -mod build; +pub(crate) mod build; mod parts; use crate::base64url; diff --git a/src/token/build.rs b/src/token/build.rs index 4ee803e..5b88ba3 100644 --- a/src/token/build.rs +++ b/src/token/build.rs @@ -55,7 +55,10 @@ fn assemble(payload: &str, meta: &str, signature: &str) -> Result) -> Result, TokenError> { +pub(crate) fn expiration( + issued_at: u64, + expires_in: Option, +) -> Result, TokenError> { expires_in .map(|seconds| { issued_at diff --git a/tests/native_token_test.rs b/tests/native_token_test.rs index cb67eac..e47affc 100644 --- a/tests/native_token_test.rs +++ b/tests/native_token_test.rs @@ -216,6 +216,104 @@ fn signs_and_validates_binary_payload() { assert!(err.to_string().contains("UTF-8")); } +#[test] +fn seals_and_opens_text_payload() { + let mut manager = manager(); + let token = manager + .seal_token( + "email=user@example.com", + GenerateTokenOptions { + issued_at: Some(1000), + issuer: Some("bin-a"), + audience: Some("bin-b"), + ..Default::default() + }, + ) + .unwrap(); + + assert!(token.starts_with("hte1.")); + assert!(!token.contains("user@example.com")); + + let verified = manager + .open_token( + &token, + ValidateTokenOptions { + clock_timestamp: Some(1001), + issuer: Some("bin-a"), + audience: Some("bin-b"), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(verified.payload, "email=user@example.com"); +} + +#[test] +fn seals_and_opens_binary_payload() { + let mut manager = manager(); + let bytes = [0, 1, 2, 3, 255]; + let token = manager + .seal_token_bytes( + &bytes, + GenerateTokenOptions { + issued_at: Some(1000), + ..Default::default() + }, + ) + .unwrap(); + + let verified = manager + .open_token_bytes( + &token, + ValidateTokenOptions { + clock_timestamp: Some(1000), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(verified.payload, bytes); +} + +#[test] +fn sealed_token_rejects_wrong_secret_and_tampering() { + let mut manager = manager(); + let token = manager + .seal_token( + "secret-data", + GenerateTokenOptions { + issued_at: Some(1000), + ..Default::default() + }, + ) + .unwrap(); + + let other = AdvancedTokenManager::new( + b"other-secure-secret", + &[b"salt-a".as_slice(), b"salt-b".as_slice()], + Algorithm::Sha256, + ) + .unwrap(); + assert!(other + .open_token( + &token, + ValidateTokenOptions { + clock_timestamp: Some(1000), + ..Default::default() + }, + ) + .unwrap_err() + .to_string() + .contains("open")); + + let mut parts: Vec<&str> = token.split('.').collect(); + parts[1] = "dGFtcGVyZWQ"; + assert!(manager + .open_token(&parts.join("."), ValidateTokenOptions::default()) + .unwrap_err() + .to_string() + .contains("open")); +} + #[test] fn rejects_bad_structure_and_base64() { let manager = manager(); From 71ffd7b7544162b14c7a55fe22db4c298c754cea Mon Sep 17 00:00:00 2001 From: dnettoRaw Date: Fri, 29 May 2026 17:46:25 +0200 Subject: [PATCH 4/4] Improve docs and inline comments --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.fr.md | 289 ++++++++++++++++++++++++++++++++++++-- README.md | 217 +++++++++++++++++++++++++--- README.pt.md | 287 +++++++++++++++++++++++++++++++++++-- src/base64url.rs | 8 ++ src/crypto.rs | 11 ++ src/error.rs | 2 + src/lib.rs | 13 +- src/manager.rs | 21 +++ src/meta.rs | 5 + src/meta/decode.rs | 12 ++ src/meta/encode.rs | 10 ++ src/meta/parse.rs | 6 + src/options.rs | 33 +++++ src/sealed.rs | 9 ++ src/sealed/build.rs | 13 ++ src/sealed/open.rs | 12 ++ src/sealed/parts.rs | 7 + src/token.rs | 9 ++ src/token/build.rs | 9 ++ src/token/parts.rs | 7 + src/validate.rs | 8 ++ src/validate/scope.rs | 4 + src/validate/signature.rs | 2 + src/validate/time.rs | 10 ++ version | 1 + 27 files changed, 963 insertions(+), 46 deletions(-) create mode 100644 version diff --git a/Cargo.lock b/Cargo.lock index 0a22d99..d3ee124 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -122,7 +122,7 @@ dependencies = [ [[package]] name = "hash_token_rust" -version = "0.3.0" +version = "0.3.5" dependencies = [ "base64", "chacha20poly1305", diff --git a/Cargo.toml b/Cargo.toml index 3df7be4..ce6f43b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hash_token_rust" -version = "0.3.0" +version = "0.3.5" edition = "2021" authors = ["dnettoRaw "] description = "Minimal native signed tokens for standalone binaries" diff --git a/README.fr.md b/README.fr.md index 0919249..ff5b6c2 100644 --- a/README.fr.md +++ b/README.fr.md @@ -2,29 +2,281 @@ Tokens natifs signes et scelles, minimaux, pour binaires Rust standalone. -Format principal : +`hash_token_rust` est fait pour de petits programmes qui doivent echanger des donnees avec des secrets partages sans importer une grosse pile d'authentification, des fichiers de cles publiques/privees, des certificats, des services, des frameworks ou JWT comme format principal. + +Il fournit deux modes natifs de token : ```text htr1... hte1... ``` -`htr1` signe les donnees, mais ne les cache pas. `hte1` chiffre et authentifie le payload avec ChaCha20-Poly1305 en utilisant une cle derivee du secret partage et du salt selectionne. +- `htr1` est signe : le payload est lisible, mais les modifications sont detectees. +- `hte1` est scelle : le payload est chiffre et authentifie. + +## Pourquoi Ceci Existe + +Ce crate est concu pour des binaires standalone qui appartiennent au meme systeme et peuvent partager un secret plus des salts. Un cas typique : + +- le binaire A emet un petit payload ; +- le binaire B verifie que le payload vient de quelqu'un qui connait le secret ; +- les tokens peuvent expirer ; +- les tokens peuvent etre limites par issuer et audience ; +- les salts peuvent etre rotates ou selectionnes explicitement ; +- aucun `.pem`, `.pub`, chaine de certificats, service central ou framework lourd n'est requis. + +Ce n'est pas une bibliotheque de hash de mot de passe. Ce n'est pas un remplacement pour Argon2, bcrypt ou scrypt. C'est un gestionnaire compact de tokens pour authentifier et, en mode scelle, chiffrer des donnees echangees entre binaires de confiance. + +## Modes De Token + +### Tokens Signes : `htr1` + +Utilisez les tokens signes quand le payload peut etre lisible mais ne doit pas etre modifiable. + +La signature authentifie : + +- version du token ; +- payload encode ; +- metadata encode ; +- salt selectionne ; +- secret partage. + +Bons usages : + +- identifiants utilisateur ou job ; +- commandes qui ne sont pas secretes ; +- tokens courts de passage entre processus ; +- messages internes ou l'integrite compte. + +### Tokens Scelles : `hte1` + +Utilisez les tokens scelles quand le payload ne doit pas etre lisible par celui qui voit le token. + +Les tokens scelles utilisent `ChaCha20-Poly1305`, un chiffrement AEAD. La cle de chiffrement est derivee du secret du manager et du salt selectionne. Les metadata et le nonce sont authentifies comme donnees associees, donc les modifier invalide le token. + +Bons usages : + +- donnees utilisateur sensibles ; +- messages internes prives ; +- payloads qui exigent integrite et confidentialite. + +## Installation + +```toml +[dependencies] +hash_token_rust = "0.3" +``` + +Avec le depot directement : + +```toml +[dependencies] +hash_token_rust = { path = "../hashTokenRust" } +``` + +## Creer Un Manager + +```rust +use hash_token_rust::{AdvancedTokenManager, Algorithm}; + +let mut manager = AdvancedTokenManager::new( + b"very-secure-secret", + &[b"salt-a".as_slice(), b"salt-b".as_slice()], + Algorithm::Sha256, +)?; +# Ok::<(), Box>(()) +``` + +Le manager a besoin de : + +| Champ | Signification | +| --- | --- | +| `secret` | Secret partage connu par les binaires qui doivent se faire confiance. Minimum 16 octets. | +| `salts` | Un ou plusieurs salts non vides. L'index du salt selectionne est stocke dans les metadata. | +| `algorithm` | Algorithme HMAC pour tokens signes et derivation de cle. `Sha256` ou `Sha512`. | + +## Exemple De Token Signe + +```rust +use hash_token_rust::{ + AdvancedTokenManager, Algorithm, GenerateTokenOptions, ValidateTokenOptions, +}; + +let mut manager = AdvancedTokenManager::new( + b"very-secure-secret", + &[b"salt-a".as_slice(), b"salt-b".as_slice()], + Algorithm::Sha256, +)?; + +let token = manager.generate_token( + "user-id=123", + GenerateTokenOptions { + expires_in: Some(300), + issuer: Some("bin-a"), + audience: Some("bin-b"), + ..Default::default() + }, +)?; + +let verified = manager.validate_token( + &token, + ValidateTokenOptions { + issuer: Some("bin-a"), + audience: Some("bin-b"), + ..Default::default() + }, +)?; + +assert_eq!(verified.payload, "user-id=123"); +assert_eq!(verified.issuer.as_deref(), Some("bin-a")); +# Ok::<(), Box>(()) +``` + +Si vous avez seulement besoin du payload : -## Usage +```rust +let payload = manager.validate_payload( + &token, + ValidateTokenOptions { + issuer: Some("bin-a"), + audience: Some("bin-b"), + ..Default::default() + }, +)?; -Utilisez ceci quand vos propres binaires doivent echanger des donnees signees sans cles publiques, certificats, services, frameworks ou beaucoup de dependances. +assert_eq!(payload, "user-id=123"); +# Ok::<(), Box>(()) +``` + +## Exemple De Token Scelle + +```rust +let token = manager.seal_token( + "email=user@example.com", + GenerateTokenOptions { + expires_in: Some(300), + issuer: Some("bin-a"), + audience: Some("bin-b"), + ..Default::default() + }, +)?; + +let verified = manager.open_token( + &token, + ValidateTokenOptions { + issuer: Some("bin-a"), + audience: Some("bin-b"), + ..Default::default() + }, +)?; + +assert_eq!(verified.payload, "email=user@example.com"); +# Ok::<(), Box>(()) +``` + +## Payloads Binaires + +Utilisez les APIs byte quand le payload n'est pas UTF-8. + +```rust +let token = manager.generate_token_bytes( + &[0, 1, 2, 255], + GenerateTokenOptions::default(), +)?; + +let verified = manager.validate_token_bytes( + &token, + ValidateTokenOptions::default(), +)?; + +assert_eq!(verified.payload, vec![0, 1, 2, 255]); +# Ok::<(), Box>(()) +``` + +Pour les payloads binaires chiffres : + +```rust +let token = manager.seal_token_bytes( + &[0, 1, 2, 255], + GenerateTokenOptions::default(), +)?; + +let verified = manager.open_token_bytes( + &token, + ValidateTokenOptions::default(), +)?; + +assert_eq!(verified.payload, vec![0, 1, 2, 255]); +# Ok::<(), Box>(()) +``` + +## Options + +### `GenerateTokenOptions` -## Securite +| Option | Signification | +| --- | --- | +| `salt_index` | Selectionne un salt specifique. Si absent, un index aleatoire est utilise. | +| `expires_in` | Ajoute une expiration en secondes depuis `issued_at`. | +| `issuer` | Identifie qui a cree le token. | +| `audience` | Identifie qui doit accepter le token. | +| `issued_at` | Remplace le timestamp d'emission. Utile pour les tests. | -- `htr1` signe les donnees ; ne les cache pas. -- `hte1` scelle les donnees ; chiffre et authentifie le payload. -- Utilisez les tokens signes pour l'authenticite et les tokens scelles pour le secret du payload. -- Utilisez un secret fort et des salts rotates volontairement. -- `validate_token` retourne le payload et les metadata valides. -- `validate_payload` existe quand seul le payload est necessaire. -- `generate_token_bytes` et `validate_token_bytes` supportent les payloads non UTF-8. -- `seal_token_bytes` et `open_token_bytes` supportent les payloads chiffres non UTF-8. +### `ValidateTokenOptions` + +| Option | Signification | +| --- | --- | +| `max_age` | Rejette les tokens plus vieux que ce nombre de secondes selon `issued_at`. | +| `issuer` | Exige que l'issuer du token corresponde. | +| `audience` | Exige que l'audience du token corresponde. | +| `clock_tolerance` | Autorise un petit decalage d'horloge en secondes. | +| `clock_timestamp` | Remplace l'heure courante. Utile pour les tests. | + +## Sortie Validee + +`validate_token` et `open_token` retournent `VerifiedToken` : + +```rust +pub struct VerifiedToken { + pub payload: String, + pub issued_at: u64, + pub expires_at: Option, + pub issuer: Option, + pub audience: Option, + pub salt_index: usize, + pub algorithm: String, +} +``` + +Les APIs byte retournent `VerifiedBytes`, avec les memes metadata et un payload `Vec`. + +## Rotation Des Salts + +Les tokens stockent l'index du salt selectionne dans les metadata. Cela rend la rotation simple : + +- gardez les anciens salts disponibles tant que les anciens tokens peuvent etre valides ; +- genereez les nouveaux tokens avec le nouvel index de salt ; +- supprimez les anciens salts seulement apres expiration de tous les anciens tokens. + +Si les binaires ne partagent pas le meme secret et la meme liste de salts, la validation ou l'ouverture echoue. + +## Notes De Securite + +- `htr1` signe les donnees ; il ne les cache pas. +- `hte1` scelle les donnees ; il chiffre et authentifie le payload. +- Les tokens signes servent a l'authenticite et l'integrite. +- Les tokens scelles servent a l'authenticite, l'integrite et la confidentialite du payload. +- Utilisez des secrets partages a haute entropie. +- Faites tourner les salts deliberement. +- Gardez des durees de vie courtes quand les tokens traversent des processus ou machines. +- N'utilisez pas ceci comme hash de mot de passe. +- Si un secret partage fuite, les tokens de ce groupe de confiance doivent etre consideres compromis. + +## Exemples + +```bash +cargo run --example native_signed +``` ## Developpement @@ -33,3 +285,14 @@ cargo fmt --check cargo clippy --all-targets --all-features cargo test ``` + +## Objectifs De Design + +- Rust 2021. +- Fichiers petits et fonctions courtes. +- Erreurs claires avec `Result`. +- Dependances minimales. +- Pas de framework. +- Pas de dependance JWT. +- Pas de panic dans le code de bibliotheque pour les erreurs normales. +- Eviter les allocations inutiles quand le code peut rester clair. diff --git a/README.md b/README.md index 233af85..4308224 100644 --- a/README.md +++ b/README.md @@ -2,20 +2,100 @@ Minimal native signed and sealed tokens for standalone Rust binaries. -The main format is: +`hash_token_rust` is for small programs that need to exchange data using shared secrets without pulling in a large authentication stack, public/private key files, certificates, services, frameworks, or JWT as the primary format. + +It provides two native token modes: ```text htr1... hte1... ``` -`htr1` signs data but does not hide it. `hte1` encrypts and authenticates data with ChaCha20-Poly1305 using a key derived from the shared secret and selected salt. +- `htr1` is signed: the payload is readable, but tampering is detected. +- `hte1` is sealed: the payload is encrypted and authenticated. + +## Why This Exists + +This crate is designed for standalone binaries that belong to the same system and can share a secret plus salts. A typical use case is: + +- binary A emits a small payload; +- binary B validates that the payload came from someone who knows the secret; +- tokens can expire; +- tokens can be scoped to an issuer and audience; +- salts can be rotated or selected explicitly; +- no `.pem`, `.pub`, certificate chain, server dependency, or heavy framework is required. + +This is not a password hashing library. It is not a replacement for Argon2, bcrypt, or scrypt. It is a compact token manager for authenticating and, when sealed mode is used, encrypting data exchanged between trusted binaries. + +## Token Modes + +### Signed Tokens: `htr1` + +Use signed tokens when the payload is allowed to be readable but must not be modifiable. + +The signature authenticates: + +- token version; +- encoded payload; +- encoded metadata; +- selected salt; +- shared secret. + +Good uses: + +- user or job identifiers; +- command payloads that are not secret; +- short-lived handoff tokens; +- internal messages where integrity matters. -## Use Case +### Sealed Tokens: `hte1` -Use this when your own binaries need to exchange signed data without public keys, certificates, services, frameworks, or a large dependency tree. +Use sealed tokens when the payload must not be readable by whoever sees the token. + +Sealed tokens use `ChaCha20-Poly1305`, an AEAD cipher. The encryption key is derived from the manager secret and the selected salt. Metadata and nonce are authenticated as associated data, so changing them invalidates the token. + +Good uses: + +- sensitive user data; +- private internal messages; +- payloads that need integrity and confidentiality. + +## Installation + +```toml +[dependencies] +hash_token_rust = "0.3" +``` + +If using the repository directly: + +```toml +[dependencies] +hash_token_rust = { path = "../hashTokenRust" } +``` -## Example +## Creating A Manager + +```rust +use hash_token_rust::{AdvancedTokenManager, Algorithm}; + +let mut manager = AdvancedTokenManager::new( + b"very-secure-secret", + &[b"salt-a".as_slice(), b"salt-b".as_slice()], + Algorithm::Sha256, +)?; +# Ok::<(), Box>(()) +``` + +The manager needs: + +| Field | Meaning | +| --- | --- | +| `secret` | Shared secret known by binaries that should trust each other. Must be at least 16 bytes. | +| `salts` | One or more non-empty salts. The selected salt index is stored in metadata. | +| `algorithm` | HMAC algorithm for signed tokens and key derivation. `Sha256` or `Sha512`. | + +## Signed Token Example ```rust use hash_token_rust::{ @@ -52,9 +132,23 @@ assert_eq!(verified.issuer.as_deref(), Some("bin-a")); # Ok::<(), Box>(()) ``` -## Sealed Payloads +If you only need the payload: -Use sealed tokens when the payload must not be readable by whoever sees the token. +```rust +let payload = manager.validate_payload( + &token, + ValidateTokenOptions { + issuer: Some("bin-a"), + audience: Some("bin-b"), + ..Default::default() + }, +)?; + +assert_eq!(payload, "user-id=123"); +# Ok::<(), Box>(()) +``` + +## Sealed Token Example ```rust let token = manager.seal_token( @@ -80,19 +174,10 @@ assert_eq!(verified.payload, "email=user@example.com"); # Ok::<(), Box>(()) ``` -## Security Notes - -- `htr1` signs data; it does not hide data. -- `hte1` seals data; it encrypts and authenticates the payload. -- Use signed tokens for authenticity and sealed tokens for payload secrecy. -- Use a strong shared secret and rotate salts deliberately. -- `validate_token` returns validated metadata with the payload. -- `validate_payload` is available when only the payload is needed. -- `generate_token_bytes` and `validate_token_bytes` support non-UTF-8 payloads. -- `seal_token_bytes` and `open_token_bytes` support encrypted non-UTF-8 payloads. - ## Binary Payloads +Use the byte APIs when the payload is not UTF-8. + ```rust let token = manager.generate_token_bytes( &[0, 1, 2, 255], @@ -108,6 +193,91 @@ assert_eq!(verified.payload, vec![0, 1, 2, 255]); # Ok::<(), Box>(()) ``` +For encrypted binary payloads: + +```rust +let token = manager.seal_token_bytes( + &[0, 1, 2, 255], + GenerateTokenOptions::default(), +)?; + +let verified = manager.open_token_bytes( + &token, + ValidateTokenOptions::default(), +)?; + +assert_eq!(verified.payload, vec![0, 1, 2, 255]); +# Ok::<(), Box>(()) +``` + +## Options + +### `GenerateTokenOptions` + +| Option | Meaning | +| --- | --- | +| `salt_index` | Selects a specific salt. If absent, a random salt index is used. | +| `expires_in` | Adds an expiration time in seconds from `issued_at`. | +| `issuer` | Identifies who created the token. | +| `audience` | Identifies who should accept the token. | +| `issued_at` | Overrides the issue timestamp. Useful for tests. | + +### `ValidateTokenOptions` + +| Option | Meaning | +| --- | --- | +| `max_age` | Rejects tokens older than this many seconds based on `issued_at`. | +| `issuer` | Requires the token issuer to match. | +| `audience` | Requires the token audience to match. | +| `clock_tolerance` | Allows small clock drift in seconds. | +| `clock_timestamp` | Overrides current time. Useful for tests. | + +## Verified Output + +`validate_token` and `open_token` return `VerifiedToken`: + +```rust +pub struct VerifiedToken { + pub payload: String, + pub issued_at: u64, + pub expires_at: Option, + pub issuer: Option, + pub audience: Option, + pub salt_index: usize, + pub algorithm: String, +} +``` + +The byte APIs return `VerifiedBytes`, which has the same metadata and a `Vec` payload. + +## Salt Rotation + +Tokens store the selected salt index in metadata. This makes rotation straightforward: + +- keep old salts available while older tokens may still be valid; +- generate new tokens with the new salt index; +- remove old salts only after all old tokens have expired. + +If binaries do not share the same secret and salt list, validation or opening fails. + +## Security Notes + +- `htr1` signs data; it does not hide data. +- `hte1` seals data; it encrypts and authenticates the payload. +- Signed tokens are for authenticity and integrity. +- Sealed tokens are for authenticity, integrity and payload secrecy. +- Use high-entropy shared secrets. +- Rotate salts deliberately. +- Keep token lifetimes short when tokens cross process or machine boundaries. +- Do not use this as password hashing. +- If a shared secret leaks, tokens for that trust group should be considered compromised. + +## Examples + +```bash +cargo run --example native_signed +``` + ## Development ```bash @@ -115,3 +285,14 @@ cargo fmt --check cargo clippy --all-targets --all-features cargo test ``` + +## Design Goals + +- Rust 2021. +- Small files and short functions. +- Clear `Result` errors. +- Minimal dependencies. +- No framework. +- No JWT dependency. +- No panic in library code for normal error paths. +- Avoid unnecessary allocation where the code can stay clear. diff --git a/README.pt.md b/README.pt.md index 4cdc191..074334f 100644 --- a/README.pt.md +++ b/README.pt.md @@ -2,29 +2,281 @@ Tokens nativos assinados e selados, mínimos, para binários Rust standalone. -Formato principal: +`hash_token_rust` é para programas pequenos que precisam trocar dados usando segredos compartilhados sem trazer uma pilha grande de autenticação, arquivos de chave pública/privada, certificados, serviços, frameworks ou JWT como formato principal. + +Ele oferece dois modos nativos de token: ```text htr1... hte1... ``` -`htr1` assina dados, mas nao esconde. `hte1` criptografa e autentica o payload com ChaCha20-Poly1305 usando chave derivada do segredo compartilhado e do salt selecionado. +- `htr1` é assinado: o payload é legível, mas alterações são detectadas. +- `hte1` é selado: o payload é criptografado e autenticado. + +## Por Que Isto Existe + +Este crate foi desenhado para binários standalone que pertencem ao mesmo sistema e podem compartilhar um segredo mais salts. Um caso típico: + +- binário A emite um payload pequeno; +- binário B valida que o payload veio de alguém que conhece o segredo; +- tokens podem expirar; +- tokens podem ser limitados por issuer e audience; +- salts podem ser rotacionados ou selecionados explicitamente; +- não é necessário `.pem`, `.pub`, cadeia de certificados, serviço central ou framework pesado. + +Isto não é uma biblioteca de hash de senha. Não substitui Argon2, bcrypt ou scrypt. É um gerenciador compacto de tokens para autenticar e, no modo selado, criptografar dados trocados entre binários confiáveis. + +## Modos De Token + +### Tokens Assinados: `htr1` + +Use tokens assinados quando o payload pode ser legível, mas não pode ser modificado. + +A assinatura autentica: + +- versão do token; +- payload codificado; +- metadata codificado; +- salt selecionado; +- segredo compartilhado. + +Bons usos: + +- identificadores de usuário ou job; +- comandos que não são secretos; +- tokens curtos de passagem entre processos; +- mensagens internas onde integridade importa. + +### Tokens Selados: `hte1` + +Use tokens selados quando o payload não deve ser legível por quem vê o token. + +Tokens selados usam `ChaCha20-Poly1305`, uma cifra AEAD. A chave de criptografia é derivada do segredo do manager e do salt selecionado. Metadata e nonce são autenticados como dados associados, então alterá-los invalida o token. + +Bons usos: + +- dados sensíveis de usuário; +- mensagens internas privadas; +- payloads que precisam de integridade e confidencialidade. + +## Instalação + +```toml +[dependencies] +hash_token_rust = "0.3" +``` + +Usando o repositório diretamente: + +```toml +[dependencies] +hash_token_rust = { path = "../hashTokenRust" } +``` + +## Criando Um Manager + +```rust +use hash_token_rust::{AdvancedTokenManager, Algorithm}; + +let mut manager = AdvancedTokenManager::new( + b"very-secure-secret", + &[b"salt-a".as_slice(), b"salt-b".as_slice()], + Algorithm::Sha256, +)?; +# Ok::<(), Box>(()) +``` + +O manager precisa de: + +| Campo | Significado | +| --- | --- | +| `secret` | Segredo compartilhado pelos binários que devem confiar entre si. Deve ter pelo menos 16 bytes. | +| `salts` | Um ou mais salts não vazios. O índice do salt selecionado fica no metadata. | +| `algorithm` | Algoritmo HMAC para tokens assinados e derivação de chave. `Sha256` ou `Sha512`. | + +## Exemplo De Token Assinado + +```rust +use hash_token_rust::{ + AdvancedTokenManager, Algorithm, GenerateTokenOptions, ValidateTokenOptions, +}; + +let mut manager = AdvancedTokenManager::new( + b"very-secure-secret", + &[b"salt-a".as_slice(), b"salt-b".as_slice()], + Algorithm::Sha256, +)?; + +let token = manager.generate_token( + "user-id=123", + GenerateTokenOptions { + expires_in: Some(300), + issuer: Some("bin-a"), + audience: Some("bin-b"), + ..Default::default() + }, +)?; + +let verified = manager.validate_token( + &token, + ValidateTokenOptions { + issuer: Some("bin-a"), + audience: Some("bin-b"), + ..Default::default() + }, +)?; + +assert_eq!(verified.payload, "user-id=123"); +assert_eq!(verified.issuer.as_deref(), Some("bin-a")); +# Ok::<(), Box>(()) +``` + +Quando você precisa apenas do payload: -## Uso +```rust +let payload = manager.validate_payload( + &token, + ValidateTokenOptions { + issuer: Some("bin-a"), + audience: Some("bin-b"), + ..Default::default() + }, +)?; -Use quando seus próprios binários precisam trocar dados assinados sem chaves públicas, certificados, serviços, frameworks ou muitas dependências. +assert_eq!(payload, "user-id=123"); +# Ok::<(), Box>(()) +``` + +## Exemplo De Token Selado + +```rust +let token = manager.seal_token( + "email=user@example.com", + GenerateTokenOptions { + expires_in: Some(300), + issuer: Some("bin-a"), + audience: Some("bin-b"), + ..Default::default() + }, +)?; + +let verified = manager.open_token( + &token, + ValidateTokenOptions { + issuer: Some("bin-a"), + audience: Some("bin-b"), + ..Default::default() + }, +)?; + +assert_eq!(verified.payload, "email=user@example.com"); +# Ok::<(), Box>(()) +``` + +## Payloads Binários + +Use as APIs de bytes quando o payload não é UTF-8. + +```rust +let token = manager.generate_token_bytes( + &[0, 1, 2, 255], + GenerateTokenOptions::default(), +)?; + +let verified = manager.validate_token_bytes( + &token, + ValidateTokenOptions::default(), +)?; + +assert_eq!(verified.payload, vec![0, 1, 2, 255]); +# Ok::<(), Box>(()) +``` + +Para payloads binários criptografados: + +```rust +let token = manager.seal_token_bytes( + &[0, 1, 2, 255], + GenerateTokenOptions::default(), +)?; + +let verified = manager.open_token_bytes( + &token, + ValidateTokenOptions::default(), +)?; + +assert_eq!(verified.payload, vec![0, 1, 2, 255]); +# Ok::<(), Box>(()) +``` + +## Opções + +### `GenerateTokenOptions` -## Segurança +| Opção | Significado | +| --- | --- | +| `salt_index` | Seleciona um salt específico. Se ausente, um índice aleatório é usado. | +| `expires_in` | Adiciona expiração em segundos a partir de `issued_at`. | +| `issuer` | Identifica quem criou o token. | +| `audience` | Identifica quem deve aceitar o token. | +| `issued_at` | Sobrescreve o timestamp de emissão. Útil para testes. | -- `htr1` assina dados; nao esconde dados. +### `ValidateTokenOptions` + +| Opção | Significado | +| --- | --- | +| `max_age` | Rejeita tokens mais antigos que este número de segundos com base em `issued_at`. | +| `issuer` | Exige que o issuer do token seja igual. | +| `audience` | Exige que a audience do token seja igual. | +| `clock_tolerance` | Permite pequena diferença de relógio em segundos. | +| `clock_timestamp` | Sobrescreve o horário atual. Útil para testes. | + +## Saída Validada + +`validate_token` e `open_token` retornam `VerifiedToken`: + +```rust +pub struct VerifiedToken { + pub payload: String, + pub issued_at: u64, + pub expires_at: Option, + pub issuer: Option, + pub audience: Option, + pub salt_index: usize, + pub algorithm: String, +} +``` + +As APIs de bytes retornam `VerifiedBytes`, com o mesmo metadata e payload `Vec`. + +## Rotação De Salts + +Tokens armazenam o índice do salt selecionado no metadata. Isso deixa a rotação simples: + +- mantenha salts antigos disponíveis enquanto tokens antigos ainda podem ser válidos; +- gere novos tokens com o novo índice de salt; +- remova salts antigos apenas depois que todos os tokens antigos expirarem. + +Se os binários não compartilham o mesmo segredo e a mesma lista de salts, a validação ou abertura falha. + +## Notas De Segurança + +- `htr1` assina dados; não esconde dados. - `hte1` sela dados; criptografa e autentica o payload. -- Use tokens assinados para autenticidade e tokens selados para sigilo do payload. -- Use segredo forte e salts rotacionados com intenção. -- `validate_token` retorna payload e metadata validados. -- `validate_payload` existe quando só o payload importa. -- `generate_token_bytes` e `validate_token_bytes` suportam payloads que nao sao UTF-8. -- `seal_token_bytes` e `open_token_bytes` suportam payloads criptografados que nao sao UTF-8. +- Tokens assinados servem para autenticidade e integridade. +- Tokens selados servem para autenticidade, integridade e sigilo do payload. +- Use segredos compartilhados com alta entropia. +- Rotacione salts deliberadamente. +- Use tempos de vida curtos quando tokens atravessam processos ou máquinas. +- Não use isto como hash de senha. +- Se um segredo compartilhado vazar, os tokens desse grupo de confiança devem ser considerados comprometidos. + +## Exemplos + +```bash +cargo run --example native_signed +``` ## Desenvolvimento @@ -33,3 +285,14 @@ cargo fmt --check cargo clippy --all-targets --all-features cargo test ``` + +## Metas De Design + +- Rust 2021. +- Arquivos pequenos e funções curtas. +- Erros claros com `Result`. +- Dependências mínimas. +- Sem framework. +- Sem dependência de JWT. +- Sem panic em código de biblioteca para erros normais. +- Evitar alocação desnecessária quando o código pode continuar claro. diff --git a/src/base64url.rs b/src/base64url.rs index 0531464..b54718d 100644 --- a/src/base64url.rs +++ b/src/base64url.rs @@ -1,3 +1,8 @@ +//! Strict unpadded Base64URL helpers. +//! +//! Decoding rejects empty, malformed, and non-canonical segments. That keeps +//! token parsing deterministic and avoids accepting multiple encodings for the +//! same bytes. use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine; @@ -8,6 +13,8 @@ pub(crate) fn encode(input: &[u8]) -> String { } pub(crate) fn decode(input: &str, name: &str) -> Result, TokenError> { + // Padding e caracteres fora do alfabeto URL-safe sao rejeitados antes da lib + // tentar normalizar qualquer coisa. if input.is_empty() || !input.bytes().all(is_allowed) { return Err(TokenError::new(format!("Invalid {} encoding.", name))); } @@ -15,6 +22,7 @@ pub(crate) fn decode(input: &str, name: &str) -> Result, TokenError> { .decode(input) .map_err(|_| TokenError::new(format!("Malformed {} encoding.", name)))?; if encode(&decoded) != input { + // Reencode e compara para garantir uma unica forma valida para o mesmo dado. return Err(TokenError::new(format!("Non-canonical {} encoding.", name))); } Ok(decoded) diff --git a/src/crypto.rs b/src/crypto.rs index c6ac735..2297490 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -1,3 +1,7 @@ +//! Cryptographic primitives used by the token formats. +//! +//! Signed mode uses HMAC. Sealed mode derives a 32-byte AEAD key from the same +//! secret and selected salt using domain-separated HMAC input. use hmac::{Hmac, Mac}; use sha2::{Sha256, Sha512}; @@ -10,6 +14,8 @@ pub(crate) fn sign( salt: &[u8], input: &[u8], ) -> Result, TokenError> { + // Mantemos a escolha do algoritmo aqui para o resto do codigo nao conhecer + // detalhes de HMAC/SHA. match algorithm { Algorithm::Sha256 => hmac_sha256(secret, salt, input), Algorithm::Sha512 => hmac_sha512(secret, salt, input), @@ -20,6 +26,7 @@ pub(crate) fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { if left.len() != right.len() { return false; } + // Keep comparison work independent from byte contents once lengths match. let mut diff = 0u8; for (a, b) in left.iter().zip(right) { diff |= a ^ b; @@ -32,6 +39,8 @@ pub(crate) fn sealing_key( secret: &[u8], salt: &[u8], ) -> Result<[u8; 32], TokenError> { + // String fixa separa o uso "sealed" do uso "signed"; mesmo segredo e salt + // nao geram material equivalente para finalidades diferentes. let digest = sign(algorithm, secret, salt, b"hash-token-rust:sealed:v1")?; let mut key = [0u8; 32]; key.copy_from_slice(&digest[..32]); @@ -39,6 +48,7 @@ pub(crate) fn sealing_key( } fn hmac_sha256(secret: &[u8], salt: &[u8], input: &[u8]) -> Result, TokenError> { + // O salt entra depois do input para manter signing_input legivel e estavel. let mut mac = Hmac::::new_from_slice(secret).map_err(|_| TokenError::new("Invalid HMAC key."))?; mac.update(input); @@ -47,6 +57,7 @@ fn hmac_sha256(secret: &[u8], salt: &[u8], input: &[u8]) -> Result, Toke } fn hmac_sha512(secret: &[u8], salt: &[u8], input: &[u8]) -> Result, TokenError> { + // Mesma regra do SHA-256, apenas com digest maior. let mut mac = Hmac::::new_from_slice(secret).map_err(|_| TokenError::new("Invalid HMAC key."))?; mac.update(input); diff --git a/src/error.rs b/src/error.rs index 6db7518..aa7ab15 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,6 +1,8 @@ +//! Error type returned by token generation, validation, sealing, and opening. use std::error::Error; use std::fmt::{self, Display}; +/// Small owned error with a human-readable validation or crypto failure. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TokenError { message: String, diff --git a/src/lib.rs b/src/lib.rs index ab85e53..f0232c9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,13 @@ +//! Minimal native tokens for standalone Rust programs. +//! +//! `hash_token_rust` signs or seals small pieces of data using a shared +//! secret, one or more salts, and explicit validation options. Signed tokens +//! keep the payload readable and protected by HMAC. Sealed tokens encrypt the +//! payload and authenticate the metadata. +//! +//! The crate is intentionally small: the public entry point is +//! [`AdvancedTokenManager`], options are plain structs, and failures return +//! [`TokenError`] instead of panicking. mod base64url; mod crypto; mod error; @@ -12,4 +22,5 @@ pub use error::TokenError; pub use manager::{AdvancedTokenManager, Algorithm}; pub use options::{GenerateTokenOptions, ValidateTokenOptions, VerifiedBytes, VerifiedToken}; -pub const LIBRARY_VERSION: &str = "0.3.0"; +/// Current library version exposed for binaries that want to log or compare it. +pub const LIBRARY_VERSION: &str = "0.3.5"; diff --git a/src/manager.rs b/src/manager.rs index a6c045d..809e1b1 100644 --- a/src/manager.rs +++ b/src/manager.rs @@ -1,3 +1,8 @@ +//! Core manager state and algorithm selection. +//! +//! The manager owns copied secret material so generated and validated tokens do +//! not borrow from caller-owned buffers. Salt selection is explicit when the +//! caller passes an index and randomized otherwise. use rand::Rng; use crate::crypto; @@ -6,9 +11,12 @@ use crate::error::TokenError; const MIN_SECRET_LENGTH: usize = 16; const MIN_SALT_COUNT: usize = 1; +/// HMAC family used by signed tokens and sealed-token key derivation. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Algorithm { + /// HMAC-SHA-256. Sha256, + /// HMAC-SHA-512. Sha512, } @@ -21,6 +29,11 @@ impl Algorithm { } } +/// Token manager configured with one shared secret, salts, and an algorithm. +/// +/// Use one manager per trust domain. Different binaries can validate each +/// other's tokens when they share the same secret, salts, algorithm, and +/// validation rules. pub struct AdvancedTokenManager { pub(crate) secret: Vec, pub(crate) salts: Vec>, @@ -29,6 +42,10 @@ pub struct AdvancedTokenManager { } impl AdvancedTokenManager { + /// Creates a manager after validating the secret and salt set. + /// + /// The secret must contain at least 16 bytes and at least one non-empty + /// salt is required. Inputs are copied so the manager can be moved freely. pub fn new(secret: &[u8], salts: &[&[u8]], algorithm: Algorithm) -> Result { validate_secret(secret)?; validate_salts(salts)?; @@ -41,6 +58,8 @@ impl AdvancedTokenManager { } pub(crate) fn select_salt(&mut self, requested: Option) -> Result { + // Quando o caller pede um salt especifico, respeitamos. Quando nao pede, + // alternamos aleatoriamente para nao ficar sempre no mesmo indice. match requested { Some(index) => self.validate_salt_index(index).map(|_| index), None => Ok(self.random_salt_index()), @@ -72,6 +91,8 @@ impl AdvancedTokenManager { let mut rng = rand::thread_rng(); loop { let index = rng.gen_range(0..self.salts.len()); + // Com mais de um salt, evita repetir o mesmo indice em chamadas + // consecutivas; com um unico salt, sai imediatamente. if Some(index) != self.last_salt_index || self.salts.len() == 1 { self.last_salt_index = Some(index); return index; diff --git a/src/meta.rs b/src/meta.rs index 21aaadf..6f60824 100644 --- a/src/meta.rs +++ b/src/meta.rs @@ -1,3 +1,8 @@ +//! Internal metadata shared by signed and sealed token formats. +//! +//! Metadata stores only routing and validation fields. In signed tokens it is +//! authenticated by the signature. In sealed tokens it is authenticated as AEAD +//! associated data. mod decode; mod encode; mod parse; diff --git a/src/meta/decode.rs b/src/meta/decode.rs index 12cd9aa..f31610d 100644 --- a/src/meta/decode.rs +++ b/src/meta/decode.rs @@ -1,8 +1,14 @@ +//! Metadata decoding and field-count validation. +//! +//! The parser is intentionally fixed-width: changing the number of fields must +//! be a format version change, not something accepted silently. use crate::base64url; use crate::error::TokenError; use crate::meta::Meta; pub(crate) fn meta(encoded: &str) -> Result { + // Metadata tambem usa o decoder estrito. Se houver padding, caractere estranho + // ou representacao nao canonica, o token cai fora cedo. let bytes = base64url::decode(encoded, "metadata")?; let text = std::str::from_utf8(&bytes).map_err(|_| TokenError::new("Metadata is not UTF-8."))?; @@ -10,6 +16,8 @@ pub(crate) fn meta(encoded: &str) -> Result { } pub(crate) fn decode_optional(value: &str, name: &str) -> Result, TokenError> { + // Em metadata, string vazia e ausencia do campo; string presente precisa ser + // Base64URL valido e UTF-8. if value.is_empty() { return Ok(None); } @@ -20,6 +28,7 @@ pub(crate) fn decode_optional(value: &str, name: &str) -> Result, } fn parse_meta(text: &str) -> Result { + // Parser propositalmente chato: cada campo tem posicao fixa e erro claro. let mut fields = text.split('|'); let meta = Meta { algorithm: super::required(next(&mut fields)?, "algorithm")?.to_string(), @@ -34,12 +43,15 @@ fn parse_meta(text: &str) -> Result { } fn next<'a>(fields: &mut impl Iterator) -> Result<&'a str, TokenError> { + // Faltar campo e erro de estrutura, nao valor vazio. fields .next() .ok_or_else(|| TokenError::new("Invalid metadata field count.")) } fn reject_extra<'a>(mut fields: impl Iterator) -> Result<(), TokenError> { + // Campo extra indica formato desconhecido. Melhor rejeitar do que tentar + // interpretar parcialmente. if fields.next().is_some() { Err(TokenError::new("Invalid metadata field count.")) } else { diff --git a/src/meta/encode.rs b/src/meta/encode.rs index 9a5be67..26af529 100644 --- a/src/meta/encode.rs +++ b/src/meta/encode.rs @@ -1,9 +1,16 @@ +//! Metadata encoding for the compact native format. +//! +//! Text claims are encoded as Base64URL inside metadata before the whole +//! metadata block is encoded. That keeps separators unambiguous. use crate::base64url; use crate::error::TokenError; use crate::meta::Meta; pub(crate) fn meta(meta: &Meta) -> Result { + // A metadata e texto pequeno e previsivel. Reservar um pouco reduz realocacao + // sem tentar calcular cada byte antes da hora. let mut text = String::with_capacity(96); + // Campos fixos e sempre na mesma ordem. Se mudar a ordem, muda o formato. push_field(&mut text, &meta.algorithm); push_field(&mut text, &meta.salt_index.to_string()); push_field(&mut text, &meta.issued_at.to_string()); @@ -20,12 +27,15 @@ pub(crate) fn meta(meta: &Meta) -> Result { } pub(crate) fn encode_optional(value: Option<&str>) -> String { + // Campo vazio representa None. Campo presente passa por Base64URL para nunca + // quebrar o separador interno '|'. value .map(|text| base64url::encode(text.as_bytes())) .unwrap_or_default() } fn push_field(output: &mut String, value: &str) { + // Separador simples porque claims textuais ja foram escapadas por Base64URL. output.push_str(value); output.push('|'); } diff --git a/src/meta/parse.rs b/src/meta/parse.rs index a8df91b..59e033e 100644 --- a/src/meta/parse.rs +++ b/src/meta/parse.rs @@ -1,6 +1,9 @@ +//! Small parsing helpers for metadata numeric fields. use crate::error::TokenError; pub(crate) fn required<'a>(value: &'a str, name: &str) -> Result<&'a str, TokenError> { + // Campos obrigatorios vazios geralmente significam metadata corrompida ou + // token montado manualmente de forma errada. if value.is_empty() { Err(TokenError::new(format!("Missing {}.", name))) } else { @@ -9,18 +12,21 @@ pub(crate) fn required<'a>(value: &'a str, name: &str) -> Result<&'a str, TokenE } pub(crate) fn parse_usize(value: &str, name: &str) -> Result { + // Salt index precisa ser numero local; a validacao de faixa acontece no manager. required(value, name)? .parse() .map_err(|_| TokenError::new(format!("Invalid {}.", name))) } pub(crate) fn parse_u64(value: &str, name: &str) -> Result { + // Timestamps sao segundos UNIX para manter o formato portavel entre binarios. required(value, name)? .parse() .map_err(|_| TokenError::new(format!("Invalid {}.", name))) } pub(crate) fn parse_optional_u64(value: &str, name: &str) -> Result, TokenError> { + // Expiracao vazia significa token sem exp interno; max_age ainda pode limitar. if value.is_empty() { Ok(None) } else { diff --git a/src/options.rs b/src/options.rs index 5f01715..5cbead4 100644 --- a/src/options.rs +++ b/src/options.rs @@ -1,40 +1,73 @@ +//! Public option and verification result types. +//! +//! Options are structs instead of long argument lists so callers can set only +//! the validation rules that matter for each token flow. + +/// Controls how a signed or sealed token is created. #[derive(Clone, Debug, Default)] pub struct GenerateTokenOptions<'a> { + /// Salt index to use. When omitted, the manager selects one randomly. pub salt_index: Option, + /// Relative expiration in seconds, added to `issued_at`. pub expires_in: Option, + /// Optional issuer value stored in metadata. pub issuer: Option<&'a str>, + /// Optional audience value stored in metadata. pub audience: Option<&'a str>, + /// Fixed issued-at timestamp for deterministic tests or external clocks. pub issued_at: Option, } +/// Controls how a signed or sealed token is validated. #[derive(Clone, Debug, Default)] pub struct ValidateTokenOptions<'a> { + /// Maximum token age in seconds, measured from `issued_at`. pub max_age: Option, + /// Required issuer. Validation fails when metadata is missing or different. pub issuer: Option<&'a str>, + /// Required audience. Validation fails when metadata is missing or different. pub audience: Option<&'a str>, + /// Seconds allowed around expiration and max-age checks. pub clock_tolerance: Option, + /// Fixed current timestamp for deterministic tests or external clocks. pub clock_timestamp: Option, } +/// Verified UTF-8 payload plus the metadata that was authenticated. #[derive(Clone, Debug, PartialEq, Eq)] pub struct VerifiedToken { + /// Original UTF-8 payload. pub payload: String, + /// Token creation timestamp. pub issued_at: u64, + /// Optional absolute expiration timestamp. pub expires_at: Option, + /// Optional issuer from metadata. pub issuer: Option, + /// Optional audience from metadata. pub audience: Option, + /// Salt index used to sign or seal the token. pub salt_index: usize, + /// Algorithm name stored in metadata, such as `HS256`. pub algorithm: String, } +/// Verified binary payload plus the metadata that was authenticated. #[derive(Clone, Debug, PartialEq, Eq)] pub struct VerifiedBytes { + /// Original payload bytes. pub payload: Vec, + /// Token creation timestamp. pub issued_at: u64, + /// Optional absolute expiration timestamp. pub expires_at: Option, + /// Optional issuer from metadata. pub issuer: Option, + /// Optional audience from metadata. pub audience: Option, + /// Salt index used to sign or seal the token. pub salt_index: usize, + /// Algorithm name stored in metadata, such as `HS512`. pub algorithm: String, } diff --git a/src/sealed.rs b/src/sealed.rs index 6638c4f..606cc88 100644 --- a/src/sealed.rs +++ b/src/sealed.rs @@ -1,3 +1,8 @@ +//! Public sealed-token API. +//! +//! Sealed tokens use the `hte1.ciphertext.metadata.nonce` shape. The payload is +//! encrypted with ChaCha20-Poly1305 and metadata is authenticated as associated +//! data. mod build; mod open; mod parts; @@ -9,6 +14,7 @@ use crate::options::{GenerateTokenOptions, ValidateTokenOptions, VerifiedBytes, pub(crate) const VERSION: &str = "hte1"; impl AdvancedTokenManager { + /// Encrypts and authenticates a UTF-8 payload. pub fn seal_token( &mut self, payload: &str, @@ -17,6 +23,7 @@ impl AdvancedTokenManager { self.seal_token_bytes(payload.as_bytes(), options) } + /// Encrypts and authenticates raw payload bytes. pub fn seal_token_bytes( &mut self, payload: &[u8], @@ -25,6 +32,7 @@ impl AdvancedTokenManager { build::token(self, payload, &options) } + /// Opens a sealed token and returns a UTF-8 payload. pub fn open_token( &self, token: &str, @@ -44,6 +52,7 @@ impl AdvancedTokenManager { }) } + /// Opens a sealed token and returns raw payload bytes. pub fn open_token_bytes( &self, token: &str, diff --git a/src/sealed/build.rs b/src/sealed/build.rs index 6fce1e1..5bf146b 100644 --- a/src/sealed/build.rs +++ b/src/sealed/build.rs @@ -1,3 +1,7 @@ +//! Sealed-token construction. +//! +//! A fresh 96-bit nonce is generated per token. Metadata and nonce are included +//! as associated data so they cannot be swapped without failing decryption. use chacha20poly1305::aead::{Aead, Payload}; use chacha20poly1305::{ChaCha20Poly1305, KeyInit, Nonce}; use rand::RngCore; @@ -16,6 +20,8 @@ pub(crate) fn token( payload: &[u8], options: &GenerateTokenOptions<'_>, ) -> Result { + // A geracao segue a ordem do formato final: salt/meta, nonce, criptografia, + // montagem. Isso evita manter estados intermediarios maiores que o necessario. let salt_index = manager.select_salt(options.salt_index)?; let meta = meta(manager, salt_index, options)?; let encoded_meta = meta.encode()?; @@ -41,6 +47,8 @@ fn meta( salt_index: usize, options: &GenerateTokenOptions<'_>, ) -> Result { + // Metadata fica fora do ciphertext para permitir roteamento e validacao + // basica sem expor o payload sensivel. let issued_at = options.issued_at.map_or_else(crate::validate::now, Ok)?; Ok(Meta { algorithm: manager.algorithm.name().to_string(), @@ -60,6 +68,8 @@ fn seal( encoded_nonce: &str, nonce: &[u8; 12], ) -> Result, TokenError> { + // A chave de sealed mode nasce do mesmo segredo e salt, mas com dominio + // separado em crypto::sealing_key para nao reutilizar HMAC cru como AEAD key. let key = crypto::sealing_key( manager.algorithm, &manager.secret, @@ -78,12 +88,15 @@ fn seal( } fn nonce() -> [u8; 12] { + // Nonce aleatorio por token. Reusar nonce com a mesma chave destruiria a + // seguranca do ChaCha20-Poly1305. let mut nonce = [0u8; 12]; rand::rngs::OsRng.fill_bytes(&mut nonce); nonce } fn assemble(ciphertext: &str, meta: &str, nonce: &str) -> Result { + // Montagem manual evita format! e aloca uma vez com capacidade conhecida. let mut token = String::with_capacity(VERSION.len() + ciphertext.len() + meta.len() + nonce.len() + 3); token.push_str(VERSION); diff --git a/src/sealed/open.rs b/src/sealed/open.rs index 4cae9e4..b871202 100644 --- a/src/sealed/open.rs +++ b/src/sealed/open.rs @@ -1,3 +1,7 @@ +//! Sealed-token opening and authenticated decryption. +//! +//! Validation runs before decryption so unknown salts, wrong algorithms, and +//! expired metadata are rejected without touching the payload. use chacha20poly1305::aead::{Aead, Payload}; use chacha20poly1305::{ChaCha20Poly1305, KeyInit, Nonce}; @@ -15,6 +19,8 @@ pub(crate) fn token( token: &str, options: &ValidateTokenOptions<'_>, ) -> Result { + // Primeiro desmonta e valida metadata. A chave depende do salt index, entao + // ele precisa ser confiavel antes de tentar abrir o payload. let parts = split(token)?; let meta = Meta::decode(parts.meta)?; validate::metadata(manager, &meta, options)?; @@ -35,6 +41,8 @@ fn open( nonce: &str, salt_index: usize, ) -> Result, TokenError> { + // Ciphertext e nonce continuam em Base64URL no token para manter o formato + // seguro para trafegar em texto, env var, URL e logs controlados. let ciphertext = base64url::decode(ciphertext, "ciphertext")?; let nonce_bytes = decode_nonce(nonce)?; let key = crypto::sealing_key( @@ -43,6 +51,8 @@ fn open( &manager.salts[salt_index], )?; let cipher = ChaCha20Poly1305::new((&key).into()); + // O mesmo AAD usado ao selar precisa bater aqui. Se metadata ou nonce mudar, + // a autenticacao falha antes de devolver qualquer byte. cipher .decrypt( Nonce::from_slice(&nonce_bytes), @@ -56,6 +66,8 @@ fn open( fn decode_nonce(encoded: &str) -> Result<[u8; 12], TokenError> { let nonce = base64url::decode(encoded, "nonce")?; + // ChaCha20-Poly1305 usa nonce de 96 bits. Qualquer outro tamanho e formato + // invalido, mesmo se o Base64URL estiver bem formado. nonce .try_into() .map_err(|_| TokenError::new("Invalid nonce length.")) diff --git a/src/sealed/parts.rs b/src/sealed/parts.rs index 0ce018a..b2b0e1f 100644 --- a/src/sealed/parts.rs +++ b/src/sealed/parts.rs @@ -1,3 +1,4 @@ +//! Sealed-token splitting and associated-data assembly. use crate::error::TokenError; use crate::sealed::VERSION; @@ -8,6 +9,8 @@ pub(crate) struct Parts<'a> { } pub(crate) fn split(token: &str) -> Result, TokenError> { + // O formato sealed tem sempre 4 partes. Se aceitar mais ou menos que isso, + // fica facil abrir margem para token truncado ou token montado errado. let mut fields = token.split('.'); let version = fields.next().unwrap_or_default(); let ciphertext = fields.next().unwrap_or_default(); @@ -22,6 +25,8 @@ pub(crate) fn split(token: &str) -> Result, TokenError> { } pub(crate) fn aad(meta: &str, nonce: &str) -> String { + // AAD e o pedaco que nao fica criptografado, mas entra na autenticacao. + // Assim metadata e nonce nao podem ser trocados entre tokens. let mut input = String::with_capacity(VERSION.len() + meta.len() + nonce.len() + 2); input.push_str(VERSION); input.push('.'); @@ -38,6 +43,8 @@ fn reject_bad_shape( nonce: &str, extra: Option<&str>, ) -> Result<(), TokenError> { + // Segmento vazio aqui quase sempre indica token cortado, separador sobrando + // ou tentativa de adaptar outro formato para este parser. if version != VERSION || ciphertext.is_empty() || meta.is_empty() diff --git a/src/token.rs b/src/token.rs index b8d3ed5..639026c 100644 --- a/src/token.rs +++ b/src/token.rs @@ -1,3 +1,7 @@ +//! Public signed-token API. +//! +//! Signed tokens use the `htr1.payload.metadata.signature` shape. The payload is +//! readable Base64URL, while metadata and payload are protected by HMAC. pub(crate) mod build; mod parts; @@ -10,6 +14,7 @@ use crate::validate; pub(crate) const VERSION: &str = "htr1"; impl AdvancedTokenManager { + /// Generates a signed token from a UTF-8 payload. pub fn generate_token( &mut self, payload: &str, @@ -18,6 +23,7 @@ impl AdvancedTokenManager { self.generate_token_bytes(payload.as_bytes(), options) } + /// Generates a signed token from raw bytes. pub fn generate_token_bytes( &mut self, payload: &[u8], @@ -26,6 +32,7 @@ impl AdvancedTokenManager { build::token(self, payload, &options) } + /// Validates a signed token and returns a UTF-8 payload. pub fn validate_token( &self, token: &str, @@ -54,6 +61,7 @@ impl AdvancedTokenManager { }) } + /// Validates a signed token and returns only the UTF-8 payload. pub fn validate_payload( &self, token: &str, @@ -63,6 +71,7 @@ impl AdvancedTokenManager { .map(|verified| verified.payload) } + /// Validates a signed token and returns raw payload bytes. pub fn validate_token_bytes( &self, token: &str, diff --git a/src/token/build.rs b/src/token/build.rs index 5b88ba3..a9f435d 100644 --- a/src/token/build.rs +++ b/src/token/build.rs @@ -1,3 +1,7 @@ +//! Signed-token construction. +//! +//! The signature covers the format version, encoded payload, and encoded +//! metadata. The final signature segment is encoded last to avoid re-parsing. use crate::base64url; use crate::error::TokenError; use crate::manager::AdvancedTokenManager; @@ -11,6 +15,8 @@ pub(crate) fn token( payload: &[u8], options: &GenerateTokenOptions<'_>, ) -> Result { + // Payload e metadata sao codificados antes da assinatura porque a assinatura + // protege o formato textual final, nao uma representacao paralela. let salt_index = manager.select_salt(options.salt_index)?; let meta = meta(manager, salt_index, options)?; let encoded_payload = base64url::encode(payload); @@ -31,6 +37,7 @@ fn meta( salt_index: usize, options: &GenerateTokenOptions<'_>, ) -> Result { + // issued_at pode vir de fora para testes e sistemas com relogio centralizado. let issued_at = options.issued_at.map_or_else(crate::validate::now, Ok)?; Ok(Meta { algorithm: manager.algorithm.name().to_string(), @@ -43,6 +50,7 @@ fn meta( } fn assemble(payload: &str, meta: &str, signature: &str) -> Result { + // Montagem manual pequena: menos alocacao intermediaria e formato explicito. let mut token = String::with_capacity(VERSION.len() + payload.len() + meta.len() + signature.len() + 3); token.push_str(VERSION); @@ -59,6 +67,7 @@ pub(crate) fn expiration( issued_at: u64, expires_in: Option, ) -> Result, TokenError> { + // checked_add evita wrap silencioso quando expires_in vier grande demais. expires_in .map(|seconds| { issued_at diff --git a/src/token/parts.rs b/src/token/parts.rs index 8a5970f..61e1bec 100644 --- a/src/token/parts.rs +++ b/src/token/parts.rs @@ -1,3 +1,5 @@ +//! Signed-token splitting and signing input assembly. + use crate::error::TokenError; use crate::token::VERSION; @@ -8,6 +10,7 @@ pub(crate) struct Parts<'a> { } pub(crate) fn split(token: &str) -> Result, TokenError> { + // Token assinado tem 4 partes fixas. Nao tentamos recuperar formato quebrado. let mut fields = token.split('.'); let version = fields.next().unwrap_or_default(); let payload = fields.next().unwrap_or_default(); @@ -22,6 +25,8 @@ pub(crate) fn split(token: &str) -> Result, TokenError> { } pub(crate) fn signing_input(payload: &str, meta: &str) -> String { + // A assinatura cobre exatamente o que sera transportado antes dela. + // Isso evita diferenca entre bytes assinados e bytes serializados. let mut input = String::with_capacity(VERSION.len() + payload.len() + meta.len() + 2); input.push_str(VERSION); input.push('.'); @@ -38,6 +43,8 @@ fn reject_bad_shape( signature: &str, extra: Option<&str>, ) -> Result<(), TokenError> { + // Segmentos vazios sao rejeitados antes de Base64URL para dar erro de + // estrutura, nao erro criptico de encoding. if version != VERSION || payload.is_empty() || meta.is_empty() diff --git a/src/validate.rs b/src/validate.rs index e229690..da080f7 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -1,3 +1,7 @@ +//! Shared validation pipeline for signed and sealed tokens. +//! +//! Validation is deliberately ordered: salt index, algorithm, time, and scope +//! are checked before signature verification or decryption uses metadata. mod scope; mod signature; mod time; @@ -15,6 +19,8 @@ pub(crate) fn metadata( meta: &Meta, options: &ValidateTokenOptions<'_>, ) -> Result<(), TokenError> { + // Ordem importante: primeiro garante que a metadata aponta para algo que este + // manager entende, depois aplica regras de tempo e escopo. manager.validate_salt_index(meta.salt_index)?; validate_algorithm(manager, meta)?; time::validate_time(meta, options)?; @@ -22,6 +28,8 @@ pub(crate) fn metadata( } fn validate_algorithm(manager: &AdvancedTokenManager, meta: &Meta) -> Result<(), TokenError> { + // Nao existe downgrade automatico: o algoritmo do token precisa ser o mesmo + // configurado no manager atual. if meta.algorithm == manager.algorithm.name() { Ok(()) } else { diff --git a/src/validate/scope.rs b/src/validate/scope.rs index 72eef9b..8f12f65 100644 --- a/src/validate/scope.rs +++ b/src/validate/scope.rs @@ -1,3 +1,4 @@ +//! Issuer and audience validation. use crate::error::TokenError; use crate::meta::Meta; use crate::options::ValidateTokenOptions; @@ -6,6 +7,8 @@ pub(crate) fn validate_scope( meta: &Meta, options: &ValidateTokenOptions<'_>, ) -> Result<(), TokenError> { + // Escopo e opt-in: se o caller exigir issuer/audience, o token precisa trazer + // exatamente aquele valor. match_required("issuer", meta.issuer.as_deref(), options.issuer)?; match_required("audience", meta.audience.as_deref(), options.audience) } @@ -15,6 +18,7 @@ fn match_required( actual: Option<&str>, expected: Option<&str>, ) -> Result<(), TokenError> { + // Separar missing de mismatch deixa debug e logs mais claros sem expor payload. match (actual, expected) { (_, None) => Ok(()), (Some(actual), Some(expected)) if actual == expected => Ok(()), diff --git a/src/validate/signature.rs b/src/validate/signature.rs index dc87b95..d995620 100644 --- a/src/validate/signature.rs +++ b/src/validate/signature.rs @@ -1,8 +1,10 @@ +//! Signature decoding and constant-time comparison. use crate::base64url; use crate::crypto; use crate::error::TokenError; pub(crate) fn signature(expected: &[u8], encoded: &str) -> Result<(), TokenError> { + // Assinatura recebida tambem passa pelo Base64URL canonico antes de comparar. let provided = base64url::decode(encoded, "signature")?; if crypto::constant_time_eq(expected, &provided) { Ok(()) diff --git a/src/validate/time.rs b/src/validate/time.rs index d453be3..9d4323a 100644 --- a/src/validate/time.rs +++ b/src/validate/time.rs @@ -1,3 +1,7 @@ +//! Time validation for expiration and maximum age. +//! +//! Callers may inject `clock_timestamp` for tests or for binaries that centralize +//! time. Clock tolerance is only a small allowance around configured limits. use std::time::{SystemTime, UNIX_EPOCH}; use crate::error::TokenError; @@ -5,6 +9,7 @@ use crate::meta::Meta; use crate::options::ValidateTokenOptions; pub(crate) fn now() -> Result { + // Retorna Result para nao esconder sistemas com relogio antes do UNIX_EPOCH. SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_secs()) @@ -15,6 +20,8 @@ pub(crate) fn validate_time( meta: &Meta, options: &ValidateTokenOptions<'_>, ) -> Result<(), TokenError> { + // clock_timestamp deixa teste deterministico e tambem ajuda binarios que ja + // recebem o tempo de uma camada externa confiavel. let now = match options.clock_timestamp { Some(value) => value, None => now()?, @@ -25,6 +32,8 @@ pub(crate) fn validate_time( } fn validate_expiration(meta: &Meta, now: u64, tolerance: u64) -> Result<(), TokenError> { + // Tolerancia amplia o exp para compensar drift pequeno, mas overflow continua + // sendo erro porque seria impossivel saber a janela real. if let Some(exp) = meta.expires_at { let exp = exp .checked_add(tolerance) @@ -42,6 +51,7 @@ fn validate_max_age( tolerance: u64, max_age: Option, ) -> Result<(), TokenError> { + // max_age limita vida util mesmo quando o token nao tem exp gravado. if let Some(max_age) = max_age { let age = now .checked_sub(meta.issued_at) diff --git a/version b/version new file mode 100644 index 0000000..09e9157 --- /dev/null +++ b/version @@ -0,0 +1 @@ +0.3.5 \ No newline at end of file