spakky-cryptography 6.5.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.
Files changed (25) hide show
  1. spakky_cryptography-6.5.0/PKG-INFO +41 -0
  2. spakky_cryptography-6.5.0/README.md +26 -0
  3. spakky_cryptography-6.5.0/pyproject.toml +80 -0
  4. spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/__init__.py +47 -0
  5. spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/auth_provider.py +409 -0
  6. spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/contributions/__init__.py +1 -0
  7. spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/contributions/auth.py +11 -0
  8. spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/cryptography/__init__.py +0 -0
  9. spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/cryptography/aes.py +91 -0
  10. spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/cryptography/gcm.py +96 -0
  11. spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/cryptography/interface.py +35 -0
  12. spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/cryptography/rsa.py +190 -0
  13. spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/encoding.py +81 -0
  14. spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/error.py +64 -0
  15. spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/hash.py +102 -0
  16. spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/hmac_signer.py +118 -0
  17. spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/key.py +96 -0
  18. spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/main.py +9 -0
  19. spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/password/__init__.py +0 -0
  20. spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/password/argon2.py +142 -0
  21. spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/password/bcrypt.py +103 -0
  22. spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/password/interface.py +20 -0
  23. spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/password/pbkdf2.py +130 -0
  24. spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/password/scrypt.py +149 -0
  25. spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/py.typed +0 -0
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.3
2
+ Name: spakky-cryptography
3
+ Version: 6.5.0
4
+ Summary: Cryptography and auth snapshot provider plugin for Spakky framework
5
+ Author: Spakky
6
+ Author-email: Spakky <sejong418@icloud.com>
7
+ License: MIT
8
+ Requires-Dist: argon2-cffi>=25.1.0
9
+ Requires-Dist: bcrypt>=5.0.0
10
+ Requires-Dist: pycryptodome>=3.23.0
11
+ Requires-Dist: spakky>=6.5.0
12
+ Requires-Dist: spakky-auth>=6.5.0
13
+ Requires-Python: >=3.12
14
+ Description-Content-Type: text/markdown
15
+
16
+ # spakky-cryptography
17
+
18
+ `spakky-cryptography` provides cryptographic utilities and the auth provider
19
+ capabilities required by signed
20
+ `AuthContextSnapshot` propagation and password hash verification.
21
+
22
+ ## Retained Utilities
23
+
24
+ - `Key`, `Base64Encoder`, `Hash`, `HMAC`
25
+ - `ICryptor`, `ISigner`
26
+ - `Aes`, `Gcm`, `Rsa`, `AsymmetricKey`
27
+ - `Argon2PasswordEncoder`, `BcryptPasswordEncoder`, `Pbkdf2PasswordEncoder`, `ScryptPasswordEncoder`
28
+
29
+ JWT/OIDC token validation remains outside this package.
30
+
31
+ ## Auth Provider Capabilities
32
+
33
+ The plugin registers `CryptographyAuthProvider`, which implements:
34
+
35
+ - `AuthCapability.SNAPSHOT_SIGN`
36
+ - `AuthCapability.SNAPSHOT_VERIFY`
37
+ - `AuthCapability.PASSWORD_HASH`
38
+ - `AuthCapability.PASSWORD_VERIFY`
39
+
40
+ Snapshot verification maps missing, invalid, and expired envelopes to
41
+ `CHALLENGE` decisions. Provider-unavailable conditions map to `ERROR`.
@@ -0,0 +1,26 @@
1
+ # spakky-cryptography
2
+
3
+ `spakky-cryptography` provides cryptographic utilities and the auth provider
4
+ capabilities required by signed
5
+ `AuthContextSnapshot` propagation and password hash verification.
6
+
7
+ ## Retained Utilities
8
+
9
+ - `Key`, `Base64Encoder`, `Hash`, `HMAC`
10
+ - `ICryptor`, `ISigner`
11
+ - `Aes`, `Gcm`, `Rsa`, `AsymmetricKey`
12
+ - `Argon2PasswordEncoder`, `BcryptPasswordEncoder`, `Pbkdf2PasswordEncoder`, `ScryptPasswordEncoder`
13
+
14
+ JWT/OIDC token validation remains outside this package.
15
+
16
+ ## Auth Provider Capabilities
17
+
18
+ The plugin registers `CryptographyAuthProvider`, which implements:
19
+
20
+ - `AuthCapability.SNAPSHOT_SIGN`
21
+ - `AuthCapability.SNAPSHOT_VERIFY`
22
+ - `AuthCapability.PASSWORD_HASH`
23
+ - `AuthCapability.PASSWORD_VERIFY`
24
+
25
+ Snapshot verification maps missing, invalid, and expired envelopes to
26
+ `CHALLENGE` decisions. Provider-unavailable conditions map to `ERROR`.
@@ -0,0 +1,80 @@
1
+ [project]
2
+ name = "spakky-cryptography"
3
+ version = "6.5.0"
4
+ description = "Cryptography and auth snapshot provider plugin for Spakky framework"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "Spakky", email = "sejong418@icloud.com" }]
9
+ dependencies = [
10
+ "argon2-cffi>=25.1.0",
11
+ "bcrypt>=5.0.0",
12
+ "pycryptodome>=3.23.0",
13
+ "spakky>=6.5.0",
14
+ "spakky-auth>=6.5.0",
15
+ ]
16
+
17
+ [project.entry-points."spakky.plugins"]
18
+ spakky-cryptography = "spakky.plugins.cryptography.main:initialize"
19
+
20
+ [project.entry-points."spakky.contributions.spakky.auth"]
21
+ spakky-cryptography = "spakky.plugins.cryptography.contributions.auth:initialize"
22
+
23
+ [build-system]
24
+ requires = ["uv_build>=0.10.10,<0.11.0"]
25
+ build-backend = "uv_build"
26
+
27
+ [tool.uv.build-backend]
28
+ module-root = "src"
29
+ module-name = "spakky.plugins.cryptography"
30
+
31
+ [tool.pyrefly]
32
+ python-version = "3.12"
33
+ search_path = ["src", ".", "../../core/spakky/src", "../../core/spakky-auth/src"]
34
+ project_excludes = ["**/__pycache__", "**/*.pyc"]
35
+
36
+ [tool.ruff]
37
+ builtins = ["_"]
38
+ cache-dir = "~/.cache/ruff"
39
+
40
+ [tool.pytest.ini_options]
41
+ pythonpath = ["src", "../../core/spakky/src", "../../core/spakky-auth/src"]
42
+ testpaths = "tests"
43
+ python_files = ["test_*.py"]
44
+ asyncio_mode = "auto"
45
+ addopts = """
46
+ --cov
47
+ --cov-report=term
48
+ --cov-report=xml
49
+ --no-cov-on-fail
50
+ --strict-markers
51
+ --dist=load
52
+ -p no:warnings
53
+ -n auto
54
+ --spec
55
+ """
56
+ spec_test_format = "{result} {docstring_summary}"
57
+
58
+ [tool.coverage.run]
59
+ include = ["src/spakky/plugins/cryptography/**/*.py"]
60
+ branch = true
61
+
62
+ [tool.coverage.report]
63
+ show_missing = true
64
+ precision = 2
65
+ fail_under = 100
66
+ skip_empty = true
67
+ exclude_lines = [
68
+ "pragma: no cover",
69
+ "def __repr__",
70
+ "raise AssertionError",
71
+ "raise NotImplementedError",
72
+ "@(abc\\.)?abstractmethod",
73
+ "@(typing\\.)?overload",
74
+ "\\.\\.\\.",
75
+ "pass",
76
+ ]
77
+
78
+ [tool.uv.sources]
79
+ spakky = { workspace = true }
80
+ spakky-auth = { workspace = true }
@@ -0,0 +1,47 @@
1
+ """Cryptography provider plugin public API."""
2
+
3
+ from spakky.core.application.plugin import Plugin
4
+ from spakky.plugins.cryptography.auth_provider import (
5
+ AuthContextSnapshotVerificationResult,
6
+ CryptographyAuthProvider,
7
+ CryptographyAuthProviderConfig,
8
+ )
9
+ from spakky.plugins.cryptography.cryptography.aes import Aes
10
+ from spakky.plugins.cryptography.cryptography.gcm import Gcm
11
+ from spakky.plugins.cryptography.cryptography.interface import ICryptor, ISigner
12
+ from spakky.plugins.cryptography.cryptography.rsa import AsymmetricKey, Rsa
13
+ from spakky.plugins.cryptography.encoding import Base64Encoder
14
+ from spakky.plugins.cryptography.hash import Hash, HashType
15
+ from spakky.plugins.cryptography.hmac_signer import HMAC, HMACType
16
+ from spakky.plugins.cryptography.key import Key
17
+ from spakky.plugins.cryptography.password.argon2 import Argon2PasswordEncoder
18
+ from spakky.plugins.cryptography.password.bcrypt import BcryptPasswordEncoder
19
+ from spakky.plugins.cryptography.password.interface import IPasswordEncoder
20
+ from spakky.plugins.cryptography.password.pbkdf2 import Pbkdf2PasswordEncoder
21
+ from spakky.plugins.cryptography.password.scrypt import ScryptPasswordEncoder
22
+
23
+ PLUGIN_NAME = Plugin(name="spakky-cryptography")
24
+
25
+ __all__ = [
26
+ "PLUGIN_NAME",
27
+ "Aes",
28
+ "Argon2PasswordEncoder",
29
+ "AsymmetricKey",
30
+ "AuthContextSnapshotVerificationResult",
31
+ "Base64Encoder",
32
+ "BcryptPasswordEncoder",
33
+ "CryptographyAuthProvider",
34
+ "CryptographyAuthProviderConfig",
35
+ "Gcm",
36
+ "HMAC",
37
+ "HMACType",
38
+ "Hash",
39
+ "HashType",
40
+ "ICryptor",
41
+ "IPasswordEncoder",
42
+ "ISigner",
43
+ "Key",
44
+ "Pbkdf2PasswordEncoder",
45
+ "Rsa",
46
+ "ScryptPasswordEncoder",
47
+ ]
@@ -0,0 +1,409 @@
1
+ """Auth snapshot and password capabilities backed by cryptographic utilities."""
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass, field
5
+ from datetime import UTC, datetime, timedelta
6
+ from typing import override
7
+ import json
8
+
9
+ from spakky.auth import (
10
+ AuthCapability,
11
+ AuthClaim,
12
+ AuthClaimValue,
13
+ AuthContext,
14
+ AuthContextSnapshot,
15
+ AuthContextSnapshotSignature,
16
+ AuthInvocation,
17
+ AuthProviderContribution,
18
+ AuthSubject,
19
+ AuthorizationDecision,
20
+ AuthorizationReasonCode,
21
+ AuthPasswordHash,
22
+ AuthPasswordPlaintext,
23
+ AuthVerificationProviderUnavailableError,
24
+ ExpiredAuthContextSnapshotError,
25
+ IAuthContextSnapshotSigner,
26
+ IAuthContextSnapshotVerifier,
27
+ InvalidAuthContextSnapshotError,
28
+ IPasswordHasher,
29
+ IPasswordVerifier,
30
+ MissingAuthContextSnapshotError,
31
+ SnapshotSignRequest,
32
+ )
33
+ from spakky.auth.constants import AUTH_CONTEXT_SNAPSHOT_SCHEMA_VERSION
34
+ from spakky.core.pod.annotations.pod import Pod
35
+ from spakky.plugins.cryptography.encoding import Base64Encoder
36
+ from spakky.plugins.cryptography.hmac_signer import HMAC, HMACType
37
+ from spakky.plugins.cryptography.key import Key
38
+ from spakky.plugins.cryptography.password.argon2 import Argon2PasswordEncoder
39
+ from spakky.plugins.cryptography.password.bcrypt import BcryptPasswordEncoder
40
+ from spakky.plugins.cryptography.password.pbkdf2 import Pbkdf2PasswordEncoder
41
+ from spakky.plugins.cryptography.password.scrypt import ScryptPasswordEncoder
42
+
43
+ CRYPTOGRAPHY_AUTH_PROVIDER_ID = "provider:spakky-cryptography"
44
+ """Stable auth provider id advertised by spakky-cryptography."""
45
+
46
+ SNAPSHOT_SIGNATURE_ALGORITHM = "HS256"
47
+ """Snapshot envelope signature algorithm used by this provider."""
48
+
49
+ JsonObject = dict[str, object]
50
+
51
+
52
+ def _utc_now() -> datetime:
53
+ """Return the current UTC timestamp."""
54
+ return datetime.now(UTC)
55
+
56
+
57
+ @dataclass(frozen=True, slots=True, kw_only=True)
58
+ class CryptographyAuthProviderConfig:
59
+ """Runtime config for cryptography auth provider capabilities."""
60
+
61
+ snapshot_key: Key = field(default_factory=lambda: Key(size=32))
62
+ """HMAC key used to sign and verify AuthContextSnapshot envelopes."""
63
+
64
+ snapshot_key_id: str = "spakky-cryptography:default"
65
+ """Identifier carried in signed snapshot envelopes."""
66
+
67
+ snapshot_ttl: timedelta = timedelta(minutes=5)
68
+ """Validity window for newly signed snapshots."""
69
+
70
+ clock: Callable[[], datetime] = _utc_now
71
+ """Clock used for signing and expiration validation."""
72
+
73
+ verification_available: bool = True
74
+ """Whether snapshot verification provider dependencies are available."""
75
+
76
+ password_available: bool = True
77
+ """Whether password hashing provider dependencies are available."""
78
+
79
+
80
+ @dataclass(frozen=True, slots=True, kw_only=True)
81
+ class AuthContextSnapshotVerificationResult:
82
+ """Decision plus optional AuthContext produced by snapshot verification."""
83
+
84
+ decision: AuthorizationDecision
85
+ """ALLOW, CHALLENGE, or ERROR decision for the verification attempt."""
86
+
87
+ auth_context: AuthContext | None = None
88
+ """Verified auth context when decision is ALLOW."""
89
+
90
+
91
+ @Pod()
92
+ class CryptographyAuthProvider(
93
+ IAuthContextSnapshotSigner,
94
+ IAuthContextSnapshotVerifier,
95
+ IPasswordHasher,
96
+ IPasswordVerifier,
97
+ ):
98
+ """Cryptography-backed provider for snapshot and password auth capabilities."""
99
+
100
+ _config: CryptographyAuthProviderConfig
101
+
102
+ def __init__(
103
+ self,
104
+ config: CryptographyAuthProviderConfig = CryptographyAuthProviderConfig(),
105
+ ) -> None:
106
+ self._config = config
107
+
108
+ @override
109
+ def sign_snapshot(self, request: SnapshotSignRequest) -> AuthContextSnapshot:
110
+ """Create a signed AuthContextSnapshot envelope."""
111
+ issued_at = self._aware_datetime(self._config.clock())
112
+ tenant = (
113
+ request.tenant
114
+ if request.tenant is not None
115
+ else request.auth_context.tenant
116
+ )
117
+ unsigned_payload = self._unsigned_payload(
118
+ auth_context=request.auth_context,
119
+ tenant=tenant,
120
+ issued_at=issued_at,
121
+ expires_at=issued_at + self._config.snapshot_ttl,
122
+ )
123
+ signature = HMAC.sign_text(
124
+ self._config.snapshot_key,
125
+ HMACType.HS256,
126
+ self._canonical_json(unsigned_payload),
127
+ url_safe=True,
128
+ )
129
+ return AuthContextSnapshot(
130
+ subject=request.auth_context.subject,
131
+ issuer=request.auth_context.issuer,
132
+ issued_at=issued_at,
133
+ expires_at=issued_at + self._config.snapshot_ttl,
134
+ signature=AuthContextSnapshotSignature(
135
+ key_id=self._config.snapshot_key_id,
136
+ algorithm=SNAPSHOT_SIGNATURE_ALGORITHM,
137
+ signature=signature,
138
+ ),
139
+ tenant=tenant,
140
+ roles=request.auth_context.roles,
141
+ scopes=request.auth_context.scopes,
142
+ selected_claims=request.auth_context.claims,
143
+ )
144
+
145
+ @override
146
+ def verify_snapshot(
147
+ self,
148
+ snapshot_envelope: str,
149
+ invocation: AuthInvocation,
150
+ ) -> AuthContext:
151
+ """Verify a signed snapshot envelope and return its AuthContext."""
152
+ if not self._config.verification_available:
153
+ raise AuthVerificationProviderUnavailableError()
154
+ if snapshot_envelope == "":
155
+ raise MissingAuthContextSnapshotError()
156
+ payload = self._decode_envelope(snapshot_envelope)
157
+ self._verify_payload_signature(payload)
158
+ expires_at = self._datetime_value(payload, "expires_at")
159
+ if expires_at < self._aware_datetime(self._config.clock()):
160
+ raise ExpiredAuthContextSnapshotError()
161
+ return self._auth_context_from_payload(payload)
162
+
163
+ def verify_snapshot_result(
164
+ self,
165
+ snapshot_envelope: str,
166
+ invocation: AuthInvocation,
167
+ ) -> AuthContextSnapshotVerificationResult:
168
+ """Verify a snapshot envelope and map auth errors to decisions."""
169
+ try:
170
+ auth_context = self.verify_snapshot(snapshot_envelope, invocation)
171
+ except MissingAuthContextSnapshotError:
172
+ return AuthContextSnapshotVerificationResult(
173
+ decision=AuthorizationDecision.challenge(
174
+ AuthorizationReasonCode.SNAPSHOT_MISSING
175
+ )
176
+ )
177
+ except InvalidAuthContextSnapshotError:
178
+ return AuthContextSnapshotVerificationResult(
179
+ decision=AuthorizationDecision.challenge(
180
+ AuthorizationReasonCode.SNAPSHOT_INVALID
181
+ )
182
+ )
183
+ except ExpiredAuthContextSnapshotError:
184
+ return AuthContextSnapshotVerificationResult(
185
+ decision=AuthorizationDecision.challenge(
186
+ AuthorizationReasonCode.SNAPSHOT_EXPIRED
187
+ )
188
+ )
189
+ except AuthVerificationProviderUnavailableError:
190
+ return AuthContextSnapshotVerificationResult(
191
+ decision=AuthorizationDecision.error(
192
+ AuthorizationReasonCode.VERIFICATION_PROVIDER_UNAVAILABLE
193
+ )
194
+ )
195
+ return AuthContextSnapshotVerificationResult(
196
+ decision=AuthorizationDecision.allow(),
197
+ auth_context=auth_context,
198
+ )
199
+
200
+ @override
201
+ def hash_password(self, password: AuthPasswordPlaintext) -> AuthPasswordHash:
202
+ """Hash plaintext password material for storage."""
203
+ if not self._config.password_available:
204
+ raise AuthVerificationProviderUnavailableError()
205
+ return BcryptPasswordEncoder(password=password).encode()
206
+
207
+ @override
208
+ def verify_password(
209
+ self,
210
+ password: AuthPasswordPlaintext,
211
+ password_hash: AuthPasswordHash,
212
+ ) -> AuthorizationDecision:
213
+ """Verify plaintext password material against a retained password hash."""
214
+ if not self._config.password_available:
215
+ return AuthorizationDecision.error(
216
+ AuthorizationReasonCode.VERIFICATION_PROVIDER_UNAVAILABLE
217
+ )
218
+ try:
219
+ if self._password_encoder(password_hash).challenge(password):
220
+ return AuthorizationDecision.allow()
221
+ except Exception:
222
+ return AuthorizationDecision.challenge(
223
+ AuthorizationReasonCode.INVALID_CREDENTIAL
224
+ )
225
+ return AuthorizationDecision.challenge(
226
+ AuthorizationReasonCode.INVALID_CREDENTIAL
227
+ )
228
+
229
+ def _unsigned_payload(
230
+ self,
231
+ auth_context: AuthContext,
232
+ tenant: str | None,
233
+ issued_at: datetime,
234
+ expires_at: datetime,
235
+ ) -> JsonObject:
236
+ selected_claims = {
237
+ claim.name: claim.value
238
+ for claim in sorted(auth_context.claims, key=lambda claim: claim.name)
239
+ }
240
+ return {
241
+ "correlation_id": None,
242
+ "delegation_chain": (),
243
+ "expires_at": expires_at.isoformat(),
244
+ "issued_at": issued_at.isoformat(),
245
+ "issuer": auth_context.issuer,
246
+ "roles": auth_context.roles,
247
+ "schema_version": AUTH_CONTEXT_SNAPSHOT_SCHEMA_VERSION,
248
+ "scopes": auth_context.scopes,
249
+ "selected_claims": selected_claims,
250
+ "subject": {
251
+ "display_name": auth_context.subject.display_name,
252
+ "id": auth_context.subject.id,
253
+ },
254
+ "tenant": tenant,
255
+ }
256
+
257
+ def _decode_envelope(self, snapshot_envelope: str) -> JsonObject:
258
+ try:
259
+ decoded = Base64Encoder.get_bytes(snapshot_envelope, url_safe=True).decode(
260
+ "UTF-8"
261
+ )
262
+ payload = json.loads(decoded)
263
+ if isinstance(payload, dict):
264
+ return self._string_keyed_dict(payload)
265
+ except Exception as exc:
266
+ raise InvalidAuthContextSnapshotError() from exc
267
+ raise InvalidAuthContextSnapshotError()
268
+
269
+ def _verify_payload_signature(self, payload: JsonObject) -> None:
270
+ signature_payload = self._dict_value(payload, "signature")
271
+ key_id = self._string_value(signature_payload, "key_id")
272
+ algorithm = self._string_value(signature_payload, "algorithm")
273
+ signature = self._string_value(signature_payload, "signature")
274
+ if key_id != self._config.snapshot_key_id:
275
+ raise InvalidAuthContextSnapshotError()
276
+ if algorithm != SNAPSHOT_SIGNATURE_ALGORITHM:
277
+ raise InvalidAuthContextSnapshotError()
278
+ unsigned_payload = dict(payload)
279
+ del unsigned_payload["signature"]
280
+ valid = HMAC.verify(
281
+ self._config.snapshot_key,
282
+ HMACType.HS256,
283
+ self._canonical_json(unsigned_payload),
284
+ signature,
285
+ url_safe=True,
286
+ )
287
+ if not valid:
288
+ raise InvalidAuthContextSnapshotError()
289
+
290
+ def _auth_context_from_payload(self, payload: JsonObject) -> AuthContext:
291
+ subject_payload = self._dict_value(payload, "subject")
292
+ return AuthContext(
293
+ subject=AuthSubject(
294
+ id=self._string_value(subject_payload, "id"),
295
+ display_name=self._optional_string_value(
296
+ subject_payload,
297
+ "display_name",
298
+ ),
299
+ ),
300
+ issuer=self._string_value(payload, "issuer"),
301
+ tenant=self._optional_string_value(payload, "tenant"),
302
+ roles=self._string_tuple_value(payload, "roles"),
303
+ scopes=self._string_tuple_value(payload, "scopes"),
304
+ claims=self._claims_value(payload, "selected_claims"),
305
+ )
306
+
307
+ def _password_encoder(
308
+ self,
309
+ password_hash: AuthPasswordHash,
310
+ ) -> (
311
+ Argon2PasswordEncoder
312
+ | BcryptPasswordEncoder
313
+ | Pbkdf2PasswordEncoder
314
+ | ScryptPasswordEncoder
315
+ ):
316
+ if password_hash.startswith(f"{Argon2PasswordEncoder.ALGORITHM_TYPE}:"):
317
+ return Argon2PasswordEncoder(password_hash=password_hash)
318
+ if password_hash.startswith(f"{BcryptPasswordEncoder.ALGORITHM_TYPE}:"):
319
+ return BcryptPasswordEncoder(password_hash=password_hash)
320
+ if password_hash.startswith(f"{Pbkdf2PasswordEncoder.ALGORITHM_TYPE}:"):
321
+ return Pbkdf2PasswordEncoder(password_hash=password_hash)
322
+ if password_hash.startswith(f"{ScryptPasswordEncoder.ALGORITHM_TYPE}:"):
323
+ return ScryptPasswordEncoder(password_hash=password_hash)
324
+ raise InvalidAuthContextSnapshotError()
325
+
326
+ def _datetime_value(self, payload: JsonObject, key: str) -> datetime:
327
+ try:
328
+ return self._aware_datetime(
329
+ datetime.fromisoformat(self._string_value(payload, key))
330
+ )
331
+ except Exception as exc:
332
+ raise InvalidAuthContextSnapshotError() from exc
333
+
334
+ def _aware_datetime(self, value: datetime) -> datetime:
335
+ if value.tzinfo is None:
336
+ return value.replace(tzinfo=UTC)
337
+ return value
338
+
339
+ def _canonical_json(self, payload: JsonObject) -> str:
340
+ return json.dumps(payload, sort_keys=True, separators=(",", ":"))
341
+
342
+ def _dict_value(self, payload: JsonObject, key: str) -> JsonObject:
343
+ value = payload.get(key)
344
+ if isinstance(value, dict):
345
+ return self._string_keyed_dict(value)
346
+ raise InvalidAuthContextSnapshotError()
347
+
348
+ def _string_value(self, payload: JsonObject, key: str) -> str:
349
+ value = payload.get(key)
350
+ if isinstance(value, str):
351
+ return value
352
+ raise InvalidAuthContextSnapshotError()
353
+
354
+ def _optional_string_value(self, payload: JsonObject, key: str) -> str | None:
355
+ value = payload.get(key)
356
+ if value is None:
357
+ return None
358
+ if isinstance(value, str):
359
+ return value
360
+ raise InvalidAuthContextSnapshotError()
361
+
362
+ def _string_tuple_value(self, payload: JsonObject, key: str) -> tuple[str, ...]:
363
+ value = payload.get(key)
364
+ if isinstance(value, list | tuple):
365
+ items: list[str] = []
366
+ for item in value:
367
+ if not isinstance(item, str):
368
+ raise InvalidAuthContextSnapshotError()
369
+ items.append(item)
370
+ return tuple(items)
371
+ raise InvalidAuthContextSnapshotError()
372
+
373
+ def _claims_value(self, payload: JsonObject, key: str) -> tuple[AuthClaim, ...]:
374
+ claims = self._dict_value(payload, key)
375
+ return tuple(
376
+ AuthClaim(name=name, value=self._claim_value(value))
377
+ for name, value in sorted(claims.items())
378
+ )
379
+
380
+ def _claim_value(self, value: object) -> AuthClaimValue:
381
+ if value is None:
382
+ return None
383
+ if isinstance(value, str | int | float | bool):
384
+ return value
385
+ raise InvalidAuthContextSnapshotError()
386
+
387
+ def _string_keyed_dict(self, payload: dict[object, object]) -> JsonObject:
388
+ result: JsonObject = {}
389
+ for key, value in payload.items():
390
+ if not isinstance(key, str):
391
+ raise InvalidAuthContextSnapshotError()
392
+ result[key] = value
393
+ return result
394
+
395
+
396
+ @Pod(name="spakky_cryptography_auth_provider_contribution")
397
+ def cryptography_auth_provider_contribution() -> AuthProviderContribution:
398
+ """Return the auth capabilities contributed by spakky-cryptography."""
399
+ return AuthProviderContribution(
400
+ provider_id=CRYPTOGRAPHY_AUTH_PROVIDER_ID,
401
+ capabilities=frozenset(
402
+ {
403
+ AuthCapability.SNAPSHOT_SIGN,
404
+ AuthCapability.SNAPSHOT_VERIFY,
405
+ AuthCapability.PASSWORD_HASH,
406
+ AuthCapability.PASSWORD_VERIFY,
407
+ }
408
+ ),
409
+ )
@@ -0,0 +1 @@
1
+ """Feature contribution entry points for spakky-cryptography."""
@@ -0,0 +1,11 @@
1
+ """Auth feature contribution for the cryptography provider."""
2
+
3
+ from spakky.core.application.application import SpakkyApplication
4
+ from spakky.plugins.cryptography.auth_provider import (
5
+ cryptography_auth_provider_contribution,
6
+ )
7
+
8
+
9
+ def initialize(app: SpakkyApplication) -> None:
10
+ """Register cryptography auth capability metadata."""
11
+ app.add(cryptography_auth_provider_contribution)