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.
- spakky_cryptography-6.5.0/PKG-INFO +41 -0
- spakky_cryptography-6.5.0/README.md +26 -0
- spakky_cryptography-6.5.0/pyproject.toml +80 -0
- spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/__init__.py +47 -0
- spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/auth_provider.py +409 -0
- spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/contributions/__init__.py +1 -0
- spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/contributions/auth.py +11 -0
- spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/cryptography/__init__.py +0 -0
- spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/cryptography/aes.py +91 -0
- spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/cryptography/gcm.py +96 -0
- spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/cryptography/interface.py +35 -0
- spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/cryptography/rsa.py +190 -0
- spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/encoding.py +81 -0
- spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/error.py +64 -0
- spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/hash.py +102 -0
- spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/hmac_signer.py +118 -0
- spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/key.py +96 -0
- spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/main.py +9 -0
- spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/password/__init__.py +0 -0
- spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/password/argon2.py +142 -0
- spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/password/bcrypt.py +103 -0
- spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/password/interface.py +20 -0
- spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/password/pbkdf2.py +130 -0
- spakky_cryptography-6.5.0/src/spakky/plugins/cryptography/password/scrypt.py +149 -0
- 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)
|
|
File without changes
|