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

@@ -12,20 +12,29 @@ Commands:
export --all [--realm R] Bulk partial-import body (primary users only).
Add --include-test to include generated users.
serve [--port P] [--ttl T] Start the minimal OIDC server on 127.0.0.1.
security-check Validate filesystem permissions.
revoke-token <jti-or-jwt> Add a token JTI to the revocation list.
Environment:
LOCAL_IDENTITY_HOME Override the store directory (default: ~/.local-identity).
"""
import argparse
import base64
import json
import sys
from .gecos import current_username, get_gecos_fullname
from .user import UserRecord, make_test_user
from . import audit
from . import export as export_mod
from . import revoke as revoke_mod
from . import serve as serve_mod
from . import store
from .security import enforce_permissions, print_security_check
# Commands that must not run the startup permission check
_SKIP_ENFORCE = {"init", "security-check"}
def _resolve_init_params(args: argparse.Namespace, config: dict) -> tuple[str, str, str]:
@@ -75,6 +84,8 @@ def cmd_init(args: argparse.Namespace) -> None:
store.write_user(test1)
store.write_user(test2)
audit.log_event("init", username, "ok")
print(f"Initialised local-identity store at {store._store_dir()}")
print(f" Primary : {primary.username} ({primary.fullname}) <{primary.email}>")
print(f" Test 1 : {test1.username} <{test1.email}>")
@@ -92,11 +103,11 @@ def cmd_list(args: argparse.Namespace) -> None:
for u in users:
utype = "test " if u.generated else "primary"
print(f"{u.username:<20} {u.fullname:<30} {u.email:<40} {utype}")
audit.log_event("list", None, f"{len(users)}_users")
def cmd_export(args: argparse.Namespace) -> None:
if args.username:
# Single-user export
try:
user = store.read_user(args.username)
except FileNotFoundError as exc:
@@ -104,8 +115,8 @@ def cmd_export(args: argparse.Namespace) -> None:
sys.exit(1)
kc = export_mod.user_to_keycloak(user, realm=args.realm)
print(json.dumps(kc, indent=2))
audit.log_event("export", args.username, "ok")
else:
# Bulk export
all_users = store.list_users()
if args.include_test:
users = all_users
@@ -124,6 +135,7 @@ def cmd_export(args: argparse.Namespace) -> None:
if_resource_exists=args.if_resource_exists,
)
print(json.dumps(body, indent=2))
audit.log_event("export", "--all", f"{len(users)}_users")
def cmd_show(args: argparse.Namespace) -> None:
@@ -133,12 +145,44 @@ def cmd_show(args: argparse.Namespace) -> None:
print(str(exc), file=sys.stderr)
sys.exit(1)
print(user.to_yaml(), end="")
audit.log_event("show", args.username, "ok")
def cmd_serve(args: argparse.Namespace) -> None:
serve_mod.run_server(port=args.port, token_ttl=args.ttl)
def cmd_security_check(args: argparse.Namespace) -> None:
rc = print_security_check()
sys.exit(rc)
def cmd_revoke_token(args: argparse.Namespace) -> None:
token_or_jti: str = args.token
if token_or_jti.count(".") == 2:
# Looks like a JWT — extract the JTI from the payload
try:
payload_b64 = token_or_jti.split(".")[1]
pad = (4 - len(payload_b64) % 4) % 4
payload = json.loads(
base64.urlsafe_b64decode(payload_b64 + "=" * pad)
)
jti = payload.get("jti")
if not jti:
print("Error: JWT has no 'jti' claim.", file=sys.stderr)
sys.exit(1)
except Exception as exc:
print(f"Error decoding JWT: {exc}", file=sys.stderr)
sys.exit(1)
else:
jti = token_or_jti
revoke_mod.revoke(jti)
print(f"Token revoked: {jti}")
audit.log_event("revoke-token", None, f"jti={jti}")
def main() -> None:
parser = argparse.ArgumentParser(
prog="local-identity",
@@ -216,7 +260,26 @@ def main() -> None:
)
p_serve.set_defaults(func=cmd_serve)
sub.add_parser(
"security-check",
help="Validate filesystem permissions of the store",
).set_defaults(func=cmd_security_check)
p_revoke = sub.add_parser(
"revoke-token",
help="Add a token JTI to the revocation list",
)
p_revoke.add_argument(
"token",
help="Token JTI (UUID) or full JWT — the JTI is extracted automatically",
)
p_revoke.set_defaults(func=cmd_revoke_token)
args = parser.parse_args()
if args.command not in _SKIP_ENFORCE:
enforce_permissions()
args.func(args)