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:
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
|
||||
Reference in New Issue
Block a user