feat: add secure agent enrollment
This commit is contained in:
1 parent
82309db79a
commit
d9f0da694b
26 files changed
+2942
-76
No files matched your search
@@ -0,0 +1 @@
|
||||
"""Per-Agent authentication support."""
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Domain errors for Agent authentication."""
|
||||
|
||||
|
||||
class AgentAuthenticationError(Exception):
|
||||
"""Base Agent authentication error."""
|
||||
|
||||
|
||||
class AgentAuthenticationUnavailable(AgentAuthenticationError):
|
||||
"""Agent authentication cannot currently be performed."""
|
||||
|
||||
|
||||
class AgentAuthenticationFailed(AgentAuthenticationError):
|
||||
"""The supplied Agent secret is invalid."""
|
||||
|
||||
|
||||
class AgentIdentityUnauthorized(AgentAuthenticationError):
|
||||
"""The supplied Agent identity is not authorized."""
|
||||
|
||||
|
||||
class AgentCredentialRevoked(AgentAuthenticationError):
|
||||
"""The Agent's permanent credential has been revoked."""
|
||||
@@ -0,0 +1,47 @@
|
||||
"""PostgreSQL persistence for Agent authentication."""
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
ConnectionFactory = Callable[[], psycopg.Connection]
|
||||
|
||||
|
||||
def default_connection_factory() -> psycopg.Connection:
|
||||
return psycopg.connect(
|
||||
host=settings.postgres_host,
|
||||
port=settings.postgres_port,
|
||||
dbname=settings.postgres_db,
|
||||
user=settings.postgres_user,
|
||||
password=settings.postgres_password,
|
||||
connect_timeout=5,
|
||||
)
|
||||
|
||||
|
||||
class AgentAuthRepository:
|
||||
def __init__(self, connection_factory: ConnectionFactory | None = None):
|
||||
self._connection_factory = connection_factory or default_connection_factory
|
||||
|
||||
def get_credential(self, agent_id: str) -> dict | None:
|
||||
connection = self._connection_factory()
|
||||
try:
|
||||
with connection.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT agent_id,
|
||||
agent_secret_hash,
|
||||
credential_created_at,
|
||||
credential_revoked_at
|
||||
FROM minerops_site_agents
|
||||
WHERE agent_id = %s
|
||||
""",
|
||||
(agent_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
finally:
|
||||
connection.close()
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Per-Agent authentication with bounded legacy compatibility."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
from app.agent_auth.exceptions import (
|
||||
AgentAuthenticationFailed,
|
||||
AgentAuthenticationUnavailable,
|
||||
AgentCredentialRevoked,
|
||||
AgentIdentityUnauthorized,
|
||||
)
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def hash_agent_secret(secret: str) -> str:
|
||||
"""Hash a high-entropy machine-generated Agent secret for persistence."""
|
||||
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class AgentAuthService:
|
||||
def __init__(self, repository):
|
||||
self.repository = repository
|
||||
|
||||
def authenticate(self, agent_id: str | None, provided_secret: str | None) -> str:
|
||||
identity = (agent_id or "").strip()
|
||||
secret = (provided_secret or "").strip()
|
||||
|
||||
if not identity:
|
||||
raise AgentAuthenticationFailed("Missing Agent identity")
|
||||
if not secret:
|
||||
raise AgentAuthenticationFailed("Invalid or missing Agent secret")
|
||||
|
||||
credential = self.repository.get_credential(identity)
|
||||
|
||||
# Any Agent with a per-Agent credential is permanently on the new
|
||||
# authentication path. The legacy shared secret must never authenticate
|
||||
# that identity, even if the Agent later becomes revoked.
|
||||
if credential and credential.get("agent_secret_hash"):
|
||||
if credential.get("credential_revoked_at") is not None:
|
||||
raise AgentCredentialRevoked("Agent credential has been revoked")
|
||||
|
||||
supplied_hash = hash_agent_secret(secret)
|
||||
if not hmac.compare_digest(
|
||||
supplied_hash,
|
||||
str(credential["agent_secret_hash"]),
|
||||
):
|
||||
raise AgentAuthenticationFailed("Invalid or missing Agent secret")
|
||||
|
||||
return identity
|
||||
|
||||
# Temporary compatibility for the single pre-enrollment Agent.
|
||||
legacy_id = settings.minerops_agent_id.strip()
|
||||
legacy_secret = settings.minerops_agent_shared_secret.strip()
|
||||
|
||||
if not legacy_id or not legacy_secret:
|
||||
raise AgentAuthenticationUnavailable(
|
||||
"Legacy Agent authentication is not configured"
|
||||
)
|
||||
|
||||
if not hmac.compare_digest(identity, legacy_id):
|
||||
raise AgentIdentityUnauthorized("Agent identity is not authorized")
|
||||
|
||||
if not hmac.compare_digest(secret, legacy_secret):
|
||||
raise AgentAuthenticationFailed("Invalid or missing Agent secret")
|
||||
|
||||
return identity
|
||||
Reference in new issue
Block a user