G3.5: Eternitas revocation receiver — fail-closed, both signature traps avoided
All checks were successful
check / gate (push) Successful in 18s
All checks were successful
check / gate (push) Successful in 18s
When a passport is revoked, every credential it holds here dies in one
transaction: tokens revoked, grants revoked. A revocation that takes effect
'eventually' is not a revocation.
Avoids two traps that each cost a sibling service a subscription that looked
wired and never once delivered:
1. strip the 'sha256=' prefix before comparing — comparing the decorated
header against a bare digest returns 401 forever
2. HMAC the RAW REQUEST BYTES, never a re-serialised body — JSON.stringify of
a parsed body reorders keys and changes whitespace, so the digest never
matches what the sender signed
Both fail silently from the sender's side: Eternitas records a delivery, the
receiver records a rejection, nobody notices for weeks.
Unset secret REFUSES rather than accepts — accepting unverified instructions
about identity is worse than missing them. And it never acknowledges a
revocation it could not apply; a 200 there is a security hole reporting success.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -547,3 +547,39 @@ def test_g76_alert_path_sets_a_user_agent():
|
||||
src = (ROOT / "scripts" / "canary.py").read_text()
|
||||
send = src[src.index("def send_alert") : src.index("def main(")]
|
||||
assert "User-Agent" in send
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# G3.5 — revocation is fail-closed, and the two signature traps
|
||||
# --------------------------------------------------------------------------
|
||||
def test_g35_webhook_strips_the_sha256_prefix():
|
||||
"""A sibling receiver compared the whole 'sha256=<hex>' header against a
|
||||
bare digest and returned 401 forever — wired, never once delivered."""
|
||||
src = (ROOT / "api" / "app" / "routes" / "webhooks.py").read_text()
|
||||
assert 'startswith("sha256=")' in src
|
||||
|
||||
|
||||
def test_g35_webhook_hmacs_raw_bytes_not_reserialised_json():
|
||||
"""JSON.stringify of a parsed body reorders keys and changes whitespace, so
|
||||
the digest never matches what the sender signed. Same silent 401."""
|
||||
src = (ROOT / "api" / "app" / "routes" / "webhooks.py").read_text()
|
||||
verify = src[src.index("def _verify") : src.index("@router.post")]
|
||||
assert "raw" in verify and "json.dumps" not in verify
|
||||
|
||||
|
||||
def test_g35_unset_secret_refuses_rather_than_accepts():
|
||||
src = (ROOT / "api" / "app" / "routes" / "webhooks.py").read_text()
|
||||
assert "webhook_secret_unset" in src
|
||||
assert "refusing unverified webhooks" in src
|
||||
|
||||
|
||||
def test_g35_signature_compare_is_constant_time():
|
||||
src = (ROOT / "api" / "app" / "routes" / "webhooks.py").read_text()
|
||||
assert "hmac.compare_digest" in src
|
||||
|
||||
|
||||
def test_g35_revocation_never_acknowledges_what_it_did_not_apply():
|
||||
"""A 200 on a revocation the receiver could not apply is a security hole
|
||||
that reports success."""
|
||||
src = (ROOT / "api" / "app" / "routes" / "webhooks.py").read_text()
|
||||
assert "refusing to acknowledge a revocation we did not apply" in src
|
||||
|
||||
Reference in New Issue
Block a user