fix(workflow): durable HMAC, dedicated mint capability, head-exact approve match (#709)

Address formal review 435 REQUEST_CHANGES on PR #710:
- F4: require durable GITEA_IRRECOVERABLE_AUTH_HMAC_KEY (fail closed; no ephemeral
  per-process secret); bind key_version into HMAC; cross-process verify works
- F5: dedicated gitea.decision_lock.irrecoverable_recovery only; reject reconciler
  equivalence; authoritative incident body + author + content_digest; reject
  self-authored incident evidence
- F3 residual: lock_targets_merged_pr_approval requires recorded-head match when
  expected_head_sha is provided (legacy no-head approve no longer primary-clears)

Co-Authored-By: Grok 4.5 (xAI) <[email protected]>
This commit is contained in:
2026-07-14 02:13:47 -04:00
co-authored by Grok 4.5
parent 9cb12ee0f4
commit 2b359e0c26
5 changed files with 753 additions and 104 deletions
+375 -68
View File
@@ -14,7 +14,6 @@ import hashlib
import hmac
import json
import os
import secrets
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any
@@ -30,14 +29,28 @@ KIND_AUTH = "irrecoverable_provenance_authorization"
KIND_RECOVERY = mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE
CONFIRMATION_PREFIX = "IRRECOVERABLE DECISION PROVENANCE PR"
# Canonical incident-comment marker (#709 review 435 F5).
INCIDENT_MARKER = "IRRECOVERABLE DECISION PROVENANCE INCIDENT"
AUTH_TTL_HOURS = 24.0
RECORD_TYPE = "irrecoverable_decision_provenance"
AUTH_TYPE = "irrecoverable_provenance_authorization"
# Internal HMAC material is process-local and never caller-supplied. Pytest
# gets a deterministic salt so hermetic tests are stable; production uses
# transport fingerprint + random secret minted at process start.
# Durable HMAC key origin (#709 review 435 F4). Never generate an ephemeral
# production key — mint/verify across reconciler/merger processes and restarts
# requires a shared secret from env (or pytest constant / explicit env override).
ENV_AUTH_HMAC_KEY = "GITEA_IRRECOVERABLE_AUTH_HMAC_KEY"
ENV_AUTH_HMAC_KEY_VERSION = "GITEA_IRRECOVERABLE_AUTH_HMAC_KEY_VERSION"
DEFAULT_KEY_VERSION = "v1"
_PYTEST_AUTH_SECRET = b"pytest-irrecoverable-auth-v1"
_PYTEST_KEY_VERSION = "pytest-v1"
_PROCESS_AUTH_SECRET: bytes | None = None
_PROCESS_AUTH_KEY_VERSION: str | None = None
_PROCESS_AUTH_SECRET_SOURCE: str | None = None
class AuthSecretError(RuntimeError):
"""Raised when the durable HMAC signing key cannot be resolved (fail closed)."""
def _now() -> datetime:
@@ -48,14 +61,91 @@ def _now_iso() -> str:
return _now().isoformat()
def _decode_key_material(raw: str) -> bytes:
"""Decode operator-supplied key material (hex, base64, or utf-8 literal)."""
text = (raw or "").strip()
if not text:
raise AuthSecretError("empty HMAC key material (fail closed, #709 F4)")
# Prefer hex (64 chars = 32 bytes).
if len(text) >= 32 and all(c in "0123456789abcdefABCDEF" for c in text):
try:
if len(text) % 2 == 0:
decoded = bytes.fromhex(text)
if len(decoded) >= 16:
return decoded
except ValueError:
pass
# base64
try:
import base64
decoded = base64.b64decode(text, validate=True)
if len(decoded) >= 16:
return decoded
except Exception:
pass
# utf-8 literal (min 16 chars after strip)
encoded = text.encode("utf-8")
if len(encoded) < 16:
raise AuthSecretError(
"HMAC key material too short (need >=16 bytes; fail closed, #709 F4)"
)
return encoded
def reset_process_auth_secret_for_tests() -> None:
"""Clear cached HMAC key (tests only)."""
global _PROCESS_AUTH_SECRET, _PROCESS_AUTH_KEY_VERSION, _PROCESS_AUTH_SECRET_SOURCE
_PROCESS_AUTH_SECRET = None
_PROCESS_AUTH_KEY_VERSION = None
_PROCESS_AUTH_SECRET_SOURCE = None
def auth_key_version() -> str:
"""Return the active signing-key version string (never secret material)."""
_process_secret() # ensure version is resolved with the secret
return _PROCESS_AUTH_KEY_VERSION or DEFAULT_KEY_VERSION
def _process_secret() -> bytes:
global _PROCESS_AUTH_SECRET
if _PROCESS_AUTH_SECRET is None:
if mcp_daemon_guard.is_pytest_runtime():
_PROCESS_AUTH_SECRET = b"pytest-irrecoverable-auth-v1"
else:
_PROCESS_AUTH_SECRET = secrets.token_bytes(32)
return _PROCESS_AUTH_SECRET
"""Resolve durable HMAC key for irrecoverable auth artifacts (#709 F4).
Production (non-pytest): **requires** ``GITEA_IRRECOVERABLE_AUTH_HMAC_KEY``.
Silently generating an ephemeral per-process key is forbidden — that made
mint+verify fail across merger/reconciler processes and restarts.
Pytest: uses a fixed constant unless the env key is set (so cross-process
regression tests can inject a shared durable key).
"""
global _PROCESS_AUTH_SECRET, _PROCESS_AUTH_KEY_VERSION, _PROCESS_AUTH_SECRET_SOURCE
if _PROCESS_AUTH_SECRET is not None:
return _PROCESS_AUTH_SECRET
env_raw = (os.environ.get(ENV_AUTH_HMAC_KEY) or "").strip()
env_ver = (os.environ.get(ENV_AUTH_HMAC_KEY_VERSION) or "").strip()
pytest = mcp_daemon_guard.is_pytest_runtime()
if env_raw:
_PROCESS_AUTH_SECRET = _decode_key_material(env_raw)
_PROCESS_AUTH_KEY_VERSION = env_ver or (
_PYTEST_KEY_VERSION if pytest else DEFAULT_KEY_VERSION
)
_PROCESS_AUTH_SECRET_SOURCE = "env"
return _PROCESS_AUTH_SECRET
if pytest:
_PROCESS_AUTH_SECRET = _PYTEST_AUTH_SECRET
_PROCESS_AUTH_KEY_VERSION = env_ver or _PYTEST_KEY_VERSION
_PROCESS_AUTH_SECRET_SOURCE = "pytest_constant"
return _PROCESS_AUTH_SECRET
# Production fail-closed: do NOT call secrets.token_bytes.
raise AuthSecretError(
f"{ENV_AUTH_HMAC_KEY} is required for production irrecoverable auth HMAC; "
"ephemeral per-process key generation is forbidden (fail closed, #709 F4 "
"review 435). Configure a durable shared secret for mint+verify across "
"processes and restarts."
)
def expected_confirmation(pr_number: int) -> str:
@@ -137,9 +227,19 @@ def _scope_payload(
}
def _sign_scope(scope: dict[str, Any], native_provenance: dict[str, Any]) -> str:
"""HMAC over canonical scope + native transport fingerprint (non-caller)."""
def _sign_scope(
scope: dict[str, Any],
native_provenance: dict[str, Any],
*,
key_version: str | None = None,
) -> str:
"""HMAC over key_version + canonical scope + native transport fingerprint.
The signing key itself is never serialized. Key *version* is bound into the
MAC so operators can rotate durable keys without ambiguous verification.
"""
material = {
"key_version": (key_version or auth_key_version()),
"scope": scope,
"native": {
"native_mcp_transport": bool(
@@ -166,15 +266,14 @@ def assess_capability_for_irrecoverable_recovery(
role_kind: str | None = None,
profile_name: str | None = None,
) -> dict[str, Any]:
"""Whether the active profile may mint/use irrecoverable recovery (#709 F1).
"""Whether the active profile may mint/use irrecoverable recovery (#709 F5).
Accepts the dedicated capability, or a reconciler-shaped profile that
already holds issue-comment mutation rights (interim equivalence until
operators grant the dedicated op). Never treats bare ``gitea.read`` as
sufficient.
**Dedicated capability only.** Reconciler role / ``gitea.issue.comment``
equivalence is intentionally rejected so a reconciler cannot self-mint
recovery authority by authoring its own incident comment (#709 review 435
F5). Bare ``gitea.read`` is never sufficient.
"""
import gitea_config
import reconciler_profile
allowed = list(allowed_operations or [])
forbidden = list(forbidden_operations or [])
@@ -191,36 +290,15 @@ def assess_capability_for_irrecoverable_recovery(
"reasons": [],
}
# Interim: reconciler profile with issue.comment (mutation, not read-only).
is_reconciler = reconciler_profile.is_reconciler_profile(allowed, forbidden)
comment_ok, _ = gitea_config.check_operation(
"gitea.issue.comment", allowed, forbidden
)
role = (role_kind or "").strip().lower()
name = (profile_name or "").strip().lower()
role_looks_reconciler = role == "reconciler" or "reconciler" in name
if is_reconciler and comment_ok:
return {
"allowed": True,
"capability": CAPABILITY_IRRECOVERABLE_RECOVERY,
"via": "reconciler_profile_equivalence",
"reasons": [],
}
if role_looks_reconciler and comment_ok and not dedicated_ok:
# Role metadata says reconciler but ops incomplete — still fail if
# is_reconciler_profile is false (missing pr.close).
reasons.append(
"reconciler role metadata without reconciler-required operations "
f"(need {CAPABILITY_IRRECOVERABLE_RECOVERY} or reconciler profile "
"with gitea.pr.close + gitea.issue.comment; gitea.read alone is "
"insufficient, #709 F1)"
)
else:
reasons.append(
f"missing dedicated capability {CAPABILITY_IRRECOVERABLE_RECOVERY} "
"(gitea.read is insufficient; require reconciler-capable mutation "
"profile or explicit grant, #709 F1)"
)
role_hint = role or name or "unknown"
reasons.append(
f"missing dedicated capability {CAPABILITY_IRRECOVERABLE_RECOVERY} "
f"(profile={role_hint!r}; reconciler/issue.comment equivalence is "
"not accepted — dedicated grant required, #709 F5 review 435; "
"gitea.read alone is insufficient)"
)
return {
"allowed": False,
"capability": CAPABILITY_IRRECOVERABLE_RECOVERY,
@@ -250,6 +328,108 @@ def assess_transport_for_auth_mint() -> dict[str, Any]:
}
def _incident_author(comment_payload: dict[str, Any]) -> str | None:
user = comment_payload.get("user")
if isinstance(user, dict):
login = (user.get("login") or user.get("username") or "").strip()
return login or None
if isinstance(user, str) and user.strip():
return user.strip()
# Some payloads flatten author.
for key in ("login", "author", "username"):
val = comment_payload.get(key)
if isinstance(val, str) and val.strip():
return val.strip()
return None
def canonical_incident_fields(
*,
remote: str,
org: str,
repo: str,
pr_number: int,
expected_head_sha: str,
incident_issue: int,
) -> dict[str, str]:
"""Ordered fields that form the authoritative incident content digest."""
head = normalize_head_sha(expected_head_sha) or ""
return {
"marker": INCIDENT_MARKER,
"remote": str(remote or "").strip(),
"org": str(org or "").strip(),
"repo": str(repo or "").strip(),
"pr_number": str(int(pr_number)),
"expected_head_sha": head,
"incident_issue": str(int(incident_issue)),
}
def incident_content_digest(fields: dict[str, str]) -> str:
"""SHA-256 hex digest over canonical field lines (stable, sort-free order)."""
# Fixed key order — do not sort; operator body must match this order.
order = (
"marker",
"remote",
"org",
"repo",
"pr_number",
"expected_head_sha",
"incident_issue",
)
lines = [f"{k}={fields[k]}" for k in order]
blob = "\n".join(lines).encode("utf-8")
return hashlib.sha256(blob).hexdigest()
def build_canonical_incident_body(
*,
remote: str,
org: str,
repo: str,
pr_number: int,
expected_head_sha: str,
incident_issue: int,
narrative: str | None = None,
) -> str:
"""Build an operator-postable canonical incident comment body (#709 F5)."""
fields = canonical_incident_fields(
remote=remote,
org=org,
repo=repo,
pr_number=pr_number,
expected_head_sha=expected_head_sha,
incident_issue=incident_issue,
)
digest = incident_content_digest(fields)
lines = [
fields["marker"],
f"remote: {fields['remote']}",
f"org: {fields['org']}",
f"repo: {fields['repo']}",
f"pr_number: {fields['pr_number']}",
f"expected_head_sha: {fields['expected_head_sha']}",
f"incident_issue: {fields['incident_issue']}",
f"content_digest: {digest}",
]
if narrative and narrative.strip():
lines.extend(["", narrative.strip()])
return "\n".join(lines)
def _parse_incident_field(body: str, name: str) -> str | None:
"""Extract ``name: value`` or ``name=value`` from incident body lines."""
for line in (body or "").splitlines():
text = line.strip()
if not text or text.startswith("#"):
continue
for sep in (":", "="):
prefix = f"{name}{sep}"
if text.lower().startswith(prefix.lower()):
return text[len(prefix) :].strip()
return None
def assess_incident_evidence(
*,
incident_issue: int | None,
@@ -259,8 +439,19 @@ def assess_incident_evidence(
expected_remote: str | None = None,
expected_org: str | None = None,
expected_repo: str | None = None,
expected_pr_number: int | None = None,
expected_head_sha: str | None = None,
mint_actor_username: str | None = None,
reject_self_authored: bool = True,
) -> dict[str, Any]:
"""Validate canonical incident evidence is present and live-fetched."""
"""Validate authoritative incident evidence (live-fetched, #709 F5).
Requires:
- live comment id match
- non-empty author identity
- canonical marker + scope fields + content_digest integrity
- optional mint-actor independence (mint actor ≠ comment author)
"""
reasons: list[str] = []
if incident_issue is None or int(incident_issue) <= 0:
reasons.append("incident_issue is required and must be a positive integer")
@@ -293,34 +484,139 @@ def assess_incident_evidence(
if not body:
reasons.append("incident comment body is empty (fail closed)")
# Soft scope hints when URLs are present (never hard-code issue numbers).
author = _incident_author(comment_payload)
if not author:
reasons.append(
"incident comment missing author identity (fail closed, #709 F5)"
)
# Self-mint prevention: minting actor must not be the sole incident author.
if (
reject_self_authored
and author
and mint_actor_username
and author.strip().lower() == str(mint_actor_username).strip().lower()
):
reasons.append(
"incident comment author matches mint actor; self-authored incident "
"evidence is not accepted (fail closed, #709 F5 review 435)"
)
# Canonical content + digest (not any non-empty body).
if body and INCIDENT_MARKER not in body:
reasons.append(
f"incident comment missing canonical marker {INCIDENT_MARKER!r} "
"(fail closed, #709 F5)"
)
elif body:
remote_f = _parse_incident_field(body, "remote")
org_f = _parse_incident_field(body, "org")
repo_f = _parse_incident_field(body, "repo")
pr_f = _parse_incident_field(body, "pr_number")
head_f = _parse_incident_field(body, "expected_head_sha")
issue_f = _parse_incident_field(body, "incident_issue")
digest_f = _parse_incident_field(body, "content_digest")
if expected_remote and remote_f and remote_f != str(expected_remote).strip():
reasons.append(
"incident body remote does not match expected remote (fail closed)"
)
if expected_org and org_f and org_f != str(expected_org).strip():
reasons.append(
"incident body org does not match expected org (fail closed)"
)
if expected_repo and repo_f and repo_f != str(expected_repo).strip():
reasons.append(
"incident body repo does not match expected repo (fail closed)"
)
if expected_pr_number is not None:
try:
if pr_f is None or int(pr_f) != int(expected_pr_number):
reasons.append(
"incident body pr_number does not match mint PR "
"(fail closed, #709 F5)"
)
except (TypeError, ValueError):
reasons.append(
"incident body pr_number missing or invalid (fail closed)"
)
if expected_head_sha:
if not head_f or not heads_equal(head_f, expected_head_sha):
reasons.append(
"incident body expected_head_sha does not match live mint "
"head (fail closed, #709 F5)"
)
try:
if issue_f is None or int(issue_f) != int(incident_issue): # type: ignore[arg-type]
reasons.append(
"incident body incident_issue does not match provided "
"incident_issue (fail closed, #709 F5)"
)
except (TypeError, ValueError):
reasons.append(
"incident body incident_issue missing or invalid (fail closed)"
)
# Content digest integrity when we have enough scope to recompute.
if (
remote_f
and org_f
and repo_f
and pr_f
and head_f
and issue_f
and digest_f
):
try:
fields = canonical_incident_fields(
remote=remote_f,
org=org_f,
repo=repo_f,
pr_number=int(pr_f),
expected_head_sha=head_f,
incident_issue=int(issue_f),
)
expected_digest = incident_content_digest(fields)
if not hmac.compare_digest(
expected_digest.lower(), str(digest_f).strip().lower()
):
reasons.append(
"incident content_digest mismatch (forged or incomplete "
"canonical body; fail closed, #709 F5)"
)
except (TypeError, ValueError) as exc:
reasons.append(
f"incident content_digest recompute failed: {exc} (fail closed)"
)
else:
reasons.append(
"incident body missing required canonical fields "
"(remote/org/repo/pr_number/expected_head_sha/incident_issue/"
"content_digest; fail closed, #709 F5)"
)
# Hard scope check when URLs are present.
issue_url = str(
comment_payload.get("issue_url")
or comment_payload.get("html_url")
or ""
)
if expected_org and expected_org not in issue_url and issue_url:
# Only fail when URL is present and clearly wrong-org; missing URL ok.
if f"/{expected_org}/" not in issue_url:
# html_url may be /user/repo/issues/n — check repo if provided
if expected_repo and f"/{expected_repo}/" not in issue_url:
reasons.append(
"incident evidence URL does not match expected repository "
"(fail closed)"
)
if expected_org and issue_url and f"/{expected_org}/" not in issue_url:
if expected_repo and f"/{expected_repo}/" not in issue_url:
reasons.append(
"incident evidence URL does not match expected repository "
"(fail closed)"
)
return {
"valid": not reasons,
"reasons": reasons,
"comment": {
"id": comment_payload.get("id"),
"author": (
(comment_payload.get("user") or {}).get("login")
if isinstance(comment_payload.get("user"), dict)
else comment_payload.get("user")
),
"author": author,
"created_at": comment_payload.get("created_at"),
"body_len": len(body),
"has_canonical_marker": INCIDENT_MARKER in body if body else False,
},
}
@@ -396,7 +692,12 @@ def build_authorization_artifact(
expires_at=expires.isoformat(),
authorization_id=authorization_id,
)
signature = _sign_scope(scope, provenance)
try:
kv = auth_key_version()
signature = _sign_scope(scope, provenance, key_version=kv)
except AuthSecretError as exc:
# Surface fail-closed mint: caller must not get a forgeable blank sig.
raise AuthSecretError(str(exc)) from exc
return {
"kind": KIND_AUTH,
"auth_type": AUTH_TYPE,
@@ -407,6 +708,8 @@ def build_authorization_artifact(
"recovery_critical": True,
"issue_ref": "#709",
"server_signature": signature,
"key_version": kv,
# Never serialize the secret; only the version id.
"native_provenance": provenance,
**scope,
"timestamp": created.isoformat(),
@@ -487,7 +790,7 @@ def verify_authorization_artifact(
if not (auth.get("server_signature") or "").strip():
reasons.append("authorization missing server_signature (fail closed)")
# Recompute signature over stored scope fields.
# Recompute signature over stored scope fields + key_version.
try:
scope = _scope_payload(
remote=str(auth.get("remote") or ""),
@@ -507,14 +810,18 @@ def verify_authorization_artifact(
native = auth.get("native_provenance") or {}
if not isinstance(native, dict):
native = {}
expected_sig = _sign_scope(scope, native)
stored_kv = str(auth.get("key_version") or auth_key_version())
expected_sig = _sign_scope(scope, native, key_version=stored_kv)
if not hmac.compare_digest(
expected_sig, str(auth.get("server_signature") or "")
):
reasons.append(
"authorization server_signature invalid (forged or corrupt; "
"fail closed, #709 F1)"
"authorization server_signature invalid (forged, corrupt, or "
"HMAC key mismatch across process/restart; fail closed, "
"#709 F4/F1)"
)
except AuthSecretError as exc:
reasons.append(f"authorization HMAC key unavailable: {exc} (fail closed)")
except (TypeError, ValueError) as exc:
reasons.append(f"authorization scope incomplete: {exc} (fail closed)")