feat: plan 16-01
This commit is contained in:
@@ -15,6 +15,8 @@ import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -79,6 +81,79 @@ def append_audit(store, base) -> None:
|
||||
store["audit"].append({**without_hash, "hash": hash_entry(without_hash)})
|
||||
|
||||
|
||||
# --- SEC-06 (H-06): sign the audit-chain HEAD -------------------------------
|
||||
# The hash chain alone is recomputable: a file-writer who edits the store can
|
||||
# recompute every hash and `verify-audit` (chain-only) would still PASS, so
|
||||
# governance-report would falsely report CERTIFIED. Signing the head with an
|
||||
# OFF-REPO private key (pubkey provisioned out-of-band; KMS in prod = SEC-02/16)
|
||||
# means a recompute-attacker without the key cannot forge a matching signature.
|
||||
|
||||
def _enforced() -> bool:
|
||||
return os.environ.get("CASAN_PROFILE") == "prod" or os.environ.get("CASAN_VERIFY_STRICT") == "1"
|
||||
|
||||
|
||||
def _cp_priv() -> str:
|
||||
key_dir = os.environ.get("CASAN_CP_KEY_DIR") or os.path.join(os.path.expanduser("~"), ".casan", "audit-keys")
|
||||
return os.path.join(key_dir, "cp-private.pem")
|
||||
|
||||
|
||||
def _cp_pub() -> str:
|
||||
# Prod provisions this out-of-band (or via KMS). Default is adjacent to the
|
||||
# store for self-contained dev; an attacker who can also rewrite the pubkey is
|
||||
# covered by ARCH-01/SEC-16 (signed harness+policy bundle).
|
||||
return os.environ.get("CASAN_CP_PUB") or (store_path() + ".pub")
|
||||
|
||||
|
||||
def _head_paths():
|
||||
sp = store_path()
|
||||
return sp + ".head", sp + ".head.sig"
|
||||
|
||||
|
||||
def chain_head(store) -> str:
|
||||
return store["audit"][-1]["hash"] if store["audit"] else GENESIS_HASH
|
||||
|
||||
|
||||
def sign_head(store) -> None:
|
||||
head_file, sig_file = _head_paths()
|
||||
head = chain_head(store)
|
||||
with open(head_file, "w", encoding="utf-8") as fh:
|
||||
fh.write(head)
|
||||
ossl = shutil.which("openssl")
|
||||
if not ossl:
|
||||
return # keyless dev: no signature — enforced mode rejects at verify time
|
||||
priv, pub = _cp_priv(), _cp_pub()
|
||||
os.makedirs(os.path.dirname(priv), exist_ok=True)
|
||||
if not os.path.isfile(priv):
|
||||
subprocess.run([ossl, "genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048",
|
||||
"-out", priv], capture_output=True)
|
||||
try:
|
||||
os.chmod(priv, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
os.makedirs(os.path.dirname(pub) or ".", exist_ok=True)
|
||||
# Always re-export the pubkey so it matches the signing key (key-sync invariant).
|
||||
subprocess.run([ossl, "rsa", "-in", priv, "-pubout", "-out", pub], capture_output=True)
|
||||
subprocess.run([ossl, "dgst", "-sha256", "-sign", priv, "-out", sig_file, head_file], capture_output=True)
|
||||
|
||||
|
||||
def verify_signature(store):
|
||||
"""Returns state in {'signed','unsigned','invalid'} + a detail string."""
|
||||
head_file, sig_file = _head_paths()
|
||||
pub = _cp_pub()
|
||||
ossl = shutil.which("openssl")
|
||||
if not (ossl and os.path.isfile(head_file) and os.path.isfile(sig_file) and os.path.isfile(pub)):
|
||||
return "unsigned", "missing signature/pubkey/openssl"
|
||||
try:
|
||||
stored_head = open(head_file, encoding="utf-8").read().strip()
|
||||
except OSError:
|
||||
return "invalid", "head-file unreadable"
|
||||
if stored_head != chain_head(store):
|
||||
return "invalid", "head-file != recomputed head (chain recomputed?)"
|
||||
res = subprocess.run([ossl, "dgst", "-sha256", "-verify", pub, "-signature", sig_file, head_file],
|
||||
capture_output=True)
|
||||
return ("signed", "ok") if res.returncode == 0 else ("invalid", "signature verify failed")
|
||||
|
||||
|
||||
def do_set(key, value, actor, reason, approval):
|
||||
policy = SETTINGS_POLICY.get(key)
|
||||
if policy is None:
|
||||
@@ -105,6 +180,7 @@ def do_set(key, value, actor, reason, approval):
|
||||
"actor": actor, "reason": reason, "at": nxt["updatedAt"],
|
||||
})
|
||||
save_store(store)
|
||||
sign_head(store)
|
||||
print(json.dumps(nxt, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
@@ -131,6 +207,7 @@ def do_rollback(key, actor, reason):
|
||||
"actor": actor, "reason": reason, "at": restored["updatedAt"],
|
||||
})
|
||||
save_store(store)
|
||||
sign_head(store)
|
||||
print(json.dumps(restored, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
@@ -141,9 +218,17 @@ def verify_audit():
|
||||
for entry in store["audit"]:
|
||||
rest = {k: v for k, v in entry.items() if k != "hash"}
|
||||
if rest.get("prevHash") != prev_hash or hash_entry(rest) != entry["hash"]:
|
||||
return {"ok": False, "brokenAt": entry["seq"]}
|
||||
return {"ok": False, "brokenAt": entry["seq"], "anchor": "chain-broken"}
|
||||
prev_hash = entry["hash"]
|
||||
return {"ok": True, "brokenAt": None}
|
||||
# SEC-06: the chain is recomputable, so require a valid HEAD signature. A
|
||||
# present-but-mismatched signature is always a failure; a MISSING signature
|
||||
# fails only in enforced mode (dev stays permissive for backward compat).
|
||||
sig_state, detail = verify_signature(store)
|
||||
if sig_state == "invalid":
|
||||
return {"ok": False, "brokenAt": None, "anchor": "signature-invalid", "detail": detail}
|
||||
if sig_state == "unsigned" and _enforced():
|
||||
return {"ok": False, "brokenAt": None, "anchor": "unsigned-strict-fail", "detail": detail}
|
||||
return {"ok": True, "brokenAt": None, "anchor": sig_state}
|
||||
|
||||
|
||||
def parse_value(raw):
|
||||
@@ -185,7 +270,7 @@ def main() -> int:
|
||||
print(json.dumps(load_store()["audit"], ensure_ascii=False)); return 0
|
||||
if args.cmd == "verify-audit":
|
||||
verdict = verify_audit()
|
||||
print(f"CP_AUDIT ok={verdict['ok']} brokenAt={verdict['brokenAt']}")
|
||||
print(f"CP_AUDIT ok={verdict['ok']} brokenAt={verdict['brokenAt']} anchor={verdict.get('anchor')}")
|
||||
return 0 if verdict["ok"] else 1
|
||||
if args.cmd == "effective":
|
||||
# Single read path for "effective setting": store value if set, else default.
|
||||
|
||||
Reference in New Issue
Block a user