Harden Sigil: sigil/identity.py

This commit is contained in:
bigpickle 2026-10-05 21:24:38 +08:00
parent f720814b56
commit 995920321a

View File

@ -14,6 +14,7 @@ After step 2 the secret is gone. It is a bootstrap, not a credential.
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import re
import secrets import secrets
import time import time
from typing import Optional from typing import Optional
@ -24,6 +25,22 @@ from .store import Store
DEFAULT_NONCE_TTL = 120 # 2 minutes. Short: a nonce is a one-shot question. 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: def _hash_secret(secret: str) -> str:
return hashlib.sha256(secret.encode("utf-8")).hexdigest() return hashlib.sha256(secret.encode("utf-8")).hexdigest()
@ -34,6 +51,7 @@ def seed_bootstrap(store: Store, agent: str, secret: Optional[str] = None) -> st
This is the only moment a shared secret exists. In production this replaces This is the only moment a shared secret exists. In production this replaces
the agent's static passcode, issued once and never transmitted again. the agent's static passcode, issued once and never transmitted again.
""" """
_validate_agent_id(agent)
secret = secret or secrets.token_urlsafe(24) secret = secret or secrets.token_urlsafe(24)
store.put_bootstrap(_hash_secret(secret), agent) store.put_bootstrap(_hash_secret(secret), agent)
store.record( store.record(
@ -49,6 +67,9 @@ def enrol(
Returns the key fingerprint, which is an identifier and safe to log. 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}")
if store.get_agent(agent) is not None: if store.get_agent(agent) is not None:
raise errors.EnrolmentFailed(f"{agent} is already enrolled") raise errors.EnrolmentFailed(f"{agent} is already enrolled")
owner = store.spend_bootstrap(_hash_secret(secret)) owner = store.spend_bootstrap(_hash_secret(secret))
@ -68,8 +89,12 @@ def enrol(
return fingerprint return fingerprint
def challenge(store: Store, agent: str, ttl: int = DEFAULT_NONCE_TTL) -> dict: 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.""" """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) row = store.get_agent(agent)
if row is None: if row is None:
raise errors.UnknownAgent(f"{agent} is not enrolled") raise errors.UnknownAgent(f"{agent} is not enrolled")
@ -96,6 +121,8 @@ def verify(
nonce: str, nonce: str,
signature_b64: str, signature_b64: str,
ttl: int = sessions.DEFAULT_AGENT_TTL, ttl: int = sessions.DEFAULT_AGENT_TTL,
limit: Optional[int] = None,
window: Optional[int] = None,
) -> dict: ) -> dict:
"""Check a signed nonce and, if it holds, mint an agent session. """Check a signed nonce and, if it holds, mint an agent session.
@ -108,12 +135,18 @@ def verify(
if row is None: if row is None:
raise errors.NonceUnknown("no such challenge") 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"] agent = row["agent"]
if row["used_at"] is not None: # 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( store.record(
"auth.replay_refused", actor=agent, subject=agent, "auth.replay_refused", actor=agent, subject=agent,
detail={"reason": "nonce already used"}, detail={"reason": "nonce already used or claimed"},
) )
raise errors.NonceUsed("challenge already answered") raise errors.NonceUsed("challenge already answered")
@ -126,10 +159,6 @@ def verify(
if agent_row["revoked_at"] is not None: if agent_row["revoked_at"] is not None:
raise errors.UnknownAgent(f"{agent} is revoked") raise errors.UnknownAgent(f"{agent} is revoked")
# Consume before judging the signature. A wrong signature must not leave
# the nonce live for another attempt.
store.mark_nonce_used(nonce)
public = keys.from_public_bytes(agent_row["pubkey"]) public = keys.from_public_bytes(agent_row["pubkey"])
try: try:
signature = base64.b64decode(signature_b64.encode("ascii")) signature = base64.b64decode(signature_b64.encode("ascii"))
@ -155,20 +184,6 @@ def verify(
) )
def rotate(store: Store, agent: str) -> str:
"""Bind a new public key, keeping the same agent identity.
Used for key compromise or routine rotation. The old key stops working the
moment the row is replaced.
"""
row = store.get_agent(agent)
if row is None:
raise errors.UnknownAgent(f"{agent} is not enrolled")
raise errors.EnrolmentFailed(
"rotate() requires a new public key: call enrol_rekey(agent, public_b64)"
)
def enrol_rekey(store: Store, agent: str, public_b64: str, secret: str) -> str: def enrol_rekey(store: Store, agent: str, public_b64: str, secret: str) -> str:
"""Re-bind a public key using a fresh one-time secret. """Re-bind a public key using a fresh one-time secret.
@ -185,11 +200,14 @@ def enrol_rekey(store: Store, agent: str, public_b64: str, secret: str) -> str:
fingerprint = keys.fingerprint(public) fingerprint = keys.fingerprint(public)
previous = row["fingerprint"] previous = row["fingerprint"]
store.put_agent(agent, keys.public_bytes(public), fingerprint) store.put_agent(agent, keys.public_bytes(public), fingerprint)
# 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")
store.record( store.record(
"identity.rekeyed", "identity.rekeyed",
actor=agent, actor=agent,
subject=agent, subject=agent,
fingerprint=fingerprint, fingerprint=fingerprint,
detail={"previous_fingerprint": previous}, detail={"previous_fingerprint": previous, "sessions_revoked": killed},
) )
return fingerprint return fingerprint