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/Cargo.lock b/Cargo.lock index c58b91d..d3ee124 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" @@ -34,11 +79,12 @@ 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", + "rand_core", "typenum", ] @@ -55,9 +101,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 +111,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 +122,15 @@ dependencies = [ [[package]] name = "hash_token_rust" -version = "0.2.0" +version = "0.3.5" dependencies = [ "base64", - "hex", + "chacha20poly1305", "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" @@ -104,22 +141,36 @@ dependencies = [ ] [[package]] -name = "itoa" -version = "1.0.15" +name = "inout" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] [[package]] name = "libc" -version = "0.2.177" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] -name = "memchr" -version = "2.7.6" +name = "opaque-debug" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +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" @@ -132,27 +183,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 +229,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,9 +248,9 @@ 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", @@ -256,36 +258,26 @@ dependencies = [ ] [[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" +name = "typenum" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] -name = "typenum" -version = "1.19.0" +name = "unicode-ident" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] -name = "unicode-ident" -version = "1.0.22" +name = "universal-hash" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] [[package]] name = "version_check" @@ -301,20 +293,26 @@ 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", "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 82686f0..ce6f43b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,18 +1,15 @@ [package] name = "hash_token_rust" -version = "0.2.0" +version = "0.3.5" 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" +chacha20poly1305 = "0.10" 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 new file mode 100644 index 0000000..ff5b6c2 --- /dev/null +++ b/README.fr.md @@ -0,0 +1,298 @@ +# hash_token_rust + +Tokens natifs signes et scelles, minimaux, pour binaires Rust standalone. + +`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` 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 : + +```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>(()) +``` + +## 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` + +| 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. | + +### `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 + +```bash +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 new file mode 100644 index 0000000..4308224 --- /dev/null +++ b/README.md @@ -0,0 +1,298 @@ +# hash_token_rust + +Minimal native signed and sealed tokens for standalone Rust binaries. + +`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` 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. + +### Sealed Tokens: `hte1` + +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" } +``` + +## 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::{ + 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>(()) +``` + +If you only need the payload: + +```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( + "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>(()) +``` + +## Binary Payloads + +Use the byte APIs when the payload is not 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>(()) +``` + +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 +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 new file mode 100644 index 0000000..074334f --- /dev/null +++ b/README.pt.md @@ -0,0 +1,298 @@ +# hash_token_rust + +Tokens nativos assinados e selados, mínimos, para binários Rust standalone. + +`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` é 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: + +```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>(()) +``` + +## 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` + +| 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. | + +### `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. +- 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 + +```bash +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/SECURITY_NOTES.md b/SECURITY_NOTES.md new file mode 100644 index 0000000..1e5e9ff --- /dev/null +++ b/SECURITY_NOTES.md @@ -0,0 +1,10 @@ +# Security Notes + +- Native `htr1` tokens are signed, not encrypted. +- 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 sealed `hte1` tokens for payload secrecy. diff --git a/examples/native_signed.rs b/examples/native_signed.rs new file mode 100644 index 0000000..bdaef52 --- /dev/null +++ b/examples/native_signed.rs @@ -0,0 +1,53 @@ +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); + + 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/advanced_token_manager.rs b/src/advanced_token_manager.rs deleted file mode 100644 index f3312bc..0000000 --- a/src/advanced_token_manager.rs +++ /dev/null @@ -1,574 +0,0 @@ -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; - -use hmac::{Hmac, Mac}; -use sha2::{Sha256, Sha512}; - -use crate::jwt::{ - sign_jwt, verify_jwt_as, Audience, Issuer, JwtAlgorithm, JwtClaims, JwtError, SignJwtOptions, - VerifyJwtOptions, -}; - -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 { - Sha256, - 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); -} - -#[derive(Clone)] -struct DefaultLogger; - -impl AdvancedTokenManagerLogger for DefaultLogger { - fn warn(&self, message: &str) { - eprintln!("{}", message); - } - - fn error(&self, message: &str) { - eprintln!("{}", message); - } -} - -#[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, -} - -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.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( - secret, - allow_auto_generate, - no_env, - default_secret_length, - &*logger, - )?; - let salts = initialize_salts( - salts, - allow_auto_generate, - no_env, - default_salt_count, - default_salt_length, - &*logger, - )?; - let algorithm = algorithm.unwrap_or(Algorithm::Sha256); - - Ok(Self { - secret, - salts, - algorithm, - last_salt_index: None, - logger, - throw_on_validation_failure, - jwt_default_algorithms, - jwt_max_payload_size, - jwt_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/base64url.rs b/src/base64url.rs new file mode 100644 index 0000000..b54718d --- /dev/null +++ b/src/base64url.rs @@ -0,0 +1,33 @@ +//! 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; + +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> { + // 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))); + } + let decoded = URL_SAFE_NO_PAD + .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) +} + +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..2297490 --- /dev/null +++ b/src/crypto.rs @@ -0,0 +1,66 @@ +//! 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}; + +use crate::error::TokenError; +use crate::manager::Algorithm; + +pub(crate) fn sign( + algorithm: Algorithm, + secret: &[u8], + 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), + } +} + +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; + } + diff == 0 +} + +pub(crate) fn sealing_key( + algorithm: Algorithm, + 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]); + Ok(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); + mac.update(salt); + Ok(mac.finalize().into_bytes().to_vec()) +} + +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); + 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..aa7ab15 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,25 @@ +//! 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, +} + +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 60b1841..0000000 --- a/src/jwt.rs +++ /dev/null @@ -1,749 +0,0 @@ -use std::collections::HashSet; -use std::fmt::{self, Display}; -use std::str::FromStr; -use std::time::{SystemTime, UNIX_EPOCH}; - -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; - -const STANDARD_CLAIMS: [&str; 6] = ["iss", "sub", "aud", "exp", "nbf", "iat"]; -const BASE64URL_ALLOWED: &[u8] = - b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - -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) - } - } - } -} - -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 = 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 header_json = serde_json::to_vec(&header) - .map_err(|_| JwtError::new("JWT: failed to serialize header."))?; - let payload_json = serde_json::to_vec(&Value::Object(claims.clone())) - .map_err(|_| JwtError::new("JWT: failed to serialize payload."))?; - let encoded_header = base64url_encode(header_json); - let encoded_payload = base64url_encode(payload_json); - 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 { - 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) -} - -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 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(()) -} diff --git a/src/lib.rs b/src/lib.rs index 96ac1f1..f0232c9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,37 +1,26 @@ -pub mod advanced_token_manager; -pub mod jwt; +//! 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; +mod manager; +mod meta; +mod options; +mod sealed; +mod token; +mod validate; -pub use advanced_token_manager::{ - AdvancedTokenError, AdvancedTokenManager, AdvancedTokenManagerLogger, - AdvancedTokenManagerOptions, Algorithm, ManagerConfig, ManagerSignJwtOptions, - ManagerVerifyJwtOptions, TokenValidationError, ValidateTokenOptions, -}; +pub use error::TokenError; +pub use manager::{AdvancedTokenManager, Algorithm}; +pub use options::{GenerateTokenOptions, ValidateTokenOptions, VerifiedBytes, VerifiedToken}; -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())); - } -} +/// 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 new file mode 100644 index 0000000..809e1b1 --- /dev/null +++ b/src/manager.rs @@ -0,0 +1,118 @@ +//! 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; +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, +} + +impl Algorithm { + pub(crate) fn name(self) -> &'static str { + match self { + Algorithm::Sha256 => "HS256", + Algorithm::Sha512 => "HS512", + } + } +} + +/// 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>, + pub(crate) algorithm: Algorithm, + last_salt_index: Option, +} + +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)?; + 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 { + // 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()), + } + } + + 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()); + // 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; + } + } + } +} + +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..6f60824 --- /dev/null +++ b/src/meta.rs @@ -0,0 +1,34 @@ +//! 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; + +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..f31610d --- /dev/null +++ b/src/meta/decode.rs @@ -0,0 +1,60 @@ +//! 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."))?; + parse_meta(text) +} + +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); + } + 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 { + // 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(), + 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> { + // 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 { + Ok(()) + } +} diff --git a/src/meta/encode.rs b/src/meta/encode.rs new file mode 100644 index 0000000..26af529 --- /dev/null +++ b/src/meta/encode.rs @@ -0,0 +1,41 @@ +//! 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()); + 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 { + // 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 new file mode 100644 index 0000000..59e033e --- /dev/null +++ b/src/meta/parse.rs @@ -0,0 +1,35 @@ +//! 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 { + Ok(value) + } +} + +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 { + parse_u64(value, name).map(Some) + } +} diff --git a/src/options.rs b/src/options.rs new file mode 100644 index 0000000..5cbead4 --- /dev/null +++ b/src/options.rs @@ -0,0 +1,86 @@ +//! 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, +} + +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/sealed.rs b/src/sealed.rs new file mode 100644 index 0000000..606cc88 --- /dev/null +++ b/src/sealed.rs @@ -0,0 +1,63 @@ +//! 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; + +use crate::error::TokenError; +use crate::manager::AdvancedTokenManager; +use crate::options::{GenerateTokenOptions, ValidateTokenOptions, VerifiedBytes, VerifiedToken}; + +pub(crate) const VERSION: &str = "hte1"; + +impl AdvancedTokenManager { + /// Encrypts and authenticates a UTF-8 payload. + pub fn seal_token( + &mut self, + payload: &str, + options: GenerateTokenOptions<'_>, + ) -> Result { + self.seal_token_bytes(payload.as_bytes(), options) + } + + /// Encrypts and authenticates raw payload bytes. + pub fn seal_token_bytes( + &mut self, + payload: &[u8], + options: GenerateTokenOptions<'_>, + ) -> Result { + build::token(self, payload, &options) + } + + /// Opens a sealed token and returns a UTF-8 payload. + 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, + }) + } + + /// Opens a sealed token and returns raw payload bytes. + 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..5bf146b --- /dev/null +++ b/src/sealed/build.rs @@ -0,0 +1,110 @@ +//! 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; + +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 { + // 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()?; + 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 { + // 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(), + 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> { + // 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, + &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] { + // 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); + 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..b871202 --- /dev/null +++ b/src/sealed/open.rs @@ -0,0 +1,74 @@ +//! 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}; + +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 { + // 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)?; + 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> { + // 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( + manager.algorithm, + &manager.secret, + &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), + 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")?; + // 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 new file mode 100644 index 0000000..b2b0e1f --- /dev/null +++ b/src/sealed/parts.rs @@ -0,0 +1,58 @@ +//! Sealed-token splitting and associated-data assembly. +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> { + // 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(); + 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 { + // 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('.'); + 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> { + // 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() + || 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 new file mode 100644 index 0000000..639026c --- /dev/null +++ b/src/token.rs @@ -0,0 +1,91 @@ +//! 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; + +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 { + /// Generates a signed token from a UTF-8 payload. + pub fn generate_token( + &mut self, + payload: &str, + options: GenerateTokenOptions<'_>, + ) -> Result { + self.generate_token_bytes(payload.as_bytes(), options) + } + + /// Generates a signed token from raw bytes. + pub fn generate_token_bytes( + &mut self, + payload: &[u8], + options: GenerateTokenOptions<'_>, + ) -> Result { + build::token(self, payload, &options) + } + + /// Validates a signed token and returns a UTF-8 payload. + 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, + }) + } + + /// Validates a signed token and returns only the UTF-8 payload. + pub fn validate_payload( + &self, + token: &str, + options: ValidateTokenOptions<'_>, + ) -> Result { + self.validate_token(token, options) + .map(|verified| verified.payload) + } + + /// Validates a signed token and returns raw payload bytes. + 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..a9f435d --- /dev/null +++ b/src/token/build.rs @@ -0,0 +1,78 @@ +//! 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; +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 { + // 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); + 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 { + // 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(), + 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 { + // 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); + token.push('.'); + token.push_str(payload); + token.push('.'); + token.push_str(meta); + token.push('.'); + token.push_str(signature); + Ok(token) +} + +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 + .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..61e1bec --- /dev/null +++ b/src/token/parts.rs @@ -0,0 +1,58 @@ +//! Signed-token splitting and signing input assembly. + +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> { + // 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(); + 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 { + // 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('.'); + 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> { + // 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() + || 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..da080f7 --- /dev/null +++ b/src/validate.rs @@ -0,0 +1,38 @@ +//! 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; + +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> { + // 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)?; + scope::validate_scope(meta, options) +} + +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 { + Err(TokenError::new("Algorithm mismatch.")) + } +} diff --git a/src/validate/scope.rs b/src/validate/scope.rs new file mode 100644 index 0000000..8f12f65 --- /dev/null +++ b/src/validate/scope.rs @@ -0,0 +1,28 @@ +//! Issuer and audience validation. +use crate::error::TokenError; +use crate::meta::Meta; +use crate::options::ValidateTokenOptions; + +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) +} + +fn match_required( + name: &str, + 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(()), + (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..d995620 --- /dev/null +++ b/src/validate/signature.rs @@ -0,0 +1,14 @@ +//! 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(()) + } 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..9d4323a --- /dev/null +++ b/src/validate/time.rs @@ -0,0 +1,65 @@ +//! 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; +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()) + .map_err(|_| TokenError::new("System time is before UNIX_EPOCH.")) +} + +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()?, + }; + 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> { + // 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) + .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> { + // 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) + .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 290aeef..0000000 --- a/tests/advanced_token_manager_test.rs +++ /dev/null @@ -1,152 +0,0 @@ -use base64::Engine; -use hash_token_rust::advanced_token_manager::{ - AdvancedTokenManager, AdvancedTokenManagerOptions, Algorithm, 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 decoded = base64::engine::general_purpose::STANDARD - .decode(token) - .unwrap(); - let text = String::from_utf8(decoded).unwrap(); - let parts: Vec<&str> = text.split('|').collect(); - assert_eq!(parts[1], "1"); -} - -#[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 mut options = AdvancedTokenManagerOptions::default(); - options.jwt_default_algorithms = Some(vec![JwtAlgorithm::HS256]); - 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 mut verify_options = ManagerVerifyJwtOptions::default(); - verify_options.algorithms = Some(vec![JwtAlgorithm::HS256]); - 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), - }), - ) - .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 mut verify_options = ManagerVerifyJwtOptions::default(); - verify_options.audience = Some(Audience::Single("service-a".into())); - let validated: JwtClaims = manager.validate_jwt(&token, Some(verify_options)).unwrap(); - assert_eq!(validated.get("sub").unwrap(), "user-123"); -} diff --git a/tests/jwt_test.rs b/tests/jwt_test.rs deleted file mode 100644 index 80384b4..0000000 --- a/tests/jwt_test.rs +++ /dev/null @@ -1,194 +0,0 @@ -use hash_token_rust::jwt::{ - sign_jwt, verify_jwt, verify_jwt_as, Audience, Issuer, JwtAlgorithm, JwtClaims, SignJwtOptions, - VerifyJwtOptions, -}; - -#[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 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(); - assert!(err.to_string().contains("invalid signature")); -} - -#[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_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..e47affc --- /dev/null +++ b/tests/native_token_test.rs @@ -0,0 +1,503 @@ +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 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(); + + 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")); +} 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