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:
2026-03-02 08:06:56 +01:00
parent ae348d0e54
commit e7bafd69fc
9 changed files with 795 additions and 16 deletions

View File

@@ -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) #
# ------------------------------------------------------------------ #