baobab-auth-security 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- baobab_auth_security/__init__.py +10 -0
- baobab_auth_security/config/__init__.py +7 -0
- baobab_auth_security/config/jwt_security_settings.py +44 -0
- baobab_auth_security/config/key_settings.py +32 -0
- baobab_auth_security/config/refresh_token_settings.py +34 -0
- baobab_auth_security/exceptions/__init__.py +5 -0
- baobab_auth_security/exceptions/baobab_auth_security_error.py +10 -0
- baobab_auth_security/integration/__init__.py +17 -0
- baobab_auth_security/integration/claims_mapper.py +48 -0
- baobab_auth_security/integration/core_integration_gaps.py +25 -0
- baobab_auth_security/integration/core_password_hasher_adapter.py +50 -0
- baobab_auth_security/integration/core_token_provider_adapter.py +25 -0
- baobab_auth_security/keys/__init__.py +51 -0
- baobab_auth_security/keys/exceptions.py +80 -0
- baobab_auth_security/keys/in_memory_key_provider.py +131 -0
- baobab_auth_security/keys/jwk.py +67 -0
- baobab_auth_security/keys/jwks.py +24 -0
- baobab_auth_security/keys/jwks_provider.py +87 -0
- baobab_auth_security/keys/key_algorithm.py +14 -0
- baobab_auth_security/keys/key_generator.py +65 -0
- baobab_auth_security/keys/key_pair.py +43 -0
- baobab_auth_security/keys/key_provider.py +35 -0
- baobab_auth_security/keys/key_rotation_service.py +130 -0
- baobab_auth_security/keys/key_status.py +15 -0
- baobab_auth_security/keys/pem_loader.py +45 -0
- baobab_auth_security/password/__init__.py +27 -0
- baobab_auth_security/password/argon2_password_hasher.py +117 -0
- baobab_auth_security/password/exceptions.py +38 -0
- baobab_auth_security/password/password_hash_policy.py +45 -0
- baobab_auth_security/password/password_hash_result.py +32 -0
- baobab_auth_security/password/password_hasher.py +43 -0
- baobab_auth_security/password/password_verification_result.py +22 -0
- baobab_auth_security/refresh_tokens/__init__.py +21 -0
- baobab_auth_security/refresh_tokens/exceptions.py +31 -0
- baobab_auth_security/refresh_tokens/refresh_token_generator.py +43 -0
- baobab_auth_security/refresh_tokens/refresh_token_hasher.py +81 -0
- baobab_auth_security/refresh_tokens/refresh_token_result.py +40 -0
- baobab_auth_security/revocation/__init__.py +8 -0
- baobab_auth_security/revocation/in_memory_revocation_checker.py +75 -0
- baobab_auth_security/revocation/token_revocation_checker.py +39 -0
- baobab_auth_security/tokens/__init__.py +51 -0
- baobab_auth_security/tokens/exceptions.py +101 -0
- baobab_auth_security/tokens/jwt_decoder.py +162 -0
- baobab_auth_security/tokens/jwt_encoder.py +71 -0
- baobab_auth_security/tokens/jwt_token_provider.py +68 -0
- baobab_auth_security/tokens/jwt_validation_result.py +30 -0
- baobab_auth_security/tokens/jwt_validator.py +150 -0
- baobab_auth_security/tokens/token_claims.py +78 -0
- baobab_auth_security/tokens/token_pair.py +32 -0
- baobab_auth_security/tokens/token_type.py +14 -0
- baobab_auth_security/version.py +8 -0
- baobab_auth_security-0.1.0.dist-info/METADATA +257 -0
- baobab_auth_security-0.1.0.dist-info/RECORD +55 -0
- baobab_auth_security-0.1.0.dist-info/WHEEL +4 -0
- baobab_auth_security-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Librairie de sécurité pour l'écosystème ``baobab-auth``.
|
|
2
|
+
|
|
3
|
+
Fournit les primitives de hash de mots de passe, JWT, JWKS, clés cryptographiques,
|
|
4
|
+
refresh tokens opaques et révocation abstraite, sans couplage HTTP ni persistance.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from baobab_auth_security.exceptions import BaobabAuthSecurityError
|
|
8
|
+
from baobab_auth_security.version import VERSION
|
|
9
|
+
|
|
10
|
+
__all__ = ["VERSION", "BaobabAuthSecurityError"]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Configuration typée de la librairie de sécurité."""
|
|
2
|
+
|
|
3
|
+
from baobab_auth_security.config.jwt_security_settings import JwtSecuritySettings
|
|
4
|
+
from baobab_auth_security.config.key_settings import KeySettings
|
|
5
|
+
from baobab_auth_security.config.refresh_token_settings import RefreshTokenSettings
|
|
6
|
+
|
|
7
|
+
__all__ = ["JwtSecuritySettings", "KeySettings", "RefreshTokenSettings"]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Configuration JWT (issuer, audience, TTL)."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from baobab_auth_security.exceptions import BaobabAuthSecurityError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class JwtSecuritySettings:
|
|
10
|
+
"""Paramètres de sécurité pour l'émission et la validation JWT.
|
|
11
|
+
|
|
12
|
+
:param issuer: émetteur attendu (``iss``).
|
|
13
|
+
:param audience: audiences acceptées (``aud``).
|
|
14
|
+
:param access_token_ttl_seconds: durée de vie recommandée des access tokens.
|
|
15
|
+
:param algorithm: algorithme de signature attendu.
|
|
16
|
+
:param require_kid: exige un ``kid`` dans le header.
|
|
17
|
+
:param require_exp: exige et valide ``exp``.
|
|
18
|
+
:param require_iat: exige et valide ``iat``.
|
|
19
|
+
:param require_nbf: exige ``nbf`` dans le payload.
|
|
20
|
+
:param clock_skew_seconds: tolérance horaire en secondes.
|
|
21
|
+
:raises BaobabAuthSecurityError: si un paramètre est invalide.
|
|
22
|
+
:spec: FEAT-006.3
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
issuer: str = "baobab-auth"
|
|
26
|
+
audience: tuple[str, ...] = ("baobab-app",)
|
|
27
|
+
access_token_ttl_seconds: int = 900
|
|
28
|
+
algorithm: str = "RS256"
|
|
29
|
+
require_kid: bool = True
|
|
30
|
+
require_exp: bool = True
|
|
31
|
+
require_iat: bool = True
|
|
32
|
+
require_nbf: bool = False
|
|
33
|
+
clock_skew_seconds: int = 30
|
|
34
|
+
|
|
35
|
+
def __post_init__(self) -> None:
|
|
36
|
+
"""Valide les paramètres de configuration."""
|
|
37
|
+
if not self.issuer:
|
|
38
|
+
raise BaobabAuthSecurityError("issuer ne doit pas être vide")
|
|
39
|
+
if not self.audience:
|
|
40
|
+
raise BaobabAuthSecurityError("audience ne doit pas être vide")
|
|
41
|
+
if self.access_token_ttl_seconds <= 0:
|
|
42
|
+
raise BaobabAuthSecurityError("access_token_ttl_seconds doit être positif")
|
|
43
|
+
if self.clock_skew_seconds < 0:
|
|
44
|
+
raise BaobabAuthSecurityError("clock_skew_seconds doit être >= 0")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Configuration des clés cryptographiques."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from baobab_auth_security.exceptions import BaobabAuthSecurityError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class KeySettings:
|
|
10
|
+
"""Paramètres de génération et gestion des clés.
|
|
11
|
+
|
|
12
|
+
:param default_algorithm: algorithme par défaut (``RS256`` MVP).
|
|
13
|
+
:param key_size: taille RSA en bits.
|
|
14
|
+
:param active_key_id: identifiant de clé active optionnel.
|
|
15
|
+
:param private_key_path: chemin PEM privé optionnel.
|
|
16
|
+
:param public_key_path: chemin PEM public optionnel.
|
|
17
|
+
:param jwks_cache_ttl_seconds: TTL cache JWKS en secondes.
|
|
18
|
+
:raises BaobabAuthSecurityError: si ``key_size`` est invalide.
|
|
19
|
+
:spec: FEAT-004.2
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
default_algorithm: str = "RS256"
|
|
23
|
+
key_size: int = 2048
|
|
24
|
+
active_key_id: str | None = None
|
|
25
|
+
private_key_path: str | None = None
|
|
26
|
+
public_key_path: str | None = None
|
|
27
|
+
jwks_cache_ttl_seconds: int = 300
|
|
28
|
+
|
|
29
|
+
def __post_init__(self) -> None:
|
|
30
|
+
"""Valide les paramètres."""
|
|
31
|
+
if self.key_size < 2048:
|
|
32
|
+
raise BaobabAuthSecurityError("key_size doit être >= 2048 pour RSA")
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Configuration des refresh tokens opaques."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from baobab_auth_security.exceptions import BaobabAuthSecurityError
|
|
6
|
+
|
|
7
|
+
_HMAC_SHA256 = "hmac-sha256"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class RefreshTokenSettings:
|
|
12
|
+
"""Paramètres de génération et hash des refresh tokens.
|
|
13
|
+
|
|
14
|
+
:param refresh_token_ttl_seconds: durée de vie du token en secondes.
|
|
15
|
+
:param refresh_token_bytes: entropie du token opaque (octets).
|
|
16
|
+
:param hash_algorithm: algorithme de hash pour stockage (``hmac-sha256`` MVP).
|
|
17
|
+
:param rotate_on_use: indique la rotation applicative recommandée.
|
|
18
|
+
:param hmac_secret: secret HMAC requis si ``hash_algorithm`` est ``hmac-sha256``.
|
|
19
|
+
:raises BaobabAuthSecurityError: si un paramètre est invalide.
|
|
20
|
+
:spec: FEAT-003.1
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
refresh_token_ttl_seconds: int = 2_592_000
|
|
24
|
+
refresh_token_bytes: int = 64
|
|
25
|
+
hash_algorithm: str = _HMAC_SHA256
|
|
26
|
+
rotate_on_use: bool = True
|
|
27
|
+
hmac_secret: str | None = None
|
|
28
|
+
|
|
29
|
+
def __post_init__(self) -> None:
|
|
30
|
+
"""Valide les paramètres de configuration."""
|
|
31
|
+
if self.refresh_token_ttl_seconds <= 0:
|
|
32
|
+
raise BaobabAuthSecurityError("refresh_token_ttl_seconds doit être positif")
|
|
33
|
+
if self.refresh_token_bytes <= 0:
|
|
34
|
+
raise BaobabAuthSecurityError("refresh_token_bytes doit être positif")
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Exception racine de la librairie ``baobab-auth-security``."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class BaobabAuthSecurityError(Exception):
|
|
5
|
+
"""Erreur générique de la librairie de sécurité ``baobab-auth``.
|
|
6
|
+
|
|
7
|
+
Toutes les exceptions publiques de ce package héritent de cette classe.
|
|
8
|
+
|
|
9
|
+
:spec: FEAT-001.1
|
|
10
|
+
"""
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Adaptateurs vers les ports de ``baobab-auth-core``."""
|
|
2
|
+
|
|
3
|
+
from baobab_auth_security.integration.claims_mapper import ClaimsMapper
|
|
4
|
+
from baobab_auth_security.integration.core_integration_gaps import CoreIntegrationGaps
|
|
5
|
+
from baobab_auth_security.integration.core_password_hasher_adapter import (
|
|
6
|
+
CorePasswordHasherAdapter,
|
|
7
|
+
)
|
|
8
|
+
from baobab_auth_security.integration.core_token_provider_adapter import (
|
|
9
|
+
CoreTokenProviderAdapter,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ClaimsMapper",
|
|
14
|
+
"CoreIntegrationGaps",
|
|
15
|
+
"CorePasswordHasherAdapter",
|
|
16
|
+
"CoreTokenProviderAdapter",
|
|
17
|
+
]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Mapping entre claims JWT et value objects ``baobab-auth-core``."""
|
|
2
|
+
|
|
3
|
+
from baobab_auth_core.domain.value_objects.auth_subject import AuthSubject
|
|
4
|
+
from baobab_auth_core.domain.value_objects.session_id import SessionId
|
|
5
|
+
from baobab_auth_core.domain.value_objects.token_id import TokenId
|
|
6
|
+
|
|
7
|
+
from baobab_auth_security.tokens.token_claims import TokenClaims
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ClaimsMapper:
|
|
11
|
+
"""Convertit ``TokenClaims`` vers les value objects du core.
|
|
12
|
+
|
|
13
|
+
:spec: FEAT-009.1
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def to_auth_subject(self, claims: TokenClaims) -> AuthSubject:
|
|
17
|
+
"""Extrait le sujet métier depuis les claims JWT.
|
|
18
|
+
|
|
19
|
+
:param claims: claims typés security.
|
|
20
|
+
:returns: ``AuthSubject`` du core.
|
|
21
|
+
"""
|
|
22
|
+
return AuthSubject(value=claims.subject)
|
|
23
|
+
|
|
24
|
+
def to_token_id(self, claims: TokenClaims) -> TokenId:
|
|
25
|
+
"""Extrait le ``jti`` depuis les claims JWT.
|
|
26
|
+
|
|
27
|
+
:param claims: claims typés security.
|
|
28
|
+
:returns: ``TokenId`` du core.
|
|
29
|
+
"""
|
|
30
|
+
return TokenId(value=claims.token_id)
|
|
31
|
+
|
|
32
|
+
def to_session_id(self, claims: TokenClaims) -> SessionId | None:
|
|
33
|
+
"""Extrait le ``sid`` optionnel depuis les claims JWT.
|
|
34
|
+
|
|
35
|
+
:param claims: claims typés security.
|
|
36
|
+
:returns: ``SessionId`` ou ``None`` si absent.
|
|
37
|
+
"""
|
|
38
|
+
if claims.session_id is None:
|
|
39
|
+
return None
|
|
40
|
+
return SessionId(value=claims.session_id)
|
|
41
|
+
|
|
42
|
+
def subject_from_auth_subject(self, subject: AuthSubject) -> str:
|
|
43
|
+
"""Convertit un ``AuthSubject`` en claim ``sub`` string.
|
|
44
|
+
|
|
45
|
+
:param subject: sujet core.
|
|
46
|
+
:returns: valeur ``sub`` pour ``TokenClaims``.
|
|
47
|
+
"""
|
|
48
|
+
return str(subject.value)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Écarts documentés entre security et ``baobab-auth-core``."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CoreIntegrationGaps:
|
|
5
|
+
"""Liste les écarts connus d'intégration avec ``baobab-auth-core``.
|
|
6
|
+
|
|
7
|
+
:spec: FEAT-009.1
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
GAPS: tuple[str, ...] = (
|
|
11
|
+
"Le port core ``TokenProvider`` n'expose que ``generate_refresh_token_id`` "
|
|
12
|
+
"(pas de création/validation JWT access token).",
|
|
13
|
+
"Le port core ``PasswordHasher`` utilise les VO ``Password``/``PasswordHash`` "
|
|
14
|
+
"alors que security expose des API string typées.",
|
|
15
|
+
"Aucun port core dédié au JWKS, à la rotation de clés ou à la révocation JWT "
|
|
16
|
+
"en v0.4 — ces capacités restent propres à baobab-auth-security.",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def documented_gaps(cls) -> tuple[str, ...]:
|
|
21
|
+
"""Renvoie les écarts documentés pour revue PO/architecte.
|
|
22
|
+
|
|
23
|
+
:returns: tuple de descriptions d'écarts.
|
|
24
|
+
"""
|
|
25
|
+
return cls.GAPS
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Adaptateur ``PasswordHasher`` security → port core."""
|
|
2
|
+
|
|
3
|
+
from baobab_auth_core.domain.value_objects.password import Password
|
|
4
|
+
from baobab_auth_core.domain.value_objects.password_hash import PasswordHash
|
|
5
|
+
from baobab_auth_core.exceptions.validation import ValidationError
|
|
6
|
+
|
|
7
|
+
from baobab_auth_security.password.exceptions import (
|
|
8
|
+
EmptyPasswordError,
|
|
9
|
+
InvalidPasswordHashError,
|
|
10
|
+
)
|
|
11
|
+
from baobab_auth_security.password.password_hasher import PasswordHasher
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CorePasswordHasherAdapter:
|
|
15
|
+
"""Implémente le port core ``PasswordHasher`` via security.
|
|
16
|
+
|
|
17
|
+
:param hasher: implémentation security injectée.
|
|
18
|
+
:spec: FEAT-009.1
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, hasher: PasswordHasher) -> None:
|
|
22
|
+
"""Initialise l'adaptateur."""
|
|
23
|
+
self._hasher = hasher
|
|
24
|
+
|
|
25
|
+
def hash(self, password: Password) -> PasswordHash:
|
|
26
|
+
"""Hache un mot de passe core en déléguant à security.
|
|
27
|
+
|
|
28
|
+
:param password: VO ``Password`` du core.
|
|
29
|
+
:returns: VO ``PasswordHash`` du core.
|
|
30
|
+
:raises ValidationError: si le mot de passe est vide côté security.
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
result = self._hasher.hash_password(password.value)
|
|
34
|
+
except EmptyPasswordError as exc:
|
|
35
|
+
raise ValidationError(str(exc)) from exc
|
|
36
|
+
return PasswordHash(value=result.hashed_password)
|
|
37
|
+
|
|
38
|
+
def verify(self, password: Password, hashed: PasswordHash) -> bool:
|
|
39
|
+
"""Vérifie un mot de passe core contre un hash core.
|
|
40
|
+
|
|
41
|
+
:param password: VO ``Password`` du core.
|
|
42
|
+
:param hashed: VO ``PasswordHash`` du core.
|
|
43
|
+
:returns: ``True`` si valide.
|
|
44
|
+
:raises ValidationError: si les entrées sont invalides côté security.
|
|
45
|
+
"""
|
|
46
|
+
try:
|
|
47
|
+
result = self._hasher.verify_password(password.value, hashed.value)
|
|
48
|
+
except (EmptyPasswordError, InvalidPasswordHashError) as exc:
|
|
49
|
+
raise ValidationError(str(exc)) from exc
|
|
50
|
+
return result.is_valid
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Adaptateur refresh token → port core ``TokenProvider``."""
|
|
2
|
+
|
|
3
|
+
from baobab_auth_core.domain.value_objects.token_id import TokenId
|
|
4
|
+
|
|
5
|
+
from baobab_auth_security.refresh_tokens.refresh_token_generator import RefreshTokenGenerator
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CoreTokenProviderAdapter:
|
|
9
|
+
"""Implémente le port core ``TokenProvider`` via ``RefreshTokenGenerator``.
|
|
10
|
+
|
|
11
|
+
:param generator: générateur de refresh tokens security.
|
|
12
|
+
:spec: FEAT-009.1
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, generator: RefreshTokenGenerator) -> None:
|
|
16
|
+
"""Initialise l'adaptateur."""
|
|
17
|
+
self._generator = generator
|
|
18
|
+
|
|
19
|
+
def generate_refresh_token_id(self) -> TokenId:
|
|
20
|
+
"""Génère un identifiant de refresh token pour le core.
|
|
21
|
+
|
|
22
|
+
:returns: ``TokenId`` encapsulant le ``token_id`` security.
|
|
23
|
+
"""
|
|
24
|
+
result = self._generator.generate_refresh_token()
|
|
25
|
+
return TokenId(value=result.token_id)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Clés cryptographiques, JWKS et rotation."""
|
|
2
|
+
|
|
3
|
+
from baobab_auth_security.keys.exceptions import (
|
|
4
|
+
InvalidKeyFormatError,
|
|
5
|
+
JwkNotFoundError,
|
|
6
|
+
JwksGenerationError,
|
|
7
|
+
JwksSecurityError,
|
|
8
|
+
KeyGenerationError,
|
|
9
|
+
KeyNotFoundError,
|
|
10
|
+
KeyRotationError,
|
|
11
|
+
KeySecurityError,
|
|
12
|
+
NoActiveSigningKeyError,
|
|
13
|
+
PrivateKeyRequiredError,
|
|
14
|
+
RevokedKeyError,
|
|
15
|
+
)
|
|
16
|
+
from baobab_auth_security.keys.in_memory_key_provider import InMemoryKeyProvider
|
|
17
|
+
from baobab_auth_security.keys.jwk import JWK
|
|
18
|
+
from baobab_auth_security.keys.jwks import JWKS
|
|
19
|
+
from baobab_auth_security.keys.jwks_provider import JwksProvider
|
|
20
|
+
from baobab_auth_security.keys.key_algorithm import KeyAlgorithm
|
|
21
|
+
from baobab_auth_security.keys.key_generator import KeyGenerator
|
|
22
|
+
from baobab_auth_security.keys.key_pair import KeyPair
|
|
23
|
+
from baobab_auth_security.keys.key_provider import KeyProvider
|
|
24
|
+
from baobab_auth_security.keys.key_rotation_service import KeyRotationService
|
|
25
|
+
from baobab_auth_security.keys.key_status import KeyStatus
|
|
26
|
+
from baobab_auth_security.keys.pem_loader import PemLoader
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"JWK",
|
|
30
|
+
"JWKS",
|
|
31
|
+
"InMemoryKeyProvider",
|
|
32
|
+
"InvalidKeyFormatError",
|
|
33
|
+
"JwkNotFoundError",
|
|
34
|
+
"JwksGenerationError",
|
|
35
|
+
"JwksProvider",
|
|
36
|
+
"JwksSecurityError",
|
|
37
|
+
"KeyAlgorithm",
|
|
38
|
+
"KeyGenerationError",
|
|
39
|
+
"KeyGenerator",
|
|
40
|
+
"KeyNotFoundError",
|
|
41
|
+
"KeyPair",
|
|
42
|
+
"KeyProvider",
|
|
43
|
+
"KeyRotationError",
|
|
44
|
+
"KeyRotationService",
|
|
45
|
+
"KeySecurityError",
|
|
46
|
+
"KeyStatus",
|
|
47
|
+
"NoActiveSigningKeyError",
|
|
48
|
+
"PemLoader",
|
|
49
|
+
"PrivateKeyRequiredError",
|
|
50
|
+
"RevokedKeyError",
|
|
51
|
+
]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Exceptions du sous-package ``keys``."""
|
|
2
|
+
|
|
3
|
+
from baobab_auth_security.exceptions.baobab_auth_security_error import BaobabAuthSecurityError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class KeySecurityError(BaobabAuthSecurityError):
|
|
7
|
+
"""Erreur liée aux clés cryptographiques.
|
|
8
|
+
|
|
9
|
+
:spec: FEAT-004.2
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class KeyGenerationError(KeySecurityError):
|
|
14
|
+
"""Erreur lors de la génération d'une clé.
|
|
15
|
+
|
|
16
|
+
:spec: FEAT-004.2
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class KeyNotFoundError(KeySecurityError):
|
|
21
|
+
"""Clé introuvable pour l'identifiant demandé.
|
|
22
|
+
|
|
23
|
+
:spec: FEAT-004.2
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class NoActiveSigningKeyError(KeySecurityError):
|
|
28
|
+
"""Aucune clé active disponible pour la signature.
|
|
29
|
+
|
|
30
|
+
:spec: FEAT-004.2
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class InvalidKeyFormatError(KeySecurityError):
|
|
35
|
+
"""Format PEM ou structure de clé invalide.
|
|
36
|
+
|
|
37
|
+
:spec: FEAT-004.2
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PrivateKeyRequiredError(KeySecurityError):
|
|
42
|
+
"""Clé privée requise pour l'opération demandée.
|
|
43
|
+
|
|
44
|
+
:spec: FEAT-004.2
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class KeyRotationError(KeySecurityError):
|
|
49
|
+
"""Erreur lors de la rotation ou activation d'une clé.
|
|
50
|
+
|
|
51
|
+
:spec: FEAT-007.1
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RevokedKeyError(KeySecurityError):
|
|
56
|
+
"""Clé révoquée ou compromise — opération refusée.
|
|
57
|
+
|
|
58
|
+
:spec: FEAT-007.1
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class JwksSecurityError(BaobabAuthSecurityError):
|
|
63
|
+
"""Erreur liée au JWKS.
|
|
64
|
+
|
|
65
|
+
:spec: FEAT-005.1
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class JwksGenerationError(JwksSecurityError):
|
|
70
|
+
"""Erreur lors de la conversion ou génération JWKS.
|
|
71
|
+
|
|
72
|
+
:spec: FEAT-005.1
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class JwkNotFoundError(JwksSecurityError):
|
|
77
|
+
"""JWK introuvable pour l'identifiant demandé.
|
|
78
|
+
|
|
79
|
+
:spec: FEAT-005.1
|
|
80
|
+
"""
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Fournisseur de clés en mémoire pour tests et développement."""
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
|
|
5
|
+
from baobab_auth_security.keys.exceptions import (
|
|
6
|
+
KeyNotFoundError,
|
|
7
|
+
NoActiveSigningKeyError,
|
|
8
|
+
RevokedKeyError,
|
|
9
|
+
)
|
|
10
|
+
from baobab_auth_security.keys.key_pair import KeyPair
|
|
11
|
+
from baobab_auth_security.keys.key_status import KeyStatus
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class InMemoryKeyProvider:
|
|
15
|
+
"""Stocke des clés en mémoire — non destiné à la production.
|
|
16
|
+
|
|
17
|
+
:spec: FEAT-004.2
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
"""Initialise un registre vide."""
|
|
22
|
+
self._keys: dict[str, KeyPair] = {}
|
|
23
|
+
self._active_key_id: str | None = None
|
|
24
|
+
|
|
25
|
+
def register_key(self, key_pair: KeyPair, *, set_active: bool = False) -> None:
|
|
26
|
+
"""Enregistre une paire de clés.
|
|
27
|
+
|
|
28
|
+
:param key_pair: clé à enregistrer.
|
|
29
|
+
:param set_active: active cette clé pour la signature.
|
|
30
|
+
"""
|
|
31
|
+
self._keys[key_pair.key_id] = key_pair
|
|
32
|
+
if set_active:
|
|
33
|
+
self._active_key_id = key_pair.key_id
|
|
34
|
+
|
|
35
|
+
def revoke_key(self, key_id: str) -> None:
|
|
36
|
+
"""Révoque logiquement une clé.
|
|
37
|
+
|
|
38
|
+
:param key_id: identifiant de la clé.
|
|
39
|
+
:raises KeyNotFoundError: si la clé n'existe pas.
|
|
40
|
+
"""
|
|
41
|
+
pair = self._keys.get(key_id)
|
|
42
|
+
if pair is None:
|
|
43
|
+
raise KeyNotFoundError(f"clé introuvable : {key_id!r}")
|
|
44
|
+
revoked = KeyPair(
|
|
45
|
+
key_id=pair.key_id,
|
|
46
|
+
algorithm=pair.algorithm,
|
|
47
|
+
status=KeyStatus.REVOKED,
|
|
48
|
+
public_key_pem=pair.public_key_pem,
|
|
49
|
+
private_key_pem=pair.private_key_pem,
|
|
50
|
+
created_at=pair.created_at,
|
|
51
|
+
activated_at=pair.activated_at,
|
|
52
|
+
expires_at=pair.expires_at,
|
|
53
|
+
revoked_at=datetime.now(UTC),
|
|
54
|
+
)
|
|
55
|
+
self._keys[key_id] = revoked
|
|
56
|
+
if self._active_key_id == key_id:
|
|
57
|
+
self._active_key_id = None
|
|
58
|
+
|
|
59
|
+
def get_active_signing_key(self) -> KeyPair:
|
|
60
|
+
"""Renvoie la clé active pour signer.
|
|
61
|
+
|
|
62
|
+
:returns: paire avec clé privée.
|
|
63
|
+
:raises NoActiveSigningKeyError: si aucune clé active.
|
|
64
|
+
"""
|
|
65
|
+
if self._active_key_id is None:
|
|
66
|
+
raise NoActiveSigningKeyError("aucune clé active pour la signature")
|
|
67
|
+
pair = self._keys.get(self._active_key_id)
|
|
68
|
+
if pair is None or pair.private_key_pem is None:
|
|
69
|
+
raise NoActiveSigningKeyError("clé active sans clé privée")
|
|
70
|
+
if pair.status != KeyStatus.ACTIVE:
|
|
71
|
+
raise NoActiveSigningKeyError("aucune clé active pour la signature")
|
|
72
|
+
return pair
|
|
73
|
+
|
|
74
|
+
def get_stored_key_pair(self, key_id: str) -> KeyPair:
|
|
75
|
+
"""Renvoie une paire stockée avec clé privée si présente.
|
|
76
|
+
|
|
77
|
+
:param key_id: identifiant de clé.
|
|
78
|
+
:returns: paire complète du registre.
|
|
79
|
+
:raises KeyNotFoundError: si introuvable.
|
|
80
|
+
"""
|
|
81
|
+
pair = self._keys.get(key_id)
|
|
82
|
+
if pair is None:
|
|
83
|
+
raise KeyNotFoundError(f"clé introuvable : {key_id!r}")
|
|
84
|
+
return pair
|
|
85
|
+
|
|
86
|
+
def update_key_pair(self, key_pair: KeyPair) -> None:
|
|
87
|
+
"""Remplace une paire enregistrée.
|
|
88
|
+
|
|
89
|
+
:param key_pair: nouvelle version de la paire.
|
|
90
|
+
:raises KeyNotFoundError: si la clé n'existe pas.
|
|
91
|
+
"""
|
|
92
|
+
if key_pair.key_id not in self._keys:
|
|
93
|
+
raise KeyNotFoundError(f"clé introuvable : {key_pair.key_id!r}")
|
|
94
|
+
self._keys[key_pair.key_id] = key_pair
|
|
95
|
+
if self._active_key_id == key_pair.key_id and key_pair.status != KeyStatus.ACTIVE:
|
|
96
|
+
self._active_key_id = None
|
|
97
|
+
|
|
98
|
+
def get_public_key(self, key_id: str) -> KeyPair:
|
|
99
|
+
"""Renvoie une clé publique par ``kid``.
|
|
100
|
+
|
|
101
|
+
:param key_id: identifiant de clé.
|
|
102
|
+
:returns: paire sans clé privée.
|
|
103
|
+
:raises KeyNotFoundError: si introuvable.
|
|
104
|
+
"""
|
|
105
|
+
pair = self._keys.get(key_id)
|
|
106
|
+
if pair is None:
|
|
107
|
+
raise KeyNotFoundError(f"clé introuvable : {key_id!r}")
|
|
108
|
+
if pair.status == KeyStatus.REVOKED:
|
|
109
|
+
raise RevokedKeyError(f"clé révoquée : {key_id!r}")
|
|
110
|
+
return KeyPair(
|
|
111
|
+
key_id=pair.key_id,
|
|
112
|
+
algorithm=pair.algorithm,
|
|
113
|
+
status=pair.status,
|
|
114
|
+
public_key_pem=pair.public_key_pem,
|
|
115
|
+
private_key_pem=None,
|
|
116
|
+
created_at=pair.created_at,
|
|
117
|
+
activated_at=pair.activated_at,
|
|
118
|
+
expires_at=pair.expires_at,
|
|
119
|
+
revoked_at=pair.revoked_at,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def list_public_keys(self) -> tuple[KeyPair, ...]:
|
|
123
|
+
"""Liste les clés publiques actives ou retraitées.
|
|
124
|
+
|
|
125
|
+
:returns: tuple de paires publiques (``ACTIVE`` et ``RETIRED``).
|
|
126
|
+
"""
|
|
127
|
+
return tuple(
|
|
128
|
+
self.get_public_key(key_id)
|
|
129
|
+
for key_id in self._keys
|
|
130
|
+
if self._keys[key_id].status in (KeyStatus.ACTIVE, KeyStatus.RETIRED)
|
|
131
|
+
)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Modèle JSON Web Key."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from baobab_auth_security.keys.exceptions import JwksSecurityError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class JWK:
|
|
11
|
+
"""Représente une clé publique au format JWK.
|
|
12
|
+
|
|
13
|
+
Pour RSA, ``n`` et ``e`` sont obligatoires.
|
|
14
|
+
|
|
15
|
+
:param kty: type de clé (``RSA``, ``EC``, …).
|
|
16
|
+
:param use: usage (``sig``, ``enc``, …).
|
|
17
|
+
:param kid: identifiant de clé.
|
|
18
|
+
:param alg: algorithme de signature.
|
|
19
|
+
:param n: module RSA (base64url).
|
|
20
|
+
:param e: exposant RSA (base64url).
|
|
21
|
+
:param crv: courbe pour EC.
|
|
22
|
+
:param x: coordonnée x pour EC.
|
|
23
|
+
:param y: coordonnée y pour EC.
|
|
24
|
+
:param key_ops: opérations de clé optionnelles.
|
|
25
|
+
:spec: FEAT-005.1
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
kty: str
|
|
29
|
+
use: str
|
|
30
|
+
kid: str
|
|
31
|
+
alg: str
|
|
32
|
+
n: str | None = None
|
|
33
|
+
e: str | None = None
|
|
34
|
+
crv: str | None = None
|
|
35
|
+
x: str | None = None
|
|
36
|
+
y: str | None = None
|
|
37
|
+
key_ops: tuple[str, ...] | None = None
|
|
38
|
+
|
|
39
|
+
def __post_init__(self) -> None:
|
|
40
|
+
"""Valide les champs obligatoires selon le type de clé."""
|
|
41
|
+
if self.kty == "RSA" and (self.n is None or self.e is None):
|
|
42
|
+
raise JwksSecurityError("n et e sont obligatoires pour une JWK RSA")
|
|
43
|
+
|
|
44
|
+
def to_dict(self) -> dict[str, Any]:
|
|
45
|
+
"""Sérialise la JWK en dictionnaire JSON-compatible.
|
|
46
|
+
|
|
47
|
+
:returns: représentation dict sans clé privée.
|
|
48
|
+
"""
|
|
49
|
+
data: dict[str, Any] = {
|
|
50
|
+
"kty": self.kty,
|
|
51
|
+
"use": self.use,
|
|
52
|
+
"kid": self.kid,
|
|
53
|
+
"alg": self.alg,
|
|
54
|
+
}
|
|
55
|
+
if self.n is not None:
|
|
56
|
+
data["n"] = self.n
|
|
57
|
+
if self.e is not None:
|
|
58
|
+
data["e"] = self.e
|
|
59
|
+
if self.crv is not None:
|
|
60
|
+
data["crv"] = self.crv
|
|
61
|
+
if self.x is not None:
|
|
62
|
+
data["x"] = self.x
|
|
63
|
+
if self.y is not None:
|
|
64
|
+
data["y"] = self.y
|
|
65
|
+
if self.key_ops is not None:
|
|
66
|
+
data["key_ops"] = list(self.key_ops)
|
|
67
|
+
return data
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Modèle JSON Web Key Set."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from baobab_auth_security.keys.jwk import JWK
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class JWKS:
|
|
11
|
+
"""Document JWKS contenant des clés publiques.
|
|
12
|
+
|
|
13
|
+
:param keys: tuple de JWK publiques.
|
|
14
|
+
:spec: FEAT-005.1
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
keys: tuple[JWK, ...]
|
|
18
|
+
|
|
19
|
+
def to_dict(self) -> dict[str, Any]:
|
|
20
|
+
"""Sérialise le JWKS en dictionnaire JSON-compatible.
|
|
21
|
+
|
|
22
|
+
:returns: structure ``{"keys": [...]}`` conforme RFC 7517.
|
|
23
|
+
"""
|
|
24
|
+
return {"keys": [jwk.to_dict() for jwk in self.keys]}
|