auth-guide 0.5.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.
- auth_guide/__init__.py +5 -0
- auth_guide/__main__.py +6 -0
- auth_guide/accounts/__init__.py +322 -0
- auth_guide/accounts/migrations/0001_initial.py +63 -0
- auth_guide/accounts/migrations/__init__.py +1 -0
- auth_guide/accounts/models.py +206 -0
- auth_guide/accounts/passwords.py +172 -0
- auth_guide/accounts/tokens.py +41 -0
- auth_guide/bootstrap.py +76 -0
- auth_guide/certificate_operations.py +129 -0
- auth_guide/cli/__init__.py +898 -0
- auth_guide/cli/client.py +83 -0
- auth_guide/cli/config.py +157 -0
- auth_guide/cli/logging.py +365 -0
- auth_guide/cli/redaction.py +11 -0
- auth_guide/config_paths.py +28 -0
- auth_guide/management_api.py +591 -0
- auth_guide/platform_trust/__init__.py +1 -0
- auth_guide/platform_trust/common.py +85 -0
- auth_guide/platform_trust/debian.py +50 -0
- auth_guide/platform_trust/macos.py +83 -0
- auth_guide/platform_trust/redhat.py +50 -0
- auth_guide/secrets.py +286 -0
- auth_guide/server/__init__.py +5 -0
- auth_guide/server/lifecycle.py +209 -0
- auth_guide/server/runtime.py +235 -0
- auth_guide/system_trust.py +145 -0
- auth_guide/tls.py +835 -0
- auth_guide-0.5.0.dist-info/METADATA +146 -0
- auth_guide-0.5.0.dist-info/RECORD +33 -0
- auth_guide-0.5.0.dist-info/WHEEL +4 -0
- auth_guide-0.5.0.dist-info/entry_points.txt +3 -0
- auth_guide-0.5.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Account-password operations behind an owned asynchronous boundary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import queue
|
|
7
|
+
import threading
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import TypeVar, cast
|
|
11
|
+
|
|
12
|
+
from argon2 import PasswordHasher, Type, extract_parameters
|
|
13
|
+
from argon2.exceptions import InvalidHashError, VerificationError
|
|
14
|
+
|
|
15
|
+
__all__ = ["PasswordOperations", "PasswordOperationsClosedError", "PasswordOperationsError"]
|
|
16
|
+
|
|
17
|
+
ResultT = TypeVar("ResultT")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PasswordOperationsError(RuntimeError):
|
|
21
|
+
"""Base error for the password operations boundary."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PasswordOperationsClosedError(PasswordOperationsError):
|
|
25
|
+
"""Raised when password work is requested outside the worker lifecycle."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(slots=True)
|
|
29
|
+
class _Outcome:
|
|
30
|
+
value: object | None = None
|
|
31
|
+
error: BaseException | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(slots=True)
|
|
35
|
+
class _Call:
|
|
36
|
+
operation: Callable[[], object]
|
|
37
|
+
loop: asyncio.AbstractEventLoop
|
|
38
|
+
completion: asyncio.Future[_Outcome]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(slots=True)
|
|
42
|
+
class _Stop:
|
|
43
|
+
loop: asyncio.AbstractEventLoop
|
|
44
|
+
completion: asyncio.Future[None]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PasswordOperations:
|
|
48
|
+
"""Run Argon2id work through one owned serial worker."""
|
|
49
|
+
|
|
50
|
+
def __init__(self) -> None:
|
|
51
|
+
self._hasher = PasswordHasher(
|
|
52
|
+
time_cost=3,
|
|
53
|
+
memory_cost=64 * 1024,
|
|
54
|
+
parallelism=4,
|
|
55
|
+
hash_len=32,
|
|
56
|
+
salt_len=16,
|
|
57
|
+
type=Type.ID,
|
|
58
|
+
)
|
|
59
|
+
self._calls: queue.Queue[_Call | _Stop] = queue.Queue()
|
|
60
|
+
self._worker: threading.Thread | None = None
|
|
61
|
+
self._closed = False
|
|
62
|
+
self._shutdown_completion: asyncio.Future[None] | None = None
|
|
63
|
+
|
|
64
|
+
async def __aenter__(self) -> "PasswordOperations":
|
|
65
|
+
await self.start()
|
|
66
|
+
return self
|
|
67
|
+
|
|
68
|
+
async def __aexit__(self, *_: object) -> None:
|
|
69
|
+
await self.aclose()
|
|
70
|
+
|
|
71
|
+
async def start(self) -> None:
|
|
72
|
+
"""Start the password operations worker."""
|
|
73
|
+
if self._closed:
|
|
74
|
+
raise PasswordOperationsClosedError("Password operations worker is closed")
|
|
75
|
+
if self._worker is None:
|
|
76
|
+
self._worker = threading.Thread(target=self._run, name="auth-guide-passwords", daemon=True)
|
|
77
|
+
self._worker.start()
|
|
78
|
+
|
|
79
|
+
async def verify_unknown(self, password: str) -> None:
|
|
80
|
+
"""Perform an equivalent password verification for an absent account."""
|
|
81
|
+
await self.verify(
|
|
82
|
+
"$argon2id$v=19$m=65536,t=3,p=4$MDEyMzQ1Njc4OWFiY2RlZg$PjSoTrIgSEYn0GfJ1m5dwCJmQtOaMjFFTIwHP6GJwrs",
|
|
83
|
+
password,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
async def aclose(self) -> None:
|
|
87
|
+
"""Stop through a shared cancellation-safe shutdown operation."""
|
|
88
|
+
completion = self._shutdown_completion
|
|
89
|
+
if completion is None:
|
|
90
|
+
loop = asyncio.get_running_loop()
|
|
91
|
+
completion = loop.create_future()
|
|
92
|
+
self._shutdown_completion = completion
|
|
93
|
+
self._closed = True
|
|
94
|
+
worker = self._worker
|
|
95
|
+
if worker is None:
|
|
96
|
+
completion.set_result(None)
|
|
97
|
+
elif worker.is_alive():
|
|
98
|
+
self._calls.put(_Stop(loop, completion))
|
|
99
|
+
else:
|
|
100
|
+
completion.set_exception(PasswordOperationsError("Password operations worker stopped unexpectedly"))
|
|
101
|
+
await asyncio.shield(completion)
|
|
102
|
+
|
|
103
|
+
async def hash(self, password: str) -> str:
|
|
104
|
+
"""Create one current-policy Argon2id verifier."""
|
|
105
|
+
return await self._call(lambda: self._hasher.hash(password))
|
|
106
|
+
|
|
107
|
+
async def verify(self, verifier: str, password: str) -> bool:
|
|
108
|
+
"""Verify a password against its fixed-policy Argon2id verifier."""
|
|
109
|
+
|
|
110
|
+
def operation() -> bool:
|
|
111
|
+
try:
|
|
112
|
+
return self._hasher.verify(verifier, password)
|
|
113
|
+
except (InvalidHashError, VerificationError):
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
return await self._call(operation)
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def is_current_verifier(verifier: str) -> bool:
|
|
120
|
+
"""Report whether a verifier uses the provider's fixed Argon2id policy."""
|
|
121
|
+
try:
|
|
122
|
+
parameters = extract_parameters(verifier)
|
|
123
|
+
except InvalidHashError:
|
|
124
|
+
return False
|
|
125
|
+
return (
|
|
126
|
+
parameters.type is Type.ID
|
|
127
|
+
and parameters.version == 19
|
|
128
|
+
and parameters.time_cost == 3
|
|
129
|
+
and parameters.memory_cost == 64 * 1024
|
|
130
|
+
and parameters.parallelism == 4
|
|
131
|
+
and parameters.hash_len == 32
|
|
132
|
+
and parameters.salt_len == 16
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
async def _call(self, operation: Callable[[], ResultT]) -> ResultT:
|
|
136
|
+
worker = self._worker
|
|
137
|
+
if self._closed or worker is None or not worker.is_alive():
|
|
138
|
+
raise PasswordOperationsClosedError("Password operations worker is not running")
|
|
139
|
+
loop = asyncio.get_running_loop()
|
|
140
|
+
completion: asyncio.Future[_Outcome] = loop.create_future()
|
|
141
|
+
self._calls.put(_Call(operation, loop, completion))
|
|
142
|
+
outcome = await asyncio.shield(completion)
|
|
143
|
+
if outcome.error is not None:
|
|
144
|
+
raise outcome.error
|
|
145
|
+
return cast(ResultT, outcome.value)
|
|
146
|
+
|
|
147
|
+
def _run(self) -> None:
|
|
148
|
+
stop: _Stop | None = None
|
|
149
|
+
try:
|
|
150
|
+
while True:
|
|
151
|
+
item = self._calls.get()
|
|
152
|
+
if isinstance(item, _Stop):
|
|
153
|
+
stop = item
|
|
154
|
+
return
|
|
155
|
+
try:
|
|
156
|
+
outcome = _Outcome(value=item.operation())
|
|
157
|
+
except BaseException as error:
|
|
158
|
+
outcome = _Outcome(error=error)
|
|
159
|
+
item.loop.call_soon_threadsafe(_complete_call, item.completion, outcome)
|
|
160
|
+
finally:
|
|
161
|
+
if stop is not None:
|
|
162
|
+
stop.loop.call_soon_threadsafe(_complete_shutdown, stop.completion)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _complete_call(completion: asyncio.Future[_Outcome], outcome: _Outcome) -> None:
|
|
166
|
+
if not completion.done():
|
|
167
|
+
completion.set_result(outcome)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _complete_shutdown(completion: asyncio.Future[None]) -> None:
|
|
171
|
+
if not completion.done():
|
|
172
|
+
completion.set_result(None)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Opaque account access-token generation and verification."""
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from hashlib import sha256
|
|
6
|
+
|
|
7
|
+
from auth_guide.accounts.models import AccountTokenRecord
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class NewAccountToken:
|
|
12
|
+
"""A raw token available only while its issuing operation completes."""
|
|
13
|
+
|
|
14
|
+
raw_value: str
|
|
15
|
+
verifier: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def generate_account_token() -> NewAccountToken:
|
|
19
|
+
"""Generate one opaque 256-bit account token and its stored verifier."""
|
|
20
|
+
raw_value = f"agt_{secrets.token_urlsafe(32)}"
|
|
21
|
+
return NewAccountToken(raw_value=raw_value, verifier=hash_account_token(raw_value))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def verify_account_token(raw_value: str, verifier: str) -> bool:
|
|
25
|
+
"""Compare a presented raw token with its persisted verifier."""
|
|
26
|
+
return secrets.compare_digest(hash_account_token(raw_value), verifier)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def resolve_account_token(raw_value: str) -> AccountTokenRecord | None:
|
|
30
|
+
"""Resolve a presented bearer to its active account token, if any."""
|
|
31
|
+
if not raw_value.startswith("agt_"):
|
|
32
|
+
return None
|
|
33
|
+
token = await AccountTokenRecord.all().select_related("account").get_or_none(verifier=hash_account_token(raw_value))
|
|
34
|
+
if token is None or not token.account.is_active:
|
|
35
|
+
return None
|
|
36
|
+
return token
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def hash_account_token(raw_value: str) -> str:
|
|
40
|
+
"""Return the SHA-256 verifier for a raw account token."""
|
|
41
|
+
return sha256(raw_value.encode("utf-8")).hexdigest()
|
auth_guide/bootstrap.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Local first-administrator initialisation for a provider configuration root."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import secrets
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from anyio import Path as AsyncPath
|
|
10
|
+
|
|
11
|
+
from auth_guide.accounts import (
|
|
12
|
+
AccountRecord,
|
|
13
|
+
AccountStore,
|
|
14
|
+
AccountStoreError,
|
|
15
|
+
AccountTokenRecord,
|
|
16
|
+
Grant,
|
|
17
|
+
generate_account_token,
|
|
18
|
+
)
|
|
19
|
+
from auth_guide.accounts.models import normalise_account_email
|
|
20
|
+
from auth_guide.secrets import PlatformSecretProvider
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class InitialProviderCredentials:
|
|
25
|
+
"""The once-only credentials produced by local provider initialisation."""
|
|
26
|
+
|
|
27
|
+
password: str
|
|
28
|
+
token: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def initialise_provider(
|
|
32
|
+
config_dir: str | Path | AsyncPath,
|
|
33
|
+
email: str,
|
|
34
|
+
remove_orphan_key: bool = False,
|
|
35
|
+
) -> InitialProviderCredentials:
|
|
36
|
+
"""Create the encrypted store and first administrator only through local CLI setup."""
|
|
37
|
+
try:
|
|
38
|
+
email_values = await normalise_account_email(email)
|
|
39
|
+
except ValueError as error:
|
|
40
|
+
raise AccountStoreError(str(error)) from error
|
|
41
|
+
|
|
42
|
+
async with PlatformSecretProvider("auth-guide") as secrets_provider:
|
|
43
|
+
store = AccountStore(config_dir, secrets_provider)
|
|
44
|
+
initialised = False
|
|
45
|
+
try:
|
|
46
|
+
if remove_orphan_key:
|
|
47
|
+
await store.remove_orphan_database_key()
|
|
48
|
+
await store.initialise()
|
|
49
|
+
initialised = True
|
|
50
|
+
password = _generate_initial_password()
|
|
51
|
+
account = AccountRecord(
|
|
52
|
+
email=email_values["email"],
|
|
53
|
+
email_comparison=email_values["email_comparison"],
|
|
54
|
+
grants=[Grant.GUIDE_ADMIN, Grant.ACCOUNTS_READ, Grant.ACCOUNTS_MANAGE, Grant.TOKENS_MANAGE],
|
|
55
|
+
)
|
|
56
|
+
await store.create_account(account, password)
|
|
57
|
+
issued_token = await generate_account_token()
|
|
58
|
+
await AccountTokenRecord.create(
|
|
59
|
+
account=account,
|
|
60
|
+
label="admin",
|
|
61
|
+
verifier=issued_token.verifier,
|
|
62
|
+
scopes=[Grant.ACCOUNTS_READ, Grant.ACCOUNTS_MANAGE, Grant.TOKENS_MANAGE],
|
|
63
|
+
)
|
|
64
|
+
return InitialProviderCredentials(password=password, token=issued_token.raw_value)
|
|
65
|
+
except BaseException:
|
|
66
|
+
if initialised:
|
|
67
|
+
await store.rollback_initialisation()
|
|
68
|
+
raise
|
|
69
|
+
finally:
|
|
70
|
+
await store.aclose()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _generate_initial_password() -> str:
|
|
74
|
+
"""Generate the fixed 12-character printable-ASCII initial password."""
|
|
75
|
+
printable_ascii = "".join(chr(value) for value in range(33, 127))
|
|
76
|
+
return "".join(secrets.choice(printable_ascii) for _ in range(12))
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Owned asynchronous boundary for synchronous certificate operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import queue
|
|
7
|
+
import threading
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import TypeVar, cast
|
|
11
|
+
|
|
12
|
+
__all__ = ["CertificateOperations", "CertificateOperationsClosedError", "CertificateOperationsError"]
|
|
13
|
+
|
|
14
|
+
ResultT = TypeVar("ResultT")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CertificateOperationsError(RuntimeError):
|
|
18
|
+
"""Base error for the certificate operations boundary."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CertificateOperationsClosedError(CertificateOperationsError):
|
|
22
|
+
"""Raised when operations are attempted outside the worker lifecycle."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(slots=True)
|
|
26
|
+
class _Outcome:
|
|
27
|
+
value: object | None = None
|
|
28
|
+
error: BaseException | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(slots=True)
|
|
32
|
+
class _Call:
|
|
33
|
+
operation: Callable[[], object]
|
|
34
|
+
loop: asyncio.AbstractEventLoop
|
|
35
|
+
completion: asyncio.Future[_Outcome]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(slots=True)
|
|
39
|
+
class _Stop:
|
|
40
|
+
loop: asyncio.AbstractEventLoop
|
|
41
|
+
completion: asyncio.Future[None]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CertificateOperations:
|
|
45
|
+
"""Run synchronous cryptographic work through one owned serial worker."""
|
|
46
|
+
|
|
47
|
+
def __init__(self) -> None:
|
|
48
|
+
self._calls: queue.Queue[_Call | _Stop] = queue.Queue()
|
|
49
|
+
self._worker: threading.Thread | None = None
|
|
50
|
+
self._closed = False
|
|
51
|
+
self._shutdown_completion: asyncio.Future[None] | None = None
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def is_running(self) -> bool:
|
|
55
|
+
"""Whether the worker is accepting certificate operations."""
|
|
56
|
+
return self._worker is not None and self._worker.is_alive() and not self._closed
|
|
57
|
+
|
|
58
|
+
async def __aenter__(self) -> "CertificateOperations":
|
|
59
|
+
await self.start()
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
async def __aexit__(self, *_: object) -> None:
|
|
63
|
+
await self.aclose()
|
|
64
|
+
|
|
65
|
+
async def start(self) -> None:
|
|
66
|
+
"""Start the certificate operations worker."""
|
|
67
|
+
if self._closed:
|
|
68
|
+
raise CertificateOperationsClosedError("Certificate operations worker is closed")
|
|
69
|
+
if self._worker is None:
|
|
70
|
+
self._worker = threading.Thread(target=self._run, name="auth-guide-certificates", daemon=True)
|
|
71
|
+
self._worker.start()
|
|
72
|
+
|
|
73
|
+
async def aclose(self) -> None:
|
|
74
|
+
"""Stop through a shared cancellation-safe shutdown operation."""
|
|
75
|
+
completion = self._shutdown_completion
|
|
76
|
+
if completion is None:
|
|
77
|
+
loop = asyncio.get_running_loop()
|
|
78
|
+
completion = loop.create_future()
|
|
79
|
+
self._shutdown_completion = completion
|
|
80
|
+
self._closed = True
|
|
81
|
+
worker = self._worker
|
|
82
|
+
if worker is None:
|
|
83
|
+
completion.set_result(None)
|
|
84
|
+
elif worker.is_alive():
|
|
85
|
+
self._calls.put(_Stop(loop, completion))
|
|
86
|
+
else:
|
|
87
|
+
completion.set_exception(
|
|
88
|
+
CertificateOperationsError("Certificate operations worker stopped unexpectedly")
|
|
89
|
+
)
|
|
90
|
+
await asyncio.shield(completion)
|
|
91
|
+
|
|
92
|
+
async def call(self, operation: Callable[[], ResultT]) -> ResultT:
|
|
93
|
+
"""Run one synchronous certificate operation without blocking the caller."""
|
|
94
|
+
if not self.is_running:
|
|
95
|
+
raise CertificateOperationsClosedError("Certificate operations worker is not running")
|
|
96
|
+
loop = asyncio.get_running_loop()
|
|
97
|
+
completion: asyncio.Future[_Outcome] = loop.create_future()
|
|
98
|
+
self._calls.put(_Call(operation, loop, completion))
|
|
99
|
+
outcome = await asyncio.shield(completion)
|
|
100
|
+
if outcome.error is not None:
|
|
101
|
+
raise outcome.error
|
|
102
|
+
return cast(ResultT, outcome.value)
|
|
103
|
+
|
|
104
|
+
def _run(self) -> None:
|
|
105
|
+
stop: _Stop | None = None
|
|
106
|
+
try:
|
|
107
|
+
while True:
|
|
108
|
+
item = self._calls.get()
|
|
109
|
+
if isinstance(item, _Stop):
|
|
110
|
+
stop = item
|
|
111
|
+
return
|
|
112
|
+
try:
|
|
113
|
+
outcome = _Outcome(value=item.operation())
|
|
114
|
+
except BaseException as error:
|
|
115
|
+
outcome = _Outcome(error=error)
|
|
116
|
+
item.loop.call_soon_threadsafe(_complete_call, item.completion, outcome)
|
|
117
|
+
finally:
|
|
118
|
+
if stop is not None:
|
|
119
|
+
stop.loop.call_soon_threadsafe(_complete_shutdown, stop.completion)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _complete_call(completion: asyncio.Future[_Outcome], outcome: _Outcome) -> None:
|
|
123
|
+
if not completion.done():
|
|
124
|
+
completion.set_result(outcome)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _complete_shutdown(completion: asyncio.Future[None]) -> None:
|
|
128
|
+
if not completion.done():
|
|
129
|
+
completion.set_result(None)
|