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:
40
local-identity/src/local_identity/audit.py
Normal file
40
local-identity/src/local_identity/audit.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
Append-only audit log for local-identity.
|
||||
|
||||
All CLI commands and OIDC server events write one entry per operation to
|
||||
~/.local-identity/audit.log (mode 600).
|
||||
|
||||
Format (TSV): ISO-timestamp command username outcome
|
||||
Example:
|
||||
2026-03-02T00:00:00Z init alice ok
|
||||
2026-03-02T00:01:00Z serve/auth alice auth_request
|
||||
2026-03-02T00:01:01Z serve/token alice token_issued
|
||||
|
||||
Failures (e.g. file permission errors) are silently swallowed — the audit
|
||||
log must never interrupt the primary operation.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from .store import _store_dir
|
||||
|
||||
|
||||
def _audit_log_path() -> Path:
|
||||
return _store_dir() / "audit.log"
|
||||
|
||||
|
||||
def log_event(command: str, username: str | None, outcome: str) -> None:
|
||||
"""Append one audit entry. Silent on any I/O failure."""
|
||||
timestamp = datetime.datetime.now(datetime.timezone.utc).strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ"
|
||||
)
|
||||
entry = f"{timestamp}\t{command}\t{username or '-'}\t{outcome}\n"
|
||||
path = _audit_log_path()
|
||||
try:
|
||||
with open(path, "a", encoding="utf-8") as fh:
|
||||
fh.write(entry)
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
52
local-identity/src/local_identity/revoke.py
Normal file
52
local-identity/src/local_identity/revoke.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
Token revocation list for local-identity OIDC serve.
|
||||
|
||||
Revoked JTIs (JWT IDs) are stored in ~/.local-identity/revoked.json (mode 600).
|
||||
The list is append-only: JTIs are added but never removed.
|
||||
|
||||
Usage:
|
||||
revoke(jti) Add a JTI to the revocation list.
|
||||
is_revoked(jti) Return True if the JTI has been revoked.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from .store import _store_dir
|
||||
|
||||
|
||||
def _revoked_path() -> Path:
|
||||
return _store_dir() / "revoked.json"
|
||||
|
||||
|
||||
def _load() -> set[str]:
|
||||
path = _revoked_path()
|
||||
if not path.exists():
|
||||
return set()
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
return set(data.get("revoked", []))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return set()
|
||||
|
||||
|
||||
def _save(revoked: set[str]) -> None:
|
||||
path = _revoked_path()
|
||||
path.write_text(
|
||||
json.dumps({"revoked": sorted(revoked)}, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
def is_revoked(jti: str) -> bool:
|
||||
"""Return True if this JTI has been revoked. Defaults to False on read error."""
|
||||
return jti in _load()
|
||||
|
||||
|
||||
def revoke(jti: str) -> None:
|
||||
"""Add a JTI to the revocation list."""
|
||||
revoked = _load()
|
||||
revoked.add(jti)
|
||||
_save(revoked)
|
||||
148
local-identity/src/local_identity/security.py
Normal file
148
local-identity/src/local_identity/security.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
Filesystem permission checking for local-identity.
|
||||
|
||||
check_store() Audit all sensitive paths; return a list of CheckResult.
|
||||
enforce_permissions() Call on CLI startup — exits 1 if any check fails.
|
||||
print_security_check() Implement the 'security-check' subcommand.
|
||||
"""
|
||||
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .store import _config_file, _store_dir, _users_dir
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckResult:
|
||||
path: str
|
||||
status: str # "pass" | "warn" | "fail"
|
||||
detail: str
|
||||
|
||||
|
||||
def _check_mode(path: Path, expected: int) -> CheckResult:
|
||||
"""Return a CheckResult for a single path against the expected mode."""
|
||||
if not path.exists():
|
||||
return CheckResult(str(path), "warn", "does not exist (skipped)")
|
||||
actual = stat.S_IMODE(os.stat(path).st_mode)
|
||||
if actual != expected:
|
||||
return CheckResult(
|
||||
str(path), "fail",
|
||||
f"mode {oct(actual)}, expected {oct(expected)}",
|
||||
)
|
||||
return CheckResult(str(path), "pass", f"mode {oct(actual)}")
|
||||
|
||||
|
||||
def check_store() -> list[CheckResult]:
|
||||
"""
|
||||
Audit every sensitive path under the store.
|
||||
Returns a list of CheckResult objects; an empty list means the store
|
||||
does not exist yet (not an error — init hasn't run).
|
||||
"""
|
||||
store_dir = _store_dir()
|
||||
if not store_dir.exists():
|
||||
return []
|
||||
|
||||
results: list[CheckResult] = []
|
||||
|
||||
# Store root
|
||||
results.append(_check_mode(store_dir, 0o700))
|
||||
|
||||
# Config file
|
||||
config = _config_file()
|
||||
if config.exists():
|
||||
results.append(_check_mode(config, 0o600))
|
||||
|
||||
# Users directory
|
||||
users_dir = _users_dir()
|
||||
if users_dir.exists():
|
||||
results.append(_check_mode(users_dir, 0o700))
|
||||
for p in sorted(users_dir.glob("*.yaml")):
|
||||
results.append(_check_mode(p, 0o600))
|
||||
|
||||
# Signing key
|
||||
signing_key = store_dir / "keys" / "signing_private.pem"
|
||||
if signing_key.exists():
|
||||
results.append(_check_mode(signing_key, 0o600))
|
||||
|
||||
# TLS private key
|
||||
tls_key = store_dir / "tls" / "server.key"
|
||||
if tls_key.exists():
|
||||
results.append(_check_mode(tls_key, 0o600))
|
||||
|
||||
# Audit log
|
||||
audit_log = store_dir / "audit.log"
|
||||
if audit_log.exists():
|
||||
results.append(_check_mode(audit_log, 0o600))
|
||||
|
||||
# Revocation list
|
||||
revoked = store_dir / "revoked.json"
|
||||
if revoked.exists():
|
||||
results.append(_check_mode(revoked, 0o600))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def enforce_permissions() -> None:
|
||||
"""
|
||||
Check store permissions and exit 1 if any check fails.
|
||||
|
||||
Call at CLI startup for every command that requires an initialised store.
|
||||
Skips silently when the store does not yet exist (let the command handle
|
||||
the missing-store error itself).
|
||||
"""
|
||||
results = check_store()
|
||||
failures = [r for r in results if r.status == "fail"]
|
||||
if not failures:
|
||||
return
|
||||
|
||||
print(
|
||||
"Error: local-identity store has incorrect permissions:", file=sys.stderr
|
||||
)
|
||||
for r in failures:
|
||||
print(f" FAIL {r.path} — {r.detail}", file=sys.stderr)
|
||||
print(
|
||||
"Run 'local-identity security-check' for a full report.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def print_security_check() -> int:
|
||||
"""
|
||||
Print a human-readable security audit and return an exit code.
|
||||
Returns 0 if all checks pass or warn; 1 if any check fails.
|
||||
"""
|
||||
store_dir = _store_dir()
|
||||
if not store_dir.exists():
|
||||
print("Store not initialised. Run 'local-identity init' first.")
|
||||
return 1
|
||||
|
||||
results = check_store()
|
||||
if not results:
|
||||
print("Store not initialised. Run 'local-identity init' first.")
|
||||
return 1
|
||||
|
||||
fail_count = 0
|
||||
warn_count = 0
|
||||
|
||||
label = {"pass": "PASS", "warn": "WARN", "fail": "FAIL"}
|
||||
for r in results:
|
||||
print(f" {label[r.status]} {r.path} — {r.detail}")
|
||||
if r.status == "fail":
|
||||
fail_count += 1
|
||||
elif r.status == "warn":
|
||||
warn_count += 1
|
||||
|
||||
print()
|
||||
if fail_count:
|
||||
print(f"Result: {fail_count} failure(s), {warn_count} warning(s) — fix permissions before use")
|
||||
return 1
|
||||
elif warn_count:
|
||||
print(f"Result: {warn_count} warning(s) — review recommended")
|
||||
return 0
|
||||
else:
|
||||
print("Result: all checks passed")
|
||||
return 0
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user