aps-agent/tests/golden/test_audit_encryption.py

350 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 加密归档模拟黄金测试(矩阵 104/116 的加密剩余项)
# 覆盖:AES-256-GCM 加密归档往返(env 原始密钥 / 密钥文件口令 scrypt 派生)、
# manifest ciphertextHash + plaintextHash 双层校验、
# 解密校验(显式密钥)、密钥缺失显式失败(不静默)、错误密钥显式失败、
# 密文篡改检出、WORM manifest 兼容(不加密行为不变 / encrypt=False 强制明文)、
# encrypt=True 无密钥 fail-closed、同月二次加密归档、网关端点接线。
# 说明:真实密钥管理(HSM/KMS、密钥轮换)为外部验收范围,本地为 env/密钥文件密钥。
# ============================================================
from __future__ import annotations
import json
import os
import stat
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from server.agent_core.audit_ledger import (
ENCRYPTED_FORMAT,
ENCRYPTION_ENV_KEY,
ENCRYPTION_ENV_KEY_FILE,
ENCRYPTION_KDF,
ENCRYPTION_NOTE,
ENCRYPTION_SCHEME,
WORM_MANIFEST,
AnchorLedger,
_decrypt_bytes,
)
from server.agent_core.audit_merkle import merkle_root
RAW_KEY = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
WRONG_KEY = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
PASSPHRASE = "s3cret-passphrase-for-audit-archive"
def _event(i: int) -> dict:
return {"id": i, "at": "2026-08-01T00:00:00+08:00", "actor": "tester",
"category": "TEST", "action": f"test.{i}", "target": {"type": "TEST"},
"power": "P0", "rationale": {}, "prevHash": "GENESIS", "hash": "0" * 64}
def _seed_ledger(ledger: AnchorLedger, n: int = 5) -> None:
evs: list[dict] = []
for i in range(n):
evs.append(_event(i + 1))
ledger.append(evs, reason="test")
def _manifest(ledger: AnchorLedger) -> dict:
return json.loads((ledger.archive_dir / WORM_MANIFEST).read_text(encoding="utf-8"))
def _entry(ledger: AnchorLedger, archive_name: str) -> dict:
return _manifest(ledger)["files"][archive_name]
# ---------- 加密归档往返 ----------
def test_encrypt_archive_roundtrip_env_key(tmp_path: Path, monkeypatch):
"""env 原始密钥(64 hex):归档文件为加密信封,manifest 双层哈希 + 算法/版本标记,
解密校验通过。"""
monkeypatch.setenv(ENCRYPTION_ENV_KEY, RAW_KEY)
ledger = AnchorLedger("tenant-e", "world-e", ledger_dir=str(tmp_path))
_seed_ledger(ledger)
r = ledger.archive_old(keep_last=2)
assert r["archived"] == 3
assert r["encryption"]["enabled"] is True
assert r["encryption"]["scheme"] == ENCRYPTION_SCHEME
archive = Path(r["archive"])
envelope = json.loads(archive.read_text(encoding="utf-8"))
assert envelope["format"] == ENCRYPTED_FORMAT
assert envelope["scheme"] == ENCRYPTION_SCHEME
assert envelope["ciphertext"] # 非明文(无 eventId 顶层字段)
assert "eventId" not in envelope
entry = _entry(ledger, archive.name)
assert entry["encryption"]["enabled"] is True
assert entry["encryption"]["scheme"] == ENCRYPTION_SCHEME
assert entry["encryption"]["kdf"] == "none" # 原始密钥不经 KDF
assert entry["ciphertextHash"] == entry["sha256"] # 密文文件哈希
assert entry["plaintextHash"] # 明文哈希
assert entry["ciphertextHash"] != entry["plaintextHash"]
v = ledger.verify_archive()
assert v["ok"] is True
assert v["summary"]["verified"] == 1
assert v["summary"]["decryptFailed"] == 0
assert v["encryption"]["enabled"] is True and v["encryption"]["count"] == 1
def test_encrypt_archive_roundtrip_key_file_passphrase(tmp_path: Path, monkeypatch):
"""密钥文件 + 口令:kdf=scrypt;清除 env 后显式传密钥仍可解密校验。"""
key_file = tmp_path / "audit.key"
key_file.write_text(PASSPHRASE + "\n", encoding="utf-8")
monkeypatch.setenv(ENCRYPTION_ENV_KEY_FILE, str(key_file))
monkeypatch.delenv(ENCRYPTION_ENV_KEY, raising=False)
ledger = AnchorLedger("tenant-p", "world-p", ledger_dir=str(tmp_path))
_seed_ledger(ledger)
r = ledger.archive_old(keep_last=2)
assert r["encryption"]["enabled"] is True
entry = _entry(ledger, Path(r["archive"]).name)
assert entry["encryption"]["kdf"] == ENCRYPTION_KDF # scrypt 派生
monkeypatch.delenv(ENCRYPTION_ENV_KEY_FILE)
v_missing = ledger.verify_archive()
assert v_missing["ok"] is False # 无密钥 → 显式失败
assert v_missing["summary"]["decryptFailed"] == 1
assert v_missing["decryptFailed"][0]["reason"] == "encryption-key-missing"
v_explicit = ledger.verify_archive(key=PASSPHRASE.encode("utf-8"))
assert v_explicit["ok"] is True
assert v_explicit["summary"]["verified"] == 1
# ---------- 解密校验 / 显式失败 ----------
def test_verify_explicit_key_and_merkle_recompute(tmp_path: Path, monkeypatch):
"""显式密钥解密 + 明文 JSONL 复算 merkle root 与清单一致(双层校验闭环)。"""
monkeypatch.setenv(ENCRYPTION_ENV_KEY, RAW_KEY)
ledger = AnchorLedger("tenant-d", "world-d", ledger_dir=str(tmp_path))
_seed_ledger(ledger)
r = ledger.archive_old(keep_last=2)
archive = Path(r["archive"])
monkeypatch.delenv(ENCRYPTION_ENV_KEY)
envelope = json.loads(archive.read_text(encoding="utf-8"))
plaintext = _decrypt_bytes(envelope, bytes.fromhex(RAW_KEY))
import hashlib
entry = _entry(ledger, archive.name)
assert hashlib.sha256(plaintext).hexdigest() == entry["plaintextHash"]
records = [json.loads(ln) for ln in plaintext.decode("utf-8").splitlines() if ln.strip()]
assert len(records) == 3
assert entry["merkleRoot"] == merkle_root(records)
assert entry["coverage"]["records"] == 3
v = ledger.verify_archive(key=bytes.fromhex(RAW_KEY))
assert v["ok"] is True
assert v["summary"]["verified"] == 1
assert v["summary"]["merkleMismatch"] == 0
def test_verify_missing_key_fails_explicitly(tmp_path: Path, monkeypatch):
"""密钥缺失:verify 显式失败(decryptFailed=1),不静默视为通过。"""
monkeypatch.setenv(ENCRYPTION_ENV_KEY, RAW_KEY)
ledger = AnchorLedger("tenant-m", "world-m", ledger_dir=str(tmp_path))
_seed_ledger(ledger)
r = ledger.archive_old(keep_last=2)
assert r["encryption"]["enabled"] is True
monkeypatch.delenv(ENCRYPTION_ENV_KEY)
v = ledger.verify_archive()
assert v["ok"] is False
assert v["summary"]["decryptFailed"] == 1
item = v["decryptFailed"][0]
assert item["file"] == Path(r["archive"]).name
assert item["reason"] == "encryption-key-missing"
assert ENCRYPTION_NOTE in item["note"] # 文档化:真实密钥管理为外部项
assert v["summary"]["verified"] == 0 # 未静默计数为通过
def test_verify_wrong_key_fails_explicitly(tmp_path: Path, monkeypatch):
"""错误密钥:解密失败(AES-GCM tag 校验失败)→ decryptFailed,显式报告。"""
monkeypatch.setenv(ENCRYPTION_ENV_KEY, RAW_KEY)
ledger = AnchorLedger("tenant-wk", "world-wk", ledger_dir=str(tmp_path))
_seed_ledger(ledger)
ledger.archive_old(keep_last=2)
v = ledger.verify_archive(key=bytes.fromhex(WRONG_KEY))
assert v["ok"] is False
assert v["summary"]["decryptFailed"] == 1
assert v["decryptFailed"][0]["reason"] == "decryption-failed"
assert v["summary"]["tampered"] == 0
# ---------- 篡改检出 ----------
def test_verify_detects_ciphertext_tampering(tmp_path: Path, monkeypatch):
"""密文篡改:改信封文件字节 → ciphertext-hash-mismatch(tampered expected/actual)。"""
monkeypatch.setenv(ENCRYPTION_ENV_KEY, RAW_KEY)
ledger = AnchorLedger("tenant-ct", "world-ct", ledger_dir=str(tmp_path))
_seed_ledger(ledger)
r = ledger.archive_old(keep_last=2)
archive = Path(r["archive"])
assert ledger.verify_archive()["ok"] is True
os.chmod(archive, stat.S_IWRITE) # 模拟攻击者清除只读位
archive.write_text(archive.read_text(encoding="utf-8") + "{\"evil\":true}\n",
encoding="utf-8")
v = ledger.verify_archive()
assert v["ok"] is False
assert v["summary"]["tampered"] == 1
item = v["tampered"][0]
assert item["reason"] == "ciphertext-hash-mismatch"
entry = _entry(ledger, archive.name)
assert item["expected"] == entry["ciphertextHash"]
assert item["actual"] != item["expected"]
def test_verify_encrypted_archive_missing_file(tmp_path: Path, monkeypatch):
"""加密归档文件缺失 → missing 显式报告(与 WORM 语义一致)。"""
monkeypatch.setenv(ENCRYPTION_ENV_KEY, RAW_KEY)
ledger = AnchorLedger("tenant-ms", "world-ms", ledger_dir=str(tmp_path))
_seed_ledger(ledger)
r = ledger.archive_old(keep_last=2)
archive = Path(r["archive"])
os.chmod(archive, stat.S_IWRITE)
archive.unlink()
v = ledger.verify_archive()
assert v["ok"] is False
assert v["summary"]["missing"] == 1
assert v["missing"][0]["file"] == archive.name
# ---------- WORM manifest 兼容 ----------
def test_plaintext_behavior_unchanged_when_no_key(tmp_path: Path, monkeypatch):
"""不配置密钥:行为与 round-42 WORM 完全一致(encryption.enabled=False,
sha256 == ciphertextHash == plaintextHash == 文件哈希)。"""
monkeypatch.delenv(ENCRYPTION_ENV_KEY, raising=False)
monkeypatch.delenv(ENCRYPTION_ENV_KEY_FILE, raising=False)
ledger = AnchorLedger("tenant-pc", "world-pc", ledger_dir=str(tmp_path))
_seed_ledger(ledger)
r = ledger.archive_old(keep_last=2)
assert r["encryption"]["enabled"] is False
archive = Path(r["archive"])
entry = _entry(ledger, archive.name)
import hashlib
file_sha = hashlib.sha256(archive.read_bytes()).hexdigest()
assert entry["sha256"] == file_sha
assert entry["ciphertextHash"] == file_sha
assert entry["plaintextHash"] == file_sha
assert entry["encryption"]["enabled"] is False
records = [json.loads(ln) for ln in archive.read_text(encoding="utf-8").splitlines()
if ln.strip()] # 明文 JSONL 仍可直读
assert len(records) == 3
assert ledger.verify_archive()["ok"] is True
assert ledger.verify_archive()["encryption"]["enabled"] is False
def test_encrypt_true_without_key_fails_closed(tmp_path: Path, monkeypatch):
"""encrypt=True 但未配置密钥 → ValueError(fail-closed,不静默降级明文)。"""
monkeypatch.delenv(ENCRYPTION_ENV_KEY, raising=False)
monkeypatch.delenv(ENCRYPTION_ENV_KEY_FILE, raising=False)
ledger = AnchorLedger("tenant-fc", "world-fc", ledger_dir=str(tmp_path))
_seed_ledger(ledger)
with pytest.raises(ValueError, match="APS_AUDIT_ENCRYPT_KEY"):
ledger.archive_old(keep_last=2, encrypt=True)
def test_encrypt_false_with_key_forces_plaintext(tmp_path: Path, monkeypatch):
"""配置了密钥但 encrypt=False:强制明文归档,WORM 清单加密标记为关闭。"""
monkeypatch.setenv(ENCRYPTION_ENV_KEY, RAW_KEY)
ledger = AnchorLedger("tenant-fp", "world-fp", ledger_dir=str(tmp_path))
_seed_ledger(ledger)
r = ledger.archive_old(keep_last=2, encrypt=False)
assert r["encryption"]["enabled"] is False
archive = Path(r["archive"])
entry = _entry(ledger, archive.name)
assert entry["encryption"]["enabled"] is False
records = [json.loads(ln) for ln in archive.read_text(encoding="utf-8").splitlines()
if ln.strip()]
assert len(records) == 3 # 明文 JSONL
assert ledger.verify_archive()["ok"] is True
def test_second_encrypted_archive_same_month_new_file(tmp_path: Path, monkeypatch):
"""同月二次加密归档:不改写已密封文件,改用 <month>.<n>.jsonl,两件均在册。"""
monkeypatch.setenv(ENCRYPTION_ENV_KEY, RAW_KEY)
ledger = AnchorLedger("tenant-2nd", "world-2nd", ledger_dir=str(tmp_path))
_seed_ledger(ledger, n=5)
r1 = ledger.archive_old(keep_last=2)
first = Path(r1["archive"])
_seed_ledger(ledger, n=3) # 追加后再次归档(同月)
r2 = ledger.archive_old(keep_last=2)
second = Path(r2["archive"])
assert second.name != first.name
assert second.name == f"{first.stem}.2.jsonl" or second.name.endswith(".2.jsonl")
manifest = _manifest(ledger)
assert first.name in manifest["files"] and second.name in manifest["files"]
assert manifest["files"][first.name]["encryption"]["enabled"] is True
assert manifest["files"][second.name]["encryption"]["enabled"] is True
assert ledger.verify_archive()["ok"] is True
assert ledger.verify_archive()["summary"]["verified"] == 2
# ---------- 网关接线(env 密钥,无需改网关代码) ----------
class _Store:
def __init__(self):
self.tenant_uuid = "platform"
self.world_key = "default"
self.data = {"auditEvents": [
{"id": 1, "at": "2026-08-01T00:00:00+08:00", "ts": "2026-08-01T00:00:00+08:00",
"actor": "tester", "category": "TEST", "action": "test.one",
"target": {"type": "TEST"}, "power": "P0", "rationale": {},
"prevHash": "GENESIS", "hash": "0" * 64},
]}
def next_id(self, _kind: str) -> int:
return 2
def save(self) -> None:
pass
@pytest.fixture
def client(monkeypatch, tmp_path):
import server.gateway.app as gateway_module
from tests.auth_provider import install_test_auth
install_test_auth(monkeypatch, "tenant-enc-api")
store = _Store()
monkeypatch.setattr(gateway_module, "get_store", lambda: store)
c = TestClient(gateway_module.create_app())
login = c.post("/api/auth/login", json={"username": "planner", "password": "test"})
assert login.status_code == 200, login.text
return c, store
def test_gov_archive_endpoints_encrypted_roundtrip(client, tmp_path, monkeypatch):
"""网关接线:配置 env 密钥 → /archive/run 产出加密归档,/archive/verify 通过;
撤密钥 → verify 显式 decryptFailed(fail-closed 语义透出 API)。"""
c, _ = client
monkeypatch.setenv("APS_AUDIT_LEDGER_DIR", str(tmp_path / "ledger"))
monkeypatch.setenv(ENCRYPTION_ENV_KEY, RAW_KEY)
for _ in range(3): # 锚定 3 条账本记录
assert c.post("/api/gov/audit/anchor").status_code == 200
run = c.post("/api/gov/archive/run?keep_last=1")
assert run.status_code == 200, run.text
body = run.json()
assert body["archived"] == 2
assert body["encryption"]["enabled"] is True
assert body["worm"]["sealed"] is True
v = c.get("/api/gov/archive/verify")
assert v.status_code == 200
data = v.json()
assert data["ok"] is True
assert data["summary"]["verified"] == 1
assert data["summary"]["decryptFailed"] == 0
monkeypatch.delenv(ENCRYPTION_ENV_KEY) # 密钥缺失 → 显式失败
v2 = c.get("/api/gov/archive/verify")
assert v2.status_code == 200
d2 = v2.json()
assert d2["ok"] is False
assert d2["summary"]["decryptFailed"] == 1
assert d2["decryptFailed"][0]["reason"] == "encryption-key-missing"