key_manager.py
Minimal key manager: envelope encryption, rotation and an audit trail
- other
- python
- 245 lines
Run security tools only on systems you own or have written permission to test.
Run it
From the repository root, after
pip install -r tools/requirements.txtcd 05-cryptography/key-management python3 key_manager.py --help
Source
#!/usr/bin/env python3"""Minimal key manager: envelope encryption, rotation and an audit trailLocation: 05-cryptography/key-management/key_manager.pyA teaching implementation of the key lifecycle from NIST SP 800-57: generate, use, rotate,retire, destroy. * A master key is derived from a passphrase with scrypt (never stored). * Every data key is random (32 bytes) and stored only WRAPPED (AES-256-GCM) under the master key. * Rotation creates version N+1 for new encryption; older versions stay readable until retired. * Destroyed keys have their wrapped material removed, so data encrypted under them is unrecoverable. * Every action is appended to a hash-chained audit log (editing history breaks the chain).For production use a KMS or HSM (AWS KMS, Google Cloud KMS, Azure Key Vault, HashiCorp Vault).Never hard-code keys in source files (CWE-321).""" import argparseimport base64import getpassimport hashlibimport jsonimport osimport sysimport timefrom datetime import datetime, timedelta, timezone from cryptography.exceptions import InvalidTagfrom cryptography.hazmat.primitives.ciphers.aead import AESGCMfrom cryptography.hazmat.primitives.kdf.scrypt import Scrypt b64 = lambda raw: base64.b64encode(raw).decode()unb64 = lambda text: base64.b64decode(text)now = lambda: datetime.now(timezone.utc) class KeyStoreError(Exception): pass class KeyStore: def __init__(self, directory, passphrase): self.dir = directory self.store_path = os.path.join(directory, "keystore.json") self.audit_path = os.path.join(directory, "audit.log") self.passphrase = passphrase.encode() self.data = None self.master = None # ----------------------------------------------------------- persistence def _derive(self, salt): return Scrypt(salt=salt, length=32, n=2 ** 15, r=8, p=1).derive(self.passphrase) def _save(self): tmp = self.store_path + ".tmp" with open(tmp, "w") as handle: json.dump(self.data, handle, indent=2) os.chmod(tmp, 0o600) os.replace(tmp, self.store_path) # atomic: no half-written keystore def init(self): if os.path.exists(self.store_path): raise KeyStoreError("keystore already exists") os.makedirs(self.dir, mode=0o700, exist_ok=True) salt = os.urandom(16) self.master = self._derive(salt) nonce = os.urandom(12) # A known plaintext lets us detect a wrong passphrase instead of failing later. check = AESGCM(self.master).encrypt(nonce, b"keystore-v1", None) self.data = {"version": 1, "salt": b64(salt), "check": b64(nonce + check), "keys": {}} self._save() self._audit("init", "-") def open(self): if not os.path.exists(self.store_path): raise KeyStoreError("no keystore here; run init first") with open(self.store_path) as handle: self.data = json.load(handle) self.master = self._derive(unb64(self.data["salt"])) blob = unb64(self.data["check"]) try: AESGCM(self.master).decrypt(blob[:12], blob[12:], None) except InvalidTag: raise KeyStoreError("wrong passphrase") # ----------------------------------------------------------- audit trail def _audit(self, action, key_id): previous = "0" * 64 if os.path.exists(self.audit_path): lines = open(self.audit_path).read().splitlines() if lines: previous = json.loads(lines[-1])["hash"] entry = {"time": now().isoformat(timespec="seconds"), "action": action, "key": key_id, "prev": previous} entry["hash"] = hashlib.sha256(json.dumps(entry, sort_keys=True).encode()).hexdigest() with open(self.audit_path, "a") as handle: handle.write(json.dumps(entry) + "\n") def verify_audit(self): previous = "0" * 64 for number, line in enumerate(open(self.audit_path).read().splitlines(), 1): entry = json.loads(line) claimed = entry.pop("hash") if entry["prev"] != previous or hashlib.sha256(json.dumps(entry, sort_keys=True).encode()).hexdigest() != claimed: return False, number previous = claimed return True, None # ----------------------------------------------------------- key lifecycle def _wrap(self, key_id, raw): nonce = os.urandom(12) return b64(nonce + AESGCM(self.master).encrypt(nonce, raw, key_id.encode())) # key id is authenticated def _unwrap(self, key_id, wrapped): blob = unb64(wrapped) return AESGCM(self.master).decrypt(blob[:12], blob[12:], key_id.encode()) def create(self, name, valid_days=90): version = 1 + max((k["version"] for k in self.data["keys"].values() if k["name"] == name), default=0) key_id = f"{name}-v{version}" self.data["keys"][key_id] = { "name": name, "version": version, "status": "active", "created": now().isoformat(timespec="seconds"), "expires": (now() + timedelta(days=valid_days)).isoformat(timespec="seconds"), "wrapped": self._wrap(key_id, os.urandom(32)), } self._save() self._audit("create", key_id) return key_id def rotate(self, name): """New version for new encryption; the previous active version becomes 'decrypt-only'.""" for key_id, meta in self.data["keys"].items(): if meta["name"] == name and meta["status"] == "active": meta["status"] = "decrypt-only" self._audit("rotate-out", key_id) return self.create(name) def set_status(self, key_id, status): meta = self._meta(key_id) if status == "destroyed": meta["wrapped"] = None meta["status"] = status self._save() self._audit(status, key_id) def _meta(self, key_id): if key_id not in self.data["keys"]: raise KeyStoreError(f"unknown key {key_id}") return self.data["keys"][key_id] def _raw(self, key_id, need): meta = self._meta(key_id) if meta["status"] == "destroyed" or not meta["wrapped"]: raise KeyStoreError(f"{key_id} has been destroyed") if need == "encrypt" and meta["status"] != "active": raise KeyStoreError(f"{key_id} is {meta['status']} and cannot be used to encrypt") if need == "encrypt" and datetime.fromisoformat(meta["expires"]) < now(): raise KeyStoreError(f"{key_id} has expired; rotate it") return self._unwrap(key_id, meta["wrapped"]) def active_key_id(self, name): for key_id, meta in self.data["keys"].items(): if meta["name"] == name and meta["status"] == "active": return key_id raise KeyStoreError(f"no active key named {name}") def encrypt(self, name, plaintext: bytes) -> dict: key_id = self.active_key_id(name) nonce = os.urandom(12) ciphertext = AESGCM(self._raw(key_id, "encrypt")).encrypt(nonce, plaintext, key_id.encode()) self._audit("encrypt", key_id) return {"key_id": key_id, "nonce": b64(nonce), "ciphertext": b64(ciphertext)} def decrypt(self, blob: dict) -> bytes: key_id = blob["key_id"] plaintext = AESGCM(self._raw(key_id, "decrypt")).decrypt(unb64(blob["nonce"]), unb64(blob["ciphertext"]), key_id.encode()) self._audit("decrypt", key_id) return plaintext def listing(self): rows = [] for key_id, meta in sorted(self.data["keys"].items()): expired = datetime.fromisoformat(meta["expires"]) < now() rows.append((key_id, "expired" if expired and meta["status"] == "active" else meta["status"], meta["created"], meta["expires"])) return rows def open_store(args): passphrase = os.environ.get(args.passphrase_env) or getpass.getpass("Keystore passphrase: ") store = KeyStore(args.dir, passphrase) return store def main(): parser = argparse.ArgumentParser(description="Teaching key manager (envelope encryption + rotation + audit log)") parser.add_argument("--dir", default="keystore", help="keystore directory") parser.add_argument("--passphrase-env", default="KEYSTORE_PASSPHRASE", help="env var holding the passphrase") sub = parser.add_subparsers(dest="cmd", required=True) sub.add_parser("init") c = sub.add_parser("create"); c.add_argument("name"); c.add_argument("--days", type=int, default=90) r = sub.add_parser("rotate"); r.add_argument("name") sub.add_parser("list") for name in ("retire", "destroy"): d = sub.add_parser(name); d.add_argument("key_id") sub.add_parser("audit") e = sub.add_parser("encrypt"); e.add_argument("name"); e.add_argument("infile"); e.add_argument("outfile") d = sub.add_parser("decrypt"); d.add_argument("infile"); d.add_argument("outfile") args = parser.parse_args() try: store = open_store(args) if args.cmd == "init": store.init(); print(f"[+] Keystore created in {args.dir}") return 0 store.open() if args.cmd == "create": print("[+] Created", store.create(args.name, args.days)) elif args.cmd == "rotate": print("[+] New active key", store.rotate(args.name)) elif args.cmd == "list": for row in store.listing(): print("%-22s %-13s created %s expires %s" % row) elif args.cmd in ("retire", "destroy"): store.set_status(args.key_id, "retired" if args.cmd == "retire" else "destroyed") print(f"[+] {args.key_id}: {args.cmd}") elif args.cmd == "audit": ok, line = store.verify_audit() print("[+] Audit log intact" if ok else f"[x] Audit log tampered at line {line}") return 0 if ok else 1 elif args.cmd == "encrypt": json.dump(store.encrypt(args.name, open(args.infile, "rb").read()), open(args.outfile, "w")) print(f"[+] Encrypted to {args.outfile}") elif args.cmd == "decrypt": open(args.outfile, "wb").write(store.decrypt(json.load(open(args.infile)))) print(f"[+] Decrypted to {args.outfile}") except (KeyStoreError, InvalidTag) as exc: print(f"[x] {exc}") return 1 return 0 if __name__ == "__main__": sys.exit(main())