enrol_rekey shared the enrol: bucket and metered before auth, so junk calls could lock out incident-response rekey. Use a separate rekey: bucket and charge it only after a bad bootstrap secret, so a valid seed always proceeds.
320 lines
12 KiB
Python
320 lines
12 KiB
Python
"""Enrolment and challenge-response authentication.
|
|
|
|
The flow replaces a static passcode with a signature:
|
|
|
|
1. seed_bootstrap platform stores a one-time secret for an agent
|
|
2. enrol agent presents that secret once, plus its public key
|
|
3. challenge platform returns a random nonce
|
|
4. verify agent signs the nonce; platform checks against the
|
|
enrolled public key and issues a short session
|
|
|
|
After step 2 the secret is gone. It is a bootstrap, not a credential.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import re
|
|
import secrets
|
|
import time
|
|
from typing import Optional
|
|
|
|
from . import errors, keys, sessions
|
|
from .store import Store
|
|
|
|
DEFAULT_NONCE_TTL = 120 # 2 minutes. Short: a nonce is a one-shot question.
|
|
|
|
|
|
_AGENT_ID = re.compile(r"^[a-z0-9][a-z0-9_.-]{0,63}$")
|
|
|
|
|
|
def _validate_agent_id(agent: str) -> None:
|
|
"""Agent ids appear in log lines, URLs and filenames.
|
|
|
|
Constrain them to a safe alphabet and length rather than trusting callers,
|
|
so an id can never carry a newline into the audit trail or a path segment
|
|
into a file.
|
|
"""
|
|
if not _AGENT_ID.match(agent or ""):
|
|
raise errors.SigilError(
|
|
f"invalid agent id {agent!r}: expected lowercase [a-z0-9._-], max 64 chars"
|
|
)
|
|
|
|
|
|
def _hash_secret(secret: str) -> str:
|
|
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def seed_bootstrap(store: Store, agent: str, secret: Optional[str] = None) -> str:
|
|
"""Register a one-time enrolment secret for an agent.
|
|
|
|
This is the only moment a shared secret exists. In production this replaces
|
|
the agent's static passcode, issued once and never transmitted again.
|
|
|
|
Refuses when the agent is currently revoked: a fresh bootstrap must not be
|
|
a path back in without an explicit reinstate.
|
|
"""
|
|
_validate_agent_id(agent)
|
|
row = store.get_agent(agent)
|
|
# Timed suspension: refuse. Permanent revoke (no live key): allow so ops
|
|
# can stage a rekey before or after reinstate.
|
|
if row is not None and is_revoked(row) and has_live_key(row):
|
|
raise errors.UnknownAgent(f"{agent} is revoked")
|
|
secret = secret or secrets.token_urlsafe(24)
|
|
store.put_bootstrap(_hash_secret(secret), agent)
|
|
store.record(
|
|
"identity.bootstrap_seeded", actor="system", subject=agent, detail={}
|
|
)
|
|
return secret
|
|
|
|
|
|
def enrol(
|
|
store: Store, agent: str, public_b64: str, secret: str
|
|
) -> str:
|
|
"""Bind a public key to an agent, once, consuming the bootstrap secret.
|
|
|
|
Returns the key fingerprint, which is an identifier and safe to log.
|
|
"""
|
|
_validate_agent_id(agent)
|
|
if not store.allow(f"enrol:{agent}"):
|
|
raise errors.RateLimited(f"too many enrolment attempts for {agent}")
|
|
# Spend the secret first so a burned secret cannot be retried. The agent
|
|
# row insert below is INSERT OR IGNORE: if two secrets race for the same
|
|
# unenrolled agent, exactly one put_agent wins and the loser fails closed
|
|
# instead of silently replacing the winner's key.
|
|
owner = store.spend_bootstrap(_hash_secret(secret))
|
|
if owner != agent:
|
|
raise errors.EnrolmentFailed("bootstrap secret invalid or already spent")
|
|
|
|
public = keys.import_public_b64(public_b64)
|
|
fingerprint = keys.fingerprint(public)
|
|
if not store.put_agent(agent, keys.public_bytes(public), fingerprint):
|
|
raise errors.EnrolmentFailed(f"{agent} is already enrolled")
|
|
# Spend any sibling seeds left over from double-seed / unused chat leaks.
|
|
store.clear_bootstrap_for_agent(agent)
|
|
store.record(
|
|
"identity.enrolled",
|
|
actor=agent,
|
|
subject=agent,
|
|
fingerprint=fingerprint,
|
|
detail={"algorithm": "ed25519"},
|
|
)
|
|
return fingerprint
|
|
|
|
|
|
def is_revoked(row, now: Optional[float] = None) -> bool:
|
|
"""Is this agent row currently revoked?
|
|
|
|
A revocation with an end time has lapsed and the agent is back in force.
|
|
Anything still valid reads as revoked, so a malformed row fails closed.
|
|
"""
|
|
if row["revoked_at"] is None:
|
|
return False
|
|
until = row["revoked_until"] if "revoked_until" in row.keys() else None
|
|
if until is None:
|
|
return True
|
|
return (now if now is not None else time.time()) < until
|
|
|
|
|
|
def has_live_key(row) -> bool:
|
|
"""Does this agent row still hold an enrolled public key?
|
|
|
|
Permanent revoke clears pubkey/fingerprint so reinstate alone cannot bring
|
|
a compromised key back. Callers that mint challenges or sessions must
|
|
refuse until bootstrap + enrol_rekey restores a real key.
|
|
"""
|
|
fp = row["fingerprint"] if "fingerprint" in row.keys() else None
|
|
pubkey = row["pubkey"] if "pubkey" in row.keys() else None
|
|
return bool(fp) and isinstance(pubkey, (bytes, bytearray)) and len(pubkey) == 32
|
|
|
|
|
|
def revoke(store: Store, agent: str, until: Optional[int] = None,
|
|
reason: Optional[str] = None, actor: str = "system") -> int:
|
|
"""Revoke an agent, optionally until a time.
|
|
|
|
`until=None` is permanent. A timestamp suspends the identity until then,
|
|
which is a real distinction: an incident response that ends should not need
|
|
a re-enrolment, and a permanent death should not be undone by a clock.
|
|
|
|
Revocation is an administrative act, so it is recorded with `actor` rather
|
|
than pretending an agent revoked itself.
|
|
"""
|
|
_validate_agent_id(agent)
|
|
if store.get_agent(agent) is None:
|
|
raise errors.UnknownAgent(f"{agent} is not enrolled")
|
|
killed = store.revoke_agent(agent, until, reason)
|
|
store.record(
|
|
"identity.revoked",
|
|
actor=actor,
|
|
subject=agent,
|
|
detail={
|
|
"until": until,
|
|
"permanent": until is None,
|
|
"reason": reason,
|
|
"sessions_revoked": killed,
|
|
},
|
|
)
|
|
return killed
|
|
|
|
|
|
def reinstate(store: Store, agent: str, actor: str = "system") -> None:
|
|
"""Lift a revocation. Audited, because it undoes a security decision.
|
|
|
|
After a *permanent* revoke the enrolled key was cleared. Reinstate lifts
|
|
the ban flags only — the agent still cannot authenticate until ops seed a
|
|
bootstrap secret and call enrol_rekey with a fresh key. Timed suspensions
|
|
keep the key, so reinstate (or waiting out the window) restores the same
|
|
identity without rotation.
|
|
"""
|
|
_validate_agent_id(agent)
|
|
if store.get_agent(agent) is None:
|
|
raise errors.UnknownAgent(f"{agent} is not enrolled")
|
|
store.reinstate(agent)
|
|
store.record("identity.reinstated", actor=actor, subject=agent)
|
|
|
|
|
|
def challenge(store: Store, agent: str, ttl: int = DEFAULT_NONCE_TTL,
|
|
limit: Optional[int] = None, window: Optional[int] = None) -> dict:
|
|
"""Issue a single-use nonce for an agent to sign."""
|
|
if not store.allow(f"challenge:{agent}", limit=limit, window=window):
|
|
raise errors.RateLimited(f"too many challenges for {agent}")
|
|
|
|
row = store.get_agent(agent)
|
|
if row is None:
|
|
raise errors.UnknownAgent(f"{agent} is not enrolled")
|
|
if is_revoked(row):
|
|
raise errors.UnknownAgent(f"{agent} is revoked")
|
|
if not has_live_key(row):
|
|
raise errors.UnknownAgent(
|
|
f"{agent} has no live key; bootstrap and enrol_rekey required"
|
|
)
|
|
nonce = secrets.token_urlsafe(24)
|
|
expires = int(time.time()) + ttl
|
|
store.put_nonce(nonce, agent, expires)
|
|
store.record(
|
|
"identity.challenge_issued", actor=agent, subject=agent,
|
|
fingerprint=row["fingerprint"], detail={"expires_at": expires},
|
|
)
|
|
return {
|
|
"nonce": nonce,
|
|
"agent": agent,
|
|
"expires_at": expires,
|
|
"ttl": ttl,
|
|
"fingerprint": row["fingerprint"],
|
|
}
|
|
|
|
|
|
def verify(
|
|
store: Store,
|
|
nonce: str,
|
|
signature_b64: str,
|
|
ttl: int = sessions.DEFAULT_AGENT_TTL,
|
|
limit: Optional[int] = None,
|
|
window: Optional[int] = None,
|
|
) -> dict:
|
|
"""Check a signed nonce and, if it holds, mint an agent session.
|
|
|
|
Replay is refused: a nonce is consumed on first use whether or not the
|
|
signature was any good, so a captured signature cannot be retried.
|
|
"""
|
|
import base64
|
|
|
|
row = store.get_nonce(nonce)
|
|
if row is None:
|
|
raise errors.NonceUnknown("no such challenge")
|
|
|
|
if not store.allow(f"verify:{row['agent']}", limit=limit, window=window):
|
|
raise errors.RateLimited("too many verification attempts")
|
|
|
|
agent = row["agent"]
|
|
|
|
# Claim the nonce before judging anything. Compare-and-set, so two
|
|
# concurrent verifies cannot both proceed, and a wrong signature still
|
|
# burns the nonce rather than leaving it open for another attempt.
|
|
if not store.claim_nonce(nonce):
|
|
store.record(
|
|
"auth.replay_refused", actor=agent, subject=agent,
|
|
detail={"reason": "nonce already used or claimed"},
|
|
)
|
|
raise errors.NonceUsed("challenge already answered")
|
|
|
|
if time.time() >= row["expires_at"]:
|
|
raise errors.NonceExpired("challenge expired")
|
|
|
|
agent_row = store.get_agent(agent)
|
|
if agent_row is None:
|
|
raise errors.UnknownAgent(f"{agent} is not enrolled")
|
|
if is_revoked(agent_row):
|
|
raise errors.UnknownAgent(f"{agent} is revoked")
|
|
if not has_live_key(agent_row):
|
|
raise errors.UnknownAgent(
|
|
f"{agent} has no live key; bootstrap and enrol_rekey required"
|
|
)
|
|
|
|
public = keys.from_public_bytes(agent_row["pubkey"])
|
|
try:
|
|
signature = base64.b64decode(signature_b64.encode("ascii"))
|
|
except Exception:
|
|
raise errors.BadSignature("signature is not valid base64")
|
|
|
|
if not keys.verify(public, signature, nonce.encode("utf-8")):
|
|
store.record(
|
|
"auth.signature_refused", actor=agent, subject=agent,
|
|
fingerprint=agent_row["fingerprint"], detail={},
|
|
)
|
|
raise errors.BadSignature("signature does not verify")
|
|
|
|
store.record(
|
|
"auth.verified",
|
|
actor=agent,
|
|
subject=agent,
|
|
fingerprint=agent_row["fingerprint"],
|
|
detail={"method": "ed25519-challenge-response"},
|
|
)
|
|
return sessions.issue_agent_session(
|
|
store, agent, ttl=ttl, fingerprint=agent_row["fingerprint"]
|
|
)
|
|
|
|
|
|
def enrol_rekey(store: Store, agent: str, public_b64: str, secret: str) -> str:
|
|
"""Re-bind a public key using a fresh one-time secret.
|
|
|
|
Rotation cannot use the agent's existing key to authorise itself: if that
|
|
key leaked, it would authorise its own replacement.
|
|
|
|
Rekey does not clear revocation. A revoked agent stays revoked until an
|
|
explicit reinstate; rotating the key must not be a back door back in.
|
|
"""
|
|
row = store.get_agent(agent)
|
|
if row is None:
|
|
raise errors.UnknownAgent(f"{agent} is not enrolled")
|
|
# Permanent revoke clears the key. Allow rekey while still revoked so ops
|
|
# can rotate before reinstate; a revoked agent that still has a live key
|
|
# (timed suspend) must not rekey as a back door.
|
|
if is_revoked(row) and has_live_key(row):
|
|
raise errors.UnknownAgent(f"{agent} is revoked")
|
|
owner = store.spend_bootstrap(_hash_secret(secret))
|
|
if owner != agent:
|
|
# Rate-limit only failed attempts so junk cannot burn the ops path, and
|
|
# use a dedicated rekey: bucket so exhausting enrol: cannot lock rekey.
|
|
if not store.allow(f"rekey:{agent}"):
|
|
raise errors.RateLimited(f"too many rekey attempts for {agent}")
|
|
raise errors.EnrolmentFailed("bootstrap secret invalid or already spent")
|
|
public = keys.import_public_b64(public_b64)
|
|
fingerprint = keys.fingerprint(public)
|
|
previous = row["fingerprint"]
|
|
if not store.update_agent_key(agent, keys.public_bytes(public), fingerprint):
|
|
raise errors.UnknownAgent(f"{agent} is not enrolled")
|
|
# A new key must not leave old sessions alive, or rotating after a
|
|
# compromise buys nothing.
|
|
killed = store.revoke_sessions_for_agent(agent, "key rotated")
|
|
# Leftover bootstrap seeds must not remain a standing rekey capability.
|
|
store.clear_bootstrap_for_agent(agent)
|
|
store.record(
|
|
"identity.rekeyed",
|
|
actor=agent,
|
|
subject=agent,
|
|
fingerprint=fingerprint,
|
|
detail={"previous_fingerprint": previous, "sessions_revoked": killed},
|
|
)
|
|
return fingerprint |