sigil/sigil/identity.py
leora 1530bfd010 Fix R6/R7/R8: claim_code expiry CAS, rate-limit rekey, delete spent bootstrap
claim_code now requires redeem_expires_at > now in the CAS UPDATE.
enrol_rekey shares the enrol: rate-limit bucket. spend_bootstrap and
sibling clear delete rows instead of retaining spent hashes, matching
the schema comment.
2026-10-06 06:15:15 +08:00

318 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.
"""
if not store.allow(f"enrol:{agent}"):
raise errors.RateLimited(f"too many enrolment attempts for {agent}")
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:
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