python-neva 0.3.1__tar.gz → 0.4.0__tar.gz
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.
- {python_neva-0.3.1 → python_neva-0.4.0}/.gitignore +0 -3
- {python_neva-0.3.1 → python_neva-0.4.0}/.pre-commit-config.yaml +2 -2
- {python_neva-0.3.1 → python_neva-0.4.0}/PKG-INFO +3 -1
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/arch/facade.py +0 -9
- python_neva-0.4.0/neva/security/__init__.py +16 -0
- python_neva-0.4.0/neva/security/encryption/__init__.py +11 -0
- python_neva-0.4.0/neva/security/encryption/encrypter.py +171 -0
- python_neva-0.4.0/neva/security/encryption/protocol.py +34 -0
- python_neva-0.4.0/neva/security/hashing/__init__.py +13 -0
- python_neva-0.4.0/neva/security/hashing/config.py +28 -0
- python_neva-0.4.0/neva/security/hashing/hash_manager.py +146 -0
- python_neva-0.4.0/neva/security/hashing/hashers/__init__.py +1 -0
- python_neva-0.4.0/neva/security/hashing/hashers/argon2.py +59 -0
- python_neva-0.4.0/neva/security/hashing/hashers/bcrypt.py +49 -0
- python_neva-0.4.0/neva/security/hashing/hashers/protocol.py +31 -0
- python_neva-0.4.0/neva/security/provider.py +17 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/support/accessors.py +0 -6
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/support/facade/__init__.py +4 -0
- python_neva-0.4.0/neva/support/facade/crypt.py +15 -0
- python_neva-0.4.0/neva/support/facade/crypt.pyi +33 -0
- python_neva-0.4.0/neva/support/facade/hash.py +14 -0
- python_neva-0.4.0/neva/support/facade/hash.pyi +63 -0
- python_neva-0.4.0/neva/support/strategy.py +96 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/support/strconv.py +0 -11
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/testing/fixtures.py +22 -2
- python_neva-0.4.0/neva/testing/test_case.py +68 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/pyproject.toml +5 -3
- python_neva-0.4.0/specifications/future_ideas.md +21 -0
- python_neva-0.4.0/specifications/security.md +801 -0
- python_neva-0.4.0/tests/conftest.py +1 -0
- python_neva-0.4.0/tests/test_encrypter.py +304 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/tests/test_example_usage.py +61 -64
- {python_neva-0.3.1 → python_neva-0.4.0}/tests/test_fixtures.py +2 -9
- python_neva-0.4.0/tests/test_hash_manager.py +92 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/tests/test_test_case.py +1 -4
- {python_neva-0.3.1 → python_neva-0.4.0}/uv.lock +257 -1
- python_neva-0.4.0/wiki/configuration/01-overview.md +59 -0
- python_neva-0.4.0/wiki/configuration/02-configuration-files.md +178 -0
- python_neva-0.4.0/wiki/configuration/03-accessing-configuration.md +129 -0
- python_neva-0.4.0/wiki/configuration/04-config-repository.md +119 -0
- python_neva-0.4.0/wiki/configuration/05-loading-process.md +154 -0
- python_neva-0.4.0/wiki/configuration/06-configuration-in-providers.md +174 -0
- python_neva-0.3.1/neva/testing/test_case.py +0 -20
- python_neva-0.3.1/tests/conftest.py +0 -5
- {python_neva-0.3.1 → python_neva-0.4.0}/.envrc +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/.python-version +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/README.md +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/__init__.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/arch/__init__.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/arch/app.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/arch/application.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/arch/config.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/arch/service_provider.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/config/__init__.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/config/base_providers.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/config/loader.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/config/provider.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/config/repository.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/database/__init__.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/database/config.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/database/manager.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/database/provider.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/database/repository.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/events/__init__.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/events/dispatcher.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/events/event.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/events/event_registry.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/events/interface.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/events/listener.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/obs/__init__.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/obs/logging/__init__.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/obs/logging/manager.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/obs/logging/provider.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/obs/middleware/__init__.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/obs/middleware/correlation.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/obs/middleware/profiler.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/py.typed +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/support/__init__.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/support/facade/app.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/support/facade/app.pyi +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/support/facade/config.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/support/facade/config.pyi +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/support/facade/log.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/support/facade/log.pyi +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/support/results.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/support/time.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/testing/__init__.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/neva/testing/http.py +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/ruff.toml +0 -0
- {python_neva-0.3.1 → python_neva-0.4.0}/tests/__init__.py +0 -0
- {python_neva-0.3.1/docs → python_neva-0.4.0/wiki}/architecture/01-overview.md +0 -0
- {python_neva-0.3.1/docs → python_neva-0.4.0/wiki}/architecture/02-dependency-injection.md +0 -0
- {python_neva-0.3.1/docs → python_neva-0.4.0/wiki}/architecture/03-service-providers.md +0 -0
- {python_neva-0.3.1/docs → python_neva-0.4.0/wiki}/architecture/04-facades.md +0 -0
- {python_neva-0.3.1/docs → python_neva-0.4.0/wiki}/architecture/05-application-lifecycle.md +0 -0
- {python_neva-0.3.1/docs → python_neva-0.4.0/wiki}/architecture/06-result-option.md +0 -0
- {python_neva-0.3.1/docs → python_neva-0.4.0}/wiki/testing/01-introduction.md +0 -0
- {python_neva-0.3.1/docs → python_neva-0.4.0}/wiki/testing/02-test-case.md +0 -0
- {python_neva-0.3.1/docs → python_neva-0.4.0}/wiki/testing/03-fixtures.md +0 -0
- {python_neva-0.3.1/docs → python_neva-0.4.0}/wiki/testing/04-http-testing.md +0 -0
- {python_neva-0.3.1/docs → python_neva-0.4.0}/wiki/testing/05-custom-configuration.md +0 -0
- {python_neva-0.3.1/docs → python_neva-0.4.0}/wiki/testing/06-test-isolation.md +0 -0
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-neva
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Add your description here
|
|
5
5
|
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: cryptography>=46.0.3
|
|
6
7
|
Requires-Dist: dishka>=1.7.2
|
|
7
8
|
Requires-Dist: fastapi[all]>=0.124.0
|
|
8
9
|
Requires-Dist: flexmock>=0.13.0
|
|
10
|
+
Requires-Dist: pwdlib[argon2,bcrypt]>=0.3.0
|
|
9
11
|
Requires-Dist: pyinstrument>=5.1.1
|
|
10
12
|
Requires-Dist: structlog>=25.5.0
|
|
11
13
|
Requires-Dist: tortoise-orm[accel]>=0.25.3
|
|
@@ -134,15 +134,6 @@ class Facade(ABC, metaclass=FacadeMeta):
|
|
|
134
134
|
Facades provide a convenient static interface to services bound in the
|
|
135
135
|
application's dependency injection container. Each facade must implement
|
|
136
136
|
get_facade_accessor to specify which service it represents.
|
|
137
|
-
|
|
138
|
-
Example:
|
|
139
|
-
class Config(Facade):
|
|
140
|
-
@classmethod
|
|
141
|
-
def get_facade_accessor(cls) -> str:
|
|
142
|
-
return "config"
|
|
143
|
-
|
|
144
|
-
# Now you can use: Config.get("app.name")
|
|
145
|
-
|
|
146
137
|
"""
|
|
147
138
|
|
|
148
139
|
_app: ClassVar["Application | None"]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Security module for authentication and hashing."""
|
|
2
|
+
|
|
3
|
+
from neva.security.encryption import AesEncrypter, DecryptionError, Encrypter
|
|
4
|
+
from neva.security.hashing import Argon2Hasher, BcryptHasher, HashManager, Hasher
|
|
5
|
+
from neva.security.provider import SecurityProvider
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"AesEncrypter",
|
|
9
|
+
"Argon2Hasher",
|
|
10
|
+
"BcryptHasher",
|
|
11
|
+
"DecryptionError",
|
|
12
|
+
"Encrypter",
|
|
13
|
+
"HashManager",
|
|
14
|
+
"Hasher",
|
|
15
|
+
"SecurityProvider",
|
|
16
|
+
]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Encryption module for symmetric encryption."""
|
|
2
|
+
|
|
3
|
+
from neva.security.encryption.encrypter import AesEncrypter, DecryptionError
|
|
4
|
+
from neva.security.encryption.protocol import Encrypter, JsonValue
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"AesEncrypter",
|
|
8
|
+
"DecryptionError",
|
|
9
|
+
"Encrypter",
|
|
10
|
+
"JsonValue",
|
|
11
|
+
]
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Encryption service using AES-256-GCM."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
from cryptography.exceptions import InvalidTag
|
|
9
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
10
|
+
|
|
11
|
+
from neva import Err, Ok, Result
|
|
12
|
+
from neva.arch import Application
|
|
13
|
+
from neva.config import ConfigRepository
|
|
14
|
+
from neva.security.encryption.protocol import JsonValue
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DecryptionError(Exception):
|
|
18
|
+
"""Raised when decryption fails."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AesEncrypter:
|
|
22
|
+
"""Encryption service using AES-256-GCM."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, app: Application) -> None:
|
|
25
|
+
"""Initialize the encrypter.
|
|
26
|
+
|
|
27
|
+
Keys are loaded lazily on first encrypt/decrypt call to avoid
|
|
28
|
+
re-entering the DI container during construction.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
app: The application instance for configuration access.
|
|
32
|
+
"""
|
|
33
|
+
self._app: Application = app
|
|
34
|
+
self._ciphers: list[AESGCM] | None = None
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def generate_key() -> str:
|
|
38
|
+
"""Generate a 32-bytes encryption key.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
A base64-encoded 32-byte key suitable for AES-256.
|
|
42
|
+
"""
|
|
43
|
+
return base64.b64encode(os.urandom(32)).decode()
|
|
44
|
+
|
|
45
|
+
def encrypt(self, value: JsonValue) -> Result[str, str]:
|
|
46
|
+
"""Encrypt a value.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
value: The value to encrypt. Non-string values are JSON serialized.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Ok with base64-encoded encrypted payload, or Err with message.
|
|
53
|
+
"""
|
|
54
|
+
if isinstance(value, str):
|
|
55
|
+
wrapper = {"__str__": value}
|
|
56
|
+
else:
|
|
57
|
+
wrapper = {"__json__": value}
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
payload = json.dumps(wrapper)
|
|
61
|
+
except (TypeError, ValueError) as e:
|
|
62
|
+
return Err(f"Failed to serialize value: {e}")
|
|
63
|
+
|
|
64
|
+
plaintext = payload.encode("utf-8")
|
|
65
|
+
iv = os.urandom(12)
|
|
66
|
+
|
|
67
|
+
ciphers = self._get_ciphers()
|
|
68
|
+
ciphertext = ciphers[0].encrypt(iv, plaintext, None)
|
|
69
|
+
|
|
70
|
+
encrypted_payload = json.dumps(
|
|
71
|
+
{
|
|
72
|
+
"iv": base64.b64encode(iv).decode("ascii"),
|
|
73
|
+
"value": base64.b64encode(ciphertext).decode("ascii"),
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
return Ok(base64.b64encode(encrypted_payload.encode("utf-8")).decode("ascii"))
|
|
78
|
+
|
|
79
|
+
def decrypt(self, payload: str) -> Result[JsonValue, str]:
|
|
80
|
+
"""Decrypt a payload.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
payload: Base64-encoded encrypted payload.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Ok with the decrypted value, or Err with message.
|
|
87
|
+
"""
|
|
88
|
+
try:
|
|
89
|
+
encrypted_data = json.loads(base64.b64decode(payload))
|
|
90
|
+
iv = base64.b64decode(encrypted_data["iv"])
|
|
91
|
+
ciphertext = base64.b64decode(encrypted_data["value"])
|
|
92
|
+
except (json.JSONDecodeError, KeyError, binascii.Error) as e:
|
|
93
|
+
return Err(f"Invalid encrypted payload format: {e}")
|
|
94
|
+
|
|
95
|
+
for cipher in self._get_ciphers():
|
|
96
|
+
try:
|
|
97
|
+
plaintext = cipher.decrypt(iv, ciphertext, None)
|
|
98
|
+
break
|
|
99
|
+
except InvalidTag:
|
|
100
|
+
continue
|
|
101
|
+
else:
|
|
102
|
+
return Err("Decryption failed: invalid key or corrupted data")
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
data = json.loads(plaintext.decode("utf-8"))
|
|
106
|
+
if "__str__" in data:
|
|
107
|
+
return Ok(data["__str__"])
|
|
108
|
+
return Ok(data["__json__"])
|
|
109
|
+
except (json.JSONDecodeError, KeyError) as e:
|
|
110
|
+
return Err(f"Decryption failed: invalid payload structure: {e}")
|
|
111
|
+
|
|
112
|
+
def _get_ciphers(self) -> list[AESGCM]:
|
|
113
|
+
"""Return cached ciphers, loading keys on first access.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
List of AESGCM cipher instances.
|
|
117
|
+
"""
|
|
118
|
+
if self._ciphers is None:
|
|
119
|
+
self._ciphers = self._load_keys()
|
|
120
|
+
return self._ciphers
|
|
121
|
+
|
|
122
|
+
def _load_keys(self) -> list[AESGCM]:
|
|
123
|
+
"""Load encryption keys from configuration.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
List of AESGCM cipher instances.
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
ValueError: If no encryption key is configured.
|
|
130
|
+
"""
|
|
131
|
+
config = self._app.make(ConfigRepository).expect(
|
|
132
|
+
"ConfigRepository not found in container"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
key = config.get("app.key", default=None).unwrap_or(None)
|
|
136
|
+
previous_keys = config.get("app.previous_keys", default=[]).unwrap_or([])
|
|
137
|
+
|
|
138
|
+
if key is None:
|
|
139
|
+
msg = "No encryption key configured. Set 'app.key' in configuration."
|
|
140
|
+
raise ValueError(msg)
|
|
141
|
+
|
|
142
|
+
ciphers = [AESGCM(self._parse_key(key))]
|
|
143
|
+
|
|
144
|
+
for prev_key in previous_keys:
|
|
145
|
+
ciphers.append(AESGCM(self._parse_key(prev_key)))
|
|
146
|
+
|
|
147
|
+
return ciphers
|
|
148
|
+
|
|
149
|
+
def _parse_key(self, key: str) -> bytes:
|
|
150
|
+
"""Parse a base64-encoded key into bytes.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
key: Base64-encoded 32-byte key.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
The decoded key bytes.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
ValueError: If key is not valid base64 or not 32 bytes.
|
|
160
|
+
"""
|
|
161
|
+
try:
|
|
162
|
+
key_bytes = base64.b64decode(key)
|
|
163
|
+
except binascii.Error as e:
|
|
164
|
+
msg = "Invalid encryption key: must be valid base64"
|
|
165
|
+
raise ValueError(msg) from e
|
|
166
|
+
|
|
167
|
+
if len(key_bytes) != 32:
|
|
168
|
+
msg = f"Invalid encryption key: must be 32 bytes, got {len(key_bytes)}"
|
|
169
|
+
raise ValueError(msg)
|
|
170
|
+
|
|
171
|
+
return key_bytes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Encrypter protocol."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Protocol, runtime_checkable
|
|
4
|
+
|
|
5
|
+
from neva import Result
|
|
6
|
+
|
|
7
|
+
JsonValue = str | int | float | bool | list[Any] | dict[str, Any] | None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@runtime_checkable
|
|
11
|
+
class Encrypter(Protocol):
|
|
12
|
+
"""Symmetric encryption interface."""
|
|
13
|
+
|
|
14
|
+
def encrypt(self, value: JsonValue) -> Result[str, str]:
|
|
15
|
+
"""Encrypt a value.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
value: The value to encrypt. Non-string values are JSON serialized.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
Ok with base64-encoded encrypted payload, or Err with message.
|
|
22
|
+
"""
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
def decrypt(self, payload: str) -> Result[JsonValue, str]:
|
|
26
|
+
"""Decrypt a payload.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
payload: Base64-encoded encrypted payload.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Ok with the decrypted value, or Err with message.
|
|
33
|
+
"""
|
|
34
|
+
...
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Hashing module for password hashing."""
|
|
2
|
+
|
|
3
|
+
from neva.security.hashing.hash_manager import HashManager
|
|
4
|
+
from neva.security.hashing.hashers.argon2 import Argon2Hasher
|
|
5
|
+
from neva.security.hashing.hashers.bcrypt import BcryptHasher
|
|
6
|
+
from neva.security.hashing.hashers.protocol import Hasher
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Argon2Hasher",
|
|
10
|
+
"BcryptHasher",
|
|
11
|
+
"HashManager",
|
|
12
|
+
"Hasher",
|
|
13
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Hashing module config."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal, NotRequired, TypedDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BcryptConfig(TypedDict):
|
|
7
|
+
"""Bcrypt hasher config."""
|
|
8
|
+
|
|
9
|
+
rounds: int
|
|
10
|
+
prefix: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Argon2Config(TypedDict):
|
|
14
|
+
"""Argon2 hasher config."""
|
|
15
|
+
|
|
16
|
+
time_cost: int
|
|
17
|
+
memory_cost: int
|
|
18
|
+
parallelism: int
|
|
19
|
+
hash_len: int
|
|
20
|
+
salt_len: int
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class HashingConfig(TypedDict):
|
|
24
|
+
"""Hasher config."""
|
|
25
|
+
|
|
26
|
+
driver: Literal["argon2", "bcrypt"]
|
|
27
|
+
argon: NotRequired[Argon2Config]
|
|
28
|
+
bcrypt: NotRequired[BcryptConfig]
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Hash manager for managing password hashing strategies."""
|
|
2
|
+
|
|
3
|
+
from typing import override
|
|
4
|
+
|
|
5
|
+
from neva import Option, from_optional
|
|
6
|
+
from neva.arch import Application
|
|
7
|
+
from neva.config import ConfigRepository
|
|
8
|
+
from neva.security.hashing.hashers.argon2 import Argon2Hasher
|
|
9
|
+
from neva.security.hashing.hashers.bcrypt import BcryptHasher
|
|
10
|
+
from neva.security.hashing.hashers.protocol import Hasher
|
|
11
|
+
from neva.support.strategy import StrategyResolver
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HashManager(StrategyResolver[Hasher]):
|
|
15
|
+
"""Manager for password hashing strategies."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, app: Application) -> None:
|
|
18
|
+
"""Initialize the hash manager and register available hashers.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
app: The application instance for configuration access.
|
|
22
|
+
"""
|
|
23
|
+
super().__init__(app)
|
|
24
|
+
|
|
25
|
+
self.register("argon2", self._create_argon2_hasher)
|
|
26
|
+
self.register("bcrypt", self._create_bcrypt_hasher)
|
|
27
|
+
|
|
28
|
+
@override
|
|
29
|
+
def default(self) -> Option[str]:
|
|
30
|
+
"""Get the default hasher from configuration.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Option containing the default hasher name ("argon2" or "bcrypt").
|
|
34
|
+
"""
|
|
35
|
+
config_result = self.app.make(ConfigRepository)
|
|
36
|
+
if config_result.is_err:
|
|
37
|
+
return from_optional(None)
|
|
38
|
+
|
|
39
|
+
config = config_result.unwrap()
|
|
40
|
+
driver = config.get("hashing.driver", default=None).unwrap_or(None)
|
|
41
|
+
return from_optional(driver)
|
|
42
|
+
|
|
43
|
+
def make(
|
|
44
|
+
self,
|
|
45
|
+
plaintext: str | bytes,
|
|
46
|
+
*,
|
|
47
|
+
salt: bytes | None = None,
|
|
48
|
+
hasher: str | None = None,
|
|
49
|
+
) -> str:
|
|
50
|
+
"""Hash a plaintext password using the specified or default hasher.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
plaintext: The password to hash.
|
|
54
|
+
salt: Optional salt for the hash.
|
|
55
|
+
hasher: Name of the hasher to use (defaults to configured hasher).
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
The hashed password string.
|
|
59
|
+
"""
|
|
60
|
+
hasher_instance = self.use(hasher).expect(
|
|
61
|
+
f"Failed to resolve hasher '{hasher or 'default'}'"
|
|
62
|
+
)
|
|
63
|
+
return hasher_instance.make(plaintext, salt=salt)
|
|
64
|
+
|
|
65
|
+
def check(
|
|
66
|
+
self,
|
|
67
|
+
plaintext: str | bytes,
|
|
68
|
+
hashed: str | bytes,
|
|
69
|
+
*,
|
|
70
|
+
hasher: str | None = None,
|
|
71
|
+
) -> bool:
|
|
72
|
+
"""Verify a password against a hashed value.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
plaintext: The plaintext password to verify.
|
|
76
|
+
hashed: The hashed password to verify against.
|
|
77
|
+
hasher: Name of the hasher to use (defaults to configured hasher).
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
True if the password matches, False otherwise.
|
|
81
|
+
"""
|
|
82
|
+
hasher_instance = self.use(hasher).expect(
|
|
83
|
+
f"Failed to resolve hasher '{hasher or 'default'}'"
|
|
84
|
+
)
|
|
85
|
+
return hasher_instance.check(plaintext, hashed)
|
|
86
|
+
|
|
87
|
+
def needs_rehash(
|
|
88
|
+
self,
|
|
89
|
+
hashed: str | bytes,
|
|
90
|
+
*,
|
|
91
|
+
hasher: str | None = None,
|
|
92
|
+
) -> bool:
|
|
93
|
+
"""Check if a hashed password needs to be rehashed.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
hashed: The hashed password to check.
|
|
97
|
+
hasher: Name of the hasher to use (defaults to configured hasher).
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
True if the password needs rehashing, False otherwise.
|
|
101
|
+
"""
|
|
102
|
+
hasher_instance = self.use(hasher).expect(
|
|
103
|
+
f"Failed to resolve hasher '{hasher or 'default'}'"
|
|
104
|
+
)
|
|
105
|
+
return hasher_instance.needs_rehash(hashed)
|
|
106
|
+
|
|
107
|
+
def _create_argon2_hasher(self, manager: StrategyResolver[Hasher]) -> Argon2Hasher:
|
|
108
|
+
"""Create an instance of the Argon2 hash strategy.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
manager: The strategy resolver instance.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Configured Argon2Hasher instance.
|
|
115
|
+
"""
|
|
116
|
+
config = manager.app.make(ConfigRepository).unwrap()
|
|
117
|
+
argon_config = config.get("hashing.argon", default={}).unwrap()
|
|
118
|
+
|
|
119
|
+
if isinstance(argon_config, dict):
|
|
120
|
+
return Argon2Hasher(
|
|
121
|
+
time_cost=argon_config.get("time_cost", 2),
|
|
122
|
+
memory_cost=argon_config.get("memory_cost", 102400),
|
|
123
|
+
parallelism=argon_config.get("parallelism", 8),
|
|
124
|
+
hash_len=argon_config.get("hash_len", 16),
|
|
125
|
+
salt_len=argon_config.get("salt_len", 16),
|
|
126
|
+
)
|
|
127
|
+
return Argon2Hasher()
|
|
128
|
+
|
|
129
|
+
def _create_bcrypt_hasher(self, manager: StrategyResolver[Hasher]) -> BcryptHasher:
|
|
130
|
+
"""Create an instance of the Bcrypt hash strategy.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
manager: The strategy resolver instance.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Configured BcryptHasher instance.
|
|
137
|
+
"""
|
|
138
|
+
config = manager.app.make(ConfigRepository).unwrap()
|
|
139
|
+
bcrypt_config = config.get("hashing.bcrypt", default={}).unwrap()
|
|
140
|
+
|
|
141
|
+
if isinstance(bcrypt_config, dict):
|
|
142
|
+
return BcryptHasher(
|
|
143
|
+
rounds=bcrypt_config.get("rounds", 12),
|
|
144
|
+
prefix=bcrypt_config.get("prefix", "2b"),
|
|
145
|
+
)
|
|
146
|
+
return BcryptHasher()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Hasher protocol and built-in hashers."""
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Argon2 hasher."""
|
|
2
|
+
|
|
3
|
+
from typing import override
|
|
4
|
+
|
|
5
|
+
import argon2
|
|
6
|
+
from pwdlib.hashers.argon2 import Argon2Hasher as PwdLibArgon2Hasher
|
|
7
|
+
|
|
8
|
+
from neva.security.hashing.hashers.protocol import Hasher
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Argon2Hasher(Hasher):
|
|
12
|
+
"""Argon2 hasher."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
time_cost: int = argon2.DEFAULT_TIME_COST,
|
|
17
|
+
memory_cost: int = argon2.DEFAULT_MEMORY_COST,
|
|
18
|
+
parallelism: int = argon2.DEFAULT_PARALLELISM,
|
|
19
|
+
hash_len: int = argon2.DEFAULT_HASH_LENGTH,
|
|
20
|
+
salt_len: int = argon2.DEFAULT_RANDOM_SALT_LENGTH,
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Initialize the hasher."""
|
|
23
|
+
self._hasher: PwdLibArgon2Hasher = PwdLibArgon2Hasher(
|
|
24
|
+
time_cost,
|
|
25
|
+
memory_cost,
|
|
26
|
+
parallelism,
|
|
27
|
+
hash_len,
|
|
28
|
+
salt_len,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
@override
|
|
32
|
+
def make(
|
|
33
|
+
self,
|
|
34
|
+
plaintext: str | bytes,
|
|
35
|
+
*,
|
|
36
|
+
salt: bytes | None = None,
|
|
37
|
+
) -> str:
|
|
38
|
+
return self._hasher.hash(
|
|
39
|
+
password=plaintext,
|
|
40
|
+
salt=salt,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
@override
|
|
44
|
+
def check(
|
|
45
|
+
self,
|
|
46
|
+
plaintext: str | bytes,
|
|
47
|
+
hashed: str | bytes,
|
|
48
|
+
) -> bool:
|
|
49
|
+
return self._hasher.verify(
|
|
50
|
+
password=plaintext,
|
|
51
|
+
hash=hashed,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
@override
|
|
55
|
+
def needs_rehash(
|
|
56
|
+
self,
|
|
57
|
+
hashed: str | bytes,
|
|
58
|
+
) -> bool:
|
|
59
|
+
return self._hasher.check_needs_rehash(hashed)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Bcrypt hasher."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal, override
|
|
4
|
+
|
|
5
|
+
from pwdlib.hashers.bcrypt import BcryptHasher as PwdLibBcryptHasher
|
|
6
|
+
|
|
7
|
+
from neva.security.hashing.hashers.protocol import Hasher
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BcryptHasher(Hasher):
|
|
11
|
+
"""Bcrypt hasher."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
rounds: int = 12,
|
|
16
|
+
prefix: Literal["2a", "2b"] = "2a",
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Initialize the hasher."""
|
|
19
|
+
self._hasher: PwdLibBcryptHasher = PwdLibBcryptHasher(rounds, prefix)
|
|
20
|
+
|
|
21
|
+
@override
|
|
22
|
+
def make(
|
|
23
|
+
self,
|
|
24
|
+
plaintext: str | bytes,
|
|
25
|
+
*,
|
|
26
|
+
salt: bytes | None = None,
|
|
27
|
+
) -> str:
|
|
28
|
+
return self._hasher.hash(
|
|
29
|
+
password=plaintext,
|
|
30
|
+
salt=salt,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
@override
|
|
34
|
+
def check(
|
|
35
|
+
self,
|
|
36
|
+
plaintext: str | bytes,
|
|
37
|
+
hashed: str | bytes,
|
|
38
|
+
) -> bool:
|
|
39
|
+
return self._hasher.verify(
|
|
40
|
+
password=plaintext,
|
|
41
|
+
hash=hashed,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
@override
|
|
45
|
+
def needs_rehash(
|
|
46
|
+
self,
|
|
47
|
+
hashed: str | bytes,
|
|
48
|
+
) -> bool:
|
|
49
|
+
return self._hasher.check_needs_rehash(hashed)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Hasher protocol."""
|
|
2
|
+
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Hasher(Protocol):
|
|
7
|
+
"""Wrapper hasher class."""
|
|
8
|
+
|
|
9
|
+
def make(
|
|
10
|
+
self,
|
|
11
|
+
plaintext: str | bytes,
|
|
12
|
+
*,
|
|
13
|
+
salt: bytes | None = None,
|
|
14
|
+
) -> str:
|
|
15
|
+
"""Hash a plaintext password."""
|
|
16
|
+
...
|
|
17
|
+
|
|
18
|
+
def check(
|
|
19
|
+
self,
|
|
20
|
+
plaintext: str | bytes,
|
|
21
|
+
hashed: str | bytes,
|
|
22
|
+
) -> bool:
|
|
23
|
+
"""Verify a password against a hashed value."""
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
def needs_rehash(
|
|
27
|
+
self,
|
|
28
|
+
hashed: str | bytes,
|
|
29
|
+
) -> bool:
|
|
30
|
+
"""Check if a hashed value needs to be rehashed."""
|
|
31
|
+
...
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Security service provider."""
|
|
2
|
+
|
|
3
|
+
from typing import Self, override
|
|
4
|
+
|
|
5
|
+
from neva import Ok, Result, arch
|
|
6
|
+
from neva.security.encryption import AesEncrypter, Encrypter
|
|
7
|
+
from neva.security.hashing import HashManager
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SecurityProvider(arch.ServiceProvider):
|
|
11
|
+
"""Provider for security features."""
|
|
12
|
+
|
|
13
|
+
@override
|
|
14
|
+
def register(self) -> Result[Self, str]:
|
|
15
|
+
self.app.bind(HashManager, interface=HashManager)
|
|
16
|
+
self.app.bind(AesEncrypter, interface=Encrypter)
|
|
17
|
+
return Ok(self)
|
|
@@ -21,12 +21,6 @@ def get_attr(obj: object, name: str) -> Result[Any, str]:
|
|
|
21
21
|
|
|
22
22
|
Returns:
|
|
23
23
|
Result containing the attribute value or an error message if not found.
|
|
24
|
-
|
|
25
|
-
Example:
|
|
26
|
-
result = get_attr(my_object, "my_attribute")
|
|
27
|
-
if result.is_ok:
|
|
28
|
-
value = result.unwrap()
|
|
29
|
-
|
|
30
24
|
"""
|
|
31
25
|
if hasattr(obj, name):
|
|
32
26
|
return Ok(getattr(obj, name))
|
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
from neva.support.facade.app import App
|
|
4
4
|
from neva.support.facade.config import Config
|
|
5
|
+
from neva.support.facade.crypt import Crypt
|
|
6
|
+
from neva.support.facade.hash import Hash
|
|
5
7
|
from neva.support.facade.log import Log
|
|
6
8
|
|
|
7
9
|
__all__ = [
|
|
8
10
|
"App",
|
|
9
11
|
"Config",
|
|
12
|
+
"Crypt",
|
|
13
|
+
"Hash",
|
|
10
14
|
"Log",
|
|
11
15
|
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Encryption utility facade."""
|
|
2
|
+
|
|
3
|
+
from typing import override
|
|
4
|
+
|
|
5
|
+
from neva.arch import Facade
|
|
6
|
+
from neva.security.encryption import Encrypter
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Crypt(Facade):
|
|
10
|
+
"""Encryption utility facade."""
|
|
11
|
+
|
|
12
|
+
@classmethod
|
|
13
|
+
@override
|
|
14
|
+
def get_facade_accessor(cls) -> type:
|
|
15
|
+
return Encrypter
|