generated from coulomb/repo-seed
feat(local-identity): Stage 4 — security hardening (NK-WP-0002-T04)
Permission enforcement on startup: enforce_permissions() checks store dir (700), user files (600), signing key, TLS key, audit.log, revoked.json. CLI and run_server() call it before any sensitive operation. New modules: security.py check_store(), enforce_permissions(), print_security_check() audit.py log_event() — append-only TSV audit log (mode 600) revoke.py revoke(jti), is_revoked(jti) — revocation list (mode 600) New CLI commands: security-check Print per-check pass/warn/fail report; exit 1 on failure revoke-token <jti|jwt> Add JTI to revocation list; accepts raw JTI or full JWT Serve integration: Audit log written for auth request, token issuance, and userinfo calls Revocation checked at /userinfo; revoked tokens return 401 Docs: security model section in LocalIdentity.md — threat model, assumptions, non-guarantees, SELinux/AppArmor guidance, revocation usage. 138 tests passing (34 new for Stage 4). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -347,11 +347,13 @@ def test_userinfo_tampered_token_rejected(oidc_server):
|
||||
token_resp = _do_auth_code_flow(base_url)
|
||||
good_token = token_resp["access_token"]
|
||||
|
||||
# Tamper with the signature (flip last char)
|
||||
# Tamper with the first character of the signature.
|
||||
# (The last character of a 256-byte RSA signature has 4 padding bits;
|
||||
# flipping only those bits produces identical decoded bytes, so we must
|
||||
# target a non-padding position.)
|
||||
parts = good_token.split(".")
|
||||
tampered = parts[0] + "." + parts[1] + "." + parts[2][:-1] + (
|
||||
"A" if parts[2][-1] != "A" else "B"
|
||||
)
|
||||
first = "A" if parts[2][0] != "A" else "B"
|
||||
tampered = parts[0] + "." + parts[1] + "." + first + parts[2][1:]
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}/userinfo",
|
||||
@@ -378,6 +380,64 @@ def test_bind_host_constant():
|
||||
assert _BIND_HOST == "127.0.0.1"
|
||||
|
||||
|
||||
def test_revoked_token_rejected_at_userinfo(oidc_server):
|
||||
"""A token whose JTI has been revoked must be rejected with 401."""
|
||||
from local_identity.revoke import revoke
|
||||
|
||||
base_url, private_key = oidc_server
|
||||
token_resp = _do_auth_code_flow(base_url)
|
||||
access_token = token_resp["access_token"]
|
||||
|
||||
# Verify it works before revocation
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}/userinfo",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
assert resp.status == 200
|
||||
|
||||
# Extract JTI and revoke it
|
||||
import base64 as _b64
|
||||
payload_b64 = access_token.split(".")[1]
|
||||
pad = (4 - len(payload_b64) % 4) % 4
|
||||
payload = json.loads(_b64.urlsafe_b64decode(payload_b64 + "=" * pad))
|
||||
revoke(payload["jti"])
|
||||
|
||||
# Now it must be rejected
|
||||
req2 = urllib.request.Request(
|
||||
f"{base_url}/userinfo",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
try:
|
||||
urllib.request.urlopen(req2)
|
||||
pytest.fail("Expected 401 after revocation")
|
||||
except urllib.error.HTTPError as e:
|
||||
assert e.code == 401
|
||||
body = json.loads(e.read())
|
||||
assert "revoked" in body.get("error_description", "")
|
||||
|
||||
|
||||
def test_audit_log_written_by_serve(oidc_server, tmp_store):
|
||||
"""The OIDC server writes to the audit log for auth, token, and userinfo."""
|
||||
base_url, _ = oidc_server
|
||||
token_resp = _do_auth_code_flow(base_url)
|
||||
|
||||
# Also hit /userinfo so that log entry is written
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}/userinfo",
|
||||
headers={"Authorization": f"Bearer {token_resp['access_token']}"},
|
||||
)
|
||||
with urllib.request.urlopen(req):
|
||||
pass
|
||||
|
||||
log_path = tmp_store / "audit.log"
|
||||
assert log_path.exists(), "audit.log should be created by serve"
|
||||
content = log_path.read_text()
|
||||
assert "serve/auth" in content
|
||||
assert "serve/token" in content
|
||||
assert "serve/userinfo" in content
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# JWT unit tests (independent of HTTP server) #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
Reference in New Issue
Block a user