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

@@ -34,9 +34,12 @@ import urllib.parse
from http import HTTPStatus
from pathlib import Path
from . import audit
from . import revoke as revoke_mod
from . import store
from .jwt_utils import JWTError, create_token, verify_token
from .keys import ensure_signing_key, jwk_public, key_id
from .security import enforce_permissions
from .tls import ensure_tls_cert
_BIND_HOST = "127.0.0.1"
@@ -270,6 +273,7 @@ class OIDCHandler(http.server.BaseHTTPRequestHandler):
"nonce": nonce or None,
"expires_at": now + _CODE_TTL,
}
audit.log_event("serve/auth", username, "auth_request")
sep = "&" if "?" in redirect_uri else "?"
location = (
@@ -335,6 +339,7 @@ class OIDCHandler(http.server.BaseHTTPRequestHandler):
nonce=code_data.get("nonce"),
)
audit.log_event("serve/token", user.username, "token_issued")
self._send_json({
"access_token": token,
"id_token": token,
@@ -358,14 +363,26 @@ class OIDCHandler(http.server.BaseHTTPRequestHandler):
try:
payload = verify_token(token, self._private_key.public_key())
except JWTError as exc:
audit.log_event("serve/userinfo", None, f"invalid_token: {exc}")
self._send_json(
{"error": "invalid_token", "error_description": str(exc)},
HTTPStatus.UNAUTHORIZED,
)
return
jti = payload.get("jti", "")
sub = payload.get("sub")
if jti and revoke_mod.is_revoked(jti):
audit.log_event("serve/userinfo", sub, "revoked_token")
self._send_json(
{"error": "invalid_token", "error_description": "token has been revoked"},
HTTPStatus.UNAUTHORIZED,
)
return
audit.log_event("serve/userinfo", sub, "ok")
self._send_json({
"sub": payload["sub"],
"sub": sub,
"email": payload.get("email"),
"name": payload.get("name"),
"preferred_username": payload.get("preferred_username"),
@@ -435,6 +452,7 @@ def run_server(port: int = 8443, token_ttl: int = 3600) -> None:
)
sys.exit(1)
enforce_permissions()
private_key = ensure_signing_key()
cert_path, key_path = ensure_tls_cert()