644 lines
30 KiB
Python
644 lines
30 KiB
Python
|
|
# ============================================================
|
|||
|
|
# 审计独立介质锚定 v1(moduleId: core-audit-ledger, 可重生 ✅)
|
|||
|
|
# plan.md §3.6 / 矩阵「全审计」:独立介质 + 外部锚定根 + 篡改检测
|
|||
|
|
# 与 write_audit 的世界内 SHA-256 链互补:账本提供跨介质锚定证明
|
|||
|
|
# ============================================================
|
|||
|
|
from __future__ import annotations # 前向类型引用
|
|||
|
|
|
|||
|
|
import hashlib # SHA-256
|
|||
|
|
import base64 # 加密信封 base64 编解码
|
|||
|
|
import json # JSONL 序列化
|
|||
|
|
import os # 目录/文件操作
|
|||
|
|
import threading # 并发写锁
|
|||
|
|
from pathlib import Path # 路径处理
|
|||
|
|
from typing import Any # 类型标注
|
|||
|
|
|
|||
|
|
# 创世锚定根(空账本 / 空事件列表)
|
|||
|
|
GENESIS_ROOT = hashlib.sha256(b"aps-audit-genesis").hexdigest()
|
|||
|
|
|
|||
|
|
|
|||
|
|
# WORM 模拟清单:归档目录密封承诺(矩阵 104/112/116 的 WORM 剩余项)
|
|||
|
|
WORM_MANIFEST = ".worm-manifest.json" # 归档目录 WORM 模拟清单文件名
|
|||
|
|
WORM_NOTE = ("逻辑 WORM 语义 + 篡改检测模拟:归档文件写入 .worm-manifest.json 密封"
|
|||
|
|
"(sha256 + merkle root 摘要 + 归档时间 + 覆盖范围),常规写路径拒绝改写;"
|
|||
|
|
"物理 WORM 介质(光盘/WORM 盘)为外部验收范围,本实现不模拟硬件级不可覆写。")
|
|||
|
|
|
|||
|
|
|
|||
|
|
# 加密归档模拟(矩阵 104/116 的加密剩余项;真实密钥管理为外部验收)
|
|||
|
|
ENCRYPTION_SCHEME = "aes-256-gcm" # AES-256-GCM 认证加密
|
|||
|
|
ENCRYPTION_KDF = "scrypt" # 口令 → 密钥派生(scrypt)
|
|||
|
|
ENCRYPTION_ENV_KEY = "APS_AUDIT_ENCRYPT_KEY" # 加密密钥/口令 env
|
|||
|
|
ENCRYPTION_ENV_KEY_FILE = "APS_AUDIT_ENCRYPT_KEY_FILE" # 加密密钥文件 env
|
|||
|
|
ENCRYPTED_FORMAT = "aps-audit-encrypted-v1" # 加密归档信封格式版本
|
|||
|
|
ENCRYPTION_NOTE = (f"本地对称加密模拟(aes-256-gcm + scrypt 派生):归档记录加密落盘,"
|
|||
|
|
f"manifest 记录 ciphertextHash + plaintextHash 双层校验;"
|
|||
|
|
f"真实密钥管理(HSM/KMS、密钥轮换与托管)为外部验收范围,本地密钥来源 "
|
|||
|
|
f"{ENCRYPTION_ENV_KEY}(64 位 hex = 32B 原始密钥,否则视为口令 scrypt 派生)"
|
|||
|
|
f"或 {ENCRYPTION_ENV_KEY_FILE}(密钥文件,规则同上)。")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _file_sha256(path: Path) -> str:
|
|||
|
|
"""计算文件 SHA-256(分块读取,适配大归档文件)。"""
|
|||
|
|
h = hashlib.sha256()
|
|||
|
|
with open(path, "rb") as fh:
|
|||
|
|
for chunk in iter(lambda: fh.read(65536), b""):
|
|||
|
|
h.update(chunk)
|
|||
|
|
return h.hexdigest()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _parse_jsonl(text: str) -> list[dict[str, Any]]:
|
|||
|
|
"""解析 JSONL 文本 → 记录列表(损坏行标注 _corrupt,不中断)。"""
|
|||
|
|
records: list[dict[str, Any]] = []
|
|||
|
|
for line in text.splitlines():
|
|||
|
|
line = line.strip()
|
|||
|
|
if not line:
|
|||
|
|
continue
|
|||
|
|
try:
|
|||
|
|
records.append(json.loads(line))
|
|||
|
|
except json.JSONDecodeError:
|
|||
|
|
records.append({"_corrupt": True})
|
|||
|
|
return records
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _key_material() -> tuple[bytes, str] | None:
|
|||
|
|
"""解析本地加密密钥材料:返回 (material, kind);kind ∈ {"raw", "passphrase"}。
|
|||
|
|
|
|||
|
|
APS_AUDIT_ENCRYPT_KEY(或密钥文件内容)为 64 位 hex → 32B 原始密钥(kind="raw"),
|
|||
|
|
否则视为口令(kind="passphrase",scrypt 派生)。未配置/密钥文件缺失 → None,
|
|||
|
|
由调用方决定 fail-closed 或明文归档。
|
|||
|
|
"""
|
|||
|
|
raw = os.environ.get(ENCRYPTION_ENV_KEY)
|
|||
|
|
if raw is None:
|
|||
|
|
key_file = os.environ.get(ENCRYPTION_ENV_KEY_FILE)
|
|||
|
|
if key_file:
|
|||
|
|
try:
|
|||
|
|
raw = Path(key_file).read_text(encoding="utf-8").strip()
|
|||
|
|
except OSError:
|
|||
|
|
return None
|
|||
|
|
if not raw:
|
|||
|
|
return None
|
|||
|
|
raw = raw.strip()
|
|||
|
|
if len(raw) == 64:
|
|||
|
|
try:
|
|||
|
|
return bytes.fromhex(raw), "raw"
|
|||
|
|
except ValueError:
|
|||
|
|
pass
|
|||
|
|
return raw.encode("utf-8"), "passphrase"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _derive_key(material: bytes, salt: bytes, use_scrypt: bool) -> bytes:
|
|||
|
|
"""密钥派生:raw 材料直接使用(须 32B);口令 scrypt(n=2^14, r=8, p=1)。"""
|
|||
|
|
if not use_scrypt:
|
|||
|
|
if len(material) != 32:
|
|||
|
|
raise ValueError("raw 密钥必须是 32 字节(64 位 hex)")
|
|||
|
|
return material
|
|||
|
|
return hashlib.scrypt(material, salt=salt, n=2 ** 14, r=8, p=1, dklen=32)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _encrypt_bytes(plaintext: bytes, material: tuple[bytes, str]) -> dict[str, Any]:
|
|||
|
|
"""AES-256-GCM 加密归档字节 → 信封 dict(salt/iv/ciphertext base64)。
|
|||
|
|
|
|||
|
|
依赖 cryptography(.venv v49 可用);库不可用 → RuntimeError(fail-closed,
|
|||
|
|
不静默降级明文)。信封自带 plaintextHash 便于外层双层校验。
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|||
|
|
except ImportError as exc: # pragma: no cover - .venv 已安装
|
|||
|
|
raise RuntimeError("cryptography 库不可用,无法执行加密归档(fail-closed)") from exc
|
|||
|
|
material_bytes, kind = material
|
|||
|
|
salt = os.urandom(16)
|
|||
|
|
iv = os.urandom(12)
|
|||
|
|
use_scrypt = kind == "passphrase"
|
|||
|
|
key = _derive_key(material_bytes, salt, use_scrypt)
|
|||
|
|
payload = AESGCM(key).encrypt(iv, plaintext, None) # ciphertext || tag
|
|||
|
|
return {
|
|||
|
|
"format": ENCRYPTED_FORMAT,
|
|||
|
|
"scheme": ENCRYPTION_SCHEME,
|
|||
|
|
"kdf": ENCRYPTION_KDF if use_scrypt else "none",
|
|||
|
|
"salt": base64.b64encode(salt).decode("ascii"),
|
|||
|
|
"iv": base64.b64encode(iv).decode("ascii"),
|
|||
|
|
"ciphertext": base64.b64encode(payload).decode("ascii"),
|
|||
|
|
"plaintextHash": hashlib.sha256(plaintext).hexdigest(),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _decrypt_bytes(envelope: dict[str, Any], material: bytes) -> bytes:
|
|||
|
|
"""解密信封 → 明文归档字节;密钥缺失/错误/密文或信封篡改 → ValueError(显式失败)。"""
|
|||
|
|
if envelope.get("format") != ENCRYPTED_FORMAT:
|
|||
|
|
raise ValueError(f"未知加密归档格式: {envelope.get('format')!r}")
|
|||
|
|
try:
|
|||
|
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|||
|
|
except ImportError as exc: # pragma: no cover - .venv 已安装
|
|||
|
|
raise RuntimeError("cryptography 库不可用,无法解密归档(fail-closed)") from exc
|
|||
|
|
try:
|
|||
|
|
salt = base64.b64decode(envelope["salt"])
|
|||
|
|
iv = base64.b64decode(envelope["iv"])
|
|||
|
|
payload = base64.b64decode(envelope["ciphertext"])
|
|||
|
|
use_scrypt = envelope.get("kdf") == ENCRYPTION_KDF
|
|||
|
|
key = _derive_key(material, salt, use_scrypt)
|
|||
|
|
plaintext = AESGCM(key).decrypt(iv, payload, None)
|
|||
|
|
except Exception as exc: # noqa: BLE001 - 密钥错误/密文篡改/信封损坏统一显式失败
|
|||
|
|
raise ValueError("归档解密失败(密钥错误或密文被篡改)") from exc
|
|||
|
|
return plaintext
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _read_envelope(path: Path) -> dict[str, Any]:
|
|||
|
|
"""读取加密归档信封(JSON);损坏 → ValueError(调用方显式失败,不静默)。"""
|
|||
|
|
try:
|
|||
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|||
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|||
|
|
raise ValueError("加密归档信封损坏") from exc
|
|||
|
|
if not isinstance(data, dict):
|
|||
|
|
raise ValueError("加密归档信封损坏")
|
|||
|
|
return data
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _atomic_write(path: Path, data: bytes) -> None:
|
|||
|
|
"""原子写文件(临时文件 + os.replace),避免半写。"""
|
|||
|
|
import tempfile as _tf
|
|||
|
|
fd, tmp = _tf.mkstemp(dir=str(path.parent), suffix=".tmp")
|
|||
|
|
try:
|
|||
|
|
with os.fdopen(fd, "wb") as fh:
|
|||
|
|
fh.write(data)
|
|||
|
|
os.replace(tmp, path)
|
|||
|
|
except BaseException:
|
|||
|
|
if os.path.exists(tmp):
|
|||
|
|
os.unlink(tmp)
|
|||
|
|
raise
|
|||
|
|
|
|||
|
|
|
|||
|
|
def audit_root(events: list[dict[str, Any]]) -> str:
|
|||
|
|
"""计算审计事件的锚定根(外部锚定证明)。
|
|||
|
|
|
|||
|
|
与 verify_audit_chain 的链式校验互补:root 对每个事件哈希做
|
|||
|
|
双哈希链聚合(Merkle 式),任何事件内容/顺序/数量变化都会改变根。
|
|||
|
|
"""
|
|||
|
|
acc = bytes.fromhex(GENESIS_ROOT) # 起点:创世根 digest
|
|||
|
|
for ev in events:
|
|||
|
|
ev_hash = str(ev.get("hash") or "").encode("utf-8")
|
|||
|
|
acc = hashlib.sha256(acc + ev_hash).digest()
|
|||
|
|
return acc.hex()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _default_ledger_dir() -> str:
|
|||
|
|
"""默认账本目录:server/data/audit-ledger(可按 APS_AUDIT_LEDGER_DIR 覆盖)。"""
|
|||
|
|
env = os.environ.get("APS_AUDIT_LEDGER_DIR")
|
|||
|
|
if env:
|
|||
|
|
return env
|
|||
|
|
try:
|
|||
|
|
from server.aps_home import path_under_data
|
|||
|
|
return str(path_under_data("audit-ledger"))
|
|||
|
|
except Exception:
|
|||
|
|
return os.path.join("server", "data", "audit-ledger")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _safe_scope(value: str) -> str:
|
|||
|
|
cleaned = "".join(c for c in (value or "default") if c.isalnum() or c in "-_")
|
|||
|
|
return cleaned[:64] or "default"
|
|||
|
|
|
|||
|
|
|
|||
|
|
class AnchorLedger:
|
|||
|
|
"""独立 append-only 审计账本(按租户/项目隔离,JSONL)。
|
|||
|
|
|
|||
|
|
每行一条锚定记录:{eventId, root, count, at, reason}。
|
|||
|
|
记录只追加;删除/改写历史行会导致校验失败(append-only 语义)。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self, tenant_uuid: str = "platform", world_key: str = "default",
|
|||
|
|
ledger_dir: str | None = None) -> None:
|
|||
|
|
self.tenant_uuid = _safe_scope(tenant_uuid)
|
|||
|
|
self.world_key = _safe_scope(world_key)
|
|||
|
|
base = Path(ledger_dir or _default_ledger_dir())
|
|||
|
|
self.path = base / self.tenant_uuid / f"{self.world_key}.jsonl"
|
|||
|
|
self._lock = threading.Lock()
|
|||
|
|
|
|||
|
|
def _ensure_dir(self) -> None:
|
|||
|
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
|
|
|||
|
|
def append(self, events: list[dict[str, Any]], *, reason: str = "manual") -> dict[str, Any]:
|
|||
|
|
"""追加一条锚定记录(原子:写临时文件后重命名,避免半行)。"""
|
|||
|
|
root = audit_root(events)
|
|||
|
|
count = len(events)
|
|||
|
|
last_event_id = str((events[-1].get("id") if events else None) or "GENESIS")
|
|||
|
|
record = {
|
|||
|
|
"eventId": last_event_id,
|
|||
|
|
"root": root,
|
|||
|
|
"count": count,
|
|||
|
|
"at": _now_iso(),
|
|||
|
|
"reason": reason,
|
|||
|
|
}
|
|||
|
|
with self._lock:
|
|||
|
|
self._ensure_dir()
|
|||
|
|
self._guard_archive_sealed(self.path) # WORM 模拟:密封归档件只读
|
|||
|
|
# append-only 追加(进程内锁 + 跨进程文件锁;fsync 保证落盘)
|
|||
|
|
from server.agent_core.audit_filelock import locked_append
|
|||
|
|
with locked_append(self.path) as dst:
|
|||
|
|
dst.write(json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n")
|
|||
|
|
return record
|
|||
|
|
|
|||
|
|
def read(self) -> list[dict[str, Any]]:
|
|||
|
|
"""读取全部锚定记录(损坏行 → 校验失败语义由 verify 处理)。"""
|
|||
|
|
if not self.path.exists():
|
|||
|
|
return []
|
|||
|
|
with self._lock:
|
|||
|
|
try:
|
|||
|
|
text = self.path.read_text(encoding="utf-8")
|
|||
|
|
except OSError:
|
|||
|
|
return []
|
|||
|
|
records: list[dict[str, Any]] = []
|
|||
|
|
for line in text.splitlines():
|
|||
|
|
line = line.strip()
|
|||
|
|
if not line:
|
|||
|
|
continue
|
|||
|
|
try:
|
|||
|
|
records.append(json.loads(line))
|
|||
|
|
except json.JSONDecodeError:
|
|||
|
|
records.append({"_corrupt": True})
|
|||
|
|
return records
|
|||
|
|
|
|||
|
|
def archive_old(self, keep_last: int | None = None,
|
|||
|
|
*, encrypt: bool | None = None) -> dict[str, Any]:
|
|||
|
|
"""清理策略(矩阵 116 行):把超过保留窗口的旧锚定记录移到独立归档目录。
|
|||
|
|
|
|||
|
|
append-only 语义保持:归档是分级存储而非删除——旧记录移入
|
|||
|
|
<dir>/archive/<yyyy-mm>.jsonl,主文件保留最近 keep_last 条;
|
|||
|
|
归档文件同样可读、可校验,证据不丢失。
|
|||
|
|
归档后文件密封为 WORM 模拟(.worm-manifest.json:sha256 + merkle root 摘要
|
|||
|
|
+ 归档时间 + 覆盖范围;文件置只读),常规写路径拒绝改写归档件。
|
|||
|
|
加密归档(矩阵 104/116 加密部分):encrypt=True 强制加密 / False 强制明文 /
|
|||
|
|
None 自动(配置 APS_AUDIT_ENCRYPT_KEY 或 APS_AUDIT_ENCRYPT_KEY_FILE 即加密);
|
|||
|
|
归档文件替换为 aes-256-gcm 信封,manifest 记录 ciphertextHash + plaintextHash
|
|||
|
|
双层校验;未配置密钥而 encrypt=True → ValueError(fail-closed,不静默降级)。
|
|||
|
|
keep_last: 保留条数(默认取 APS_AUDIT_LEDGER_KEEP_LAST,缺省 10000)
|
|||
|
|
Returns: {"archived": n, "kept": m, "archive": path, "worm": {...},
|
|||
|
|
"encryption": {...}}
|
|||
|
|
"""
|
|||
|
|
import os as _os
|
|||
|
|
import tempfile
|
|||
|
|
if keep_last is None:
|
|||
|
|
try:
|
|||
|
|
keep_last = int(_os.environ.get("APS_AUDIT_LEDGER_KEEP_LAST") or "10000")
|
|||
|
|
except ValueError:
|
|||
|
|
keep_last = 10000
|
|||
|
|
if keep_last < 0:
|
|||
|
|
raise ValueError("keep_last must be >= 0")
|
|||
|
|
records = self.read()
|
|||
|
|
if len(records) <= keep_last:
|
|||
|
|
return {"archived": 0, "kept": len(records), "archive": None,
|
|||
|
|
"encryption": {"enabled": False, "note": ENCRYPTION_NOTE}}
|
|||
|
|
overflow = records[:-keep_last]
|
|||
|
|
kept = records[-keep_last:]
|
|||
|
|
key_material = _key_material()
|
|||
|
|
encrypt_on = bool(key_material) if encrypt is None else bool(encrypt)
|
|||
|
|
if encrypt_on and key_material is None:
|
|||
|
|
raise ValueError(
|
|||
|
|
f"加密归档需要密钥:设置 {ENCRYPTION_ENV_KEY}(64 位 hex 或口令)"
|
|||
|
|
f"或 {ENCRYPTION_ENV_KEY_FILE}(密钥文件),或传 encrypt=False")
|
|||
|
|
archive_dir = self.path.parent / "archive"
|
|||
|
|
archive_dir.mkdir(parents=True, exist_ok=True)
|
|||
|
|
month = (kept[0].get("at") or "unknown")[:7] if kept else "unknown"
|
|||
|
|
# WORM 模拟:同月归档文件已密封/已存在 → 改用 <month>.<n>.jsonl,不改写旧件
|
|||
|
|
manifest = self._read_manifest() or {}
|
|||
|
|
sealed = set((manifest.get("files") or {}).keys())
|
|||
|
|
archive_path = archive_dir / f"{month}.jsonl"
|
|||
|
|
seq = 2
|
|||
|
|
while (archive_path.name in sealed) or archive_path.exists():
|
|||
|
|
archive_path = archive_dir / f"{month}.{seq}.jsonl"
|
|||
|
|
seq += 1
|
|||
|
|
from server.agent_core.audit_filelock import locked_append
|
|||
|
|
with locked_append(archive_path) as fh:
|
|||
|
|
for rec in overflow:
|
|||
|
|
fh.write(json.dumps(rec, ensure_ascii=False, sort_keys=True) + "\n")
|
|||
|
|
# 重写主文件为保留窗口(原子替换;先写临时文件再 os.replace)
|
|||
|
|
fd, tmp = tempfile.mkstemp(dir=str(self.path.parent), suffix=".tmp")
|
|||
|
|
try:
|
|||
|
|
with _os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|||
|
|
for rec in kept:
|
|||
|
|
fh.write(json.dumps(rec, ensure_ascii=False, sort_keys=True) + "\n")
|
|||
|
|
_os.replace(tmp, self.path)
|
|||
|
|
except BaseException:
|
|||
|
|
if _os.path.exists(tmp):
|
|||
|
|
_os.unlink(tmp)
|
|||
|
|
raise
|
|||
|
|
result = {"archived": len(overflow), "kept": len(kept), "archive": str(archive_path)}
|
|||
|
|
if overflow: # WORM 模拟:密封 + 只读 + 清单
|
|||
|
|
entry = self._seal_archive(archive_path, overflow, _now_iso(),
|
|||
|
|
key_material=key_material if encrypt_on else None)
|
|||
|
|
result["worm"] = {"mode": "worm-simulated", "sealed": True,
|
|||
|
|
"file": archive_path.name, "entry": entry}
|
|||
|
|
result["encryption"] = {
|
|||
|
|
"enabled": encrypt_on,
|
|||
|
|
"scheme": ENCRYPTION_SCHEME if encrypt_on else None,
|
|||
|
|
"file": archive_path.name if encrypt_on else None,
|
|||
|
|
"note": ENCRYPTION_NOTE,
|
|||
|
|
}
|
|||
|
|
else:
|
|||
|
|
result["worm"] = {"mode": "worm-simulated", "sealed": False}
|
|||
|
|
result["encryption"] = {"enabled": False, "note": ENCRYPTION_NOTE}
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def archive_dir(self) -> Path:
|
|||
|
|
"""归档目录:<ledger_dir>/<tenant>/archive(WORM 模拟密封区)。"""
|
|||
|
|
return self.path.parent / "archive"
|
|||
|
|
|
|||
|
|
def _manifest_path(self) -> Path:
|
|||
|
|
"""WORM 清单路径(归档目录内 .worm-manifest.json)。"""
|
|||
|
|
return self.archive_dir / WORM_MANIFEST
|
|||
|
|
|
|||
|
|
def _read_manifest(self) -> dict[str, Any] | None:
|
|||
|
|
"""读取 WORM 清单;缺失/损坏返回 None(verify_archive 显式报告)。"""
|
|||
|
|
mp = self._manifest_path()
|
|||
|
|
if not mp.exists():
|
|||
|
|
return None
|
|||
|
|
try:
|
|||
|
|
data = json.loads(mp.read_text(encoding="utf-8"))
|
|||
|
|
return data if isinstance(data, dict) else None
|
|||
|
|
except (OSError, json.JSONDecodeError):
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def _write_manifest(self, manifest: dict[str, Any]) -> None:
|
|||
|
|
"""原子写 WORM 清单(临时文件 + os.replace,避免半写损坏)。"""
|
|||
|
|
import tempfile as _tf
|
|||
|
|
self.archive_dir.mkdir(parents=True, exist_ok=True)
|
|||
|
|
fd, tmp = _tf.mkstemp(dir=str(self.archive_dir), suffix=".tmp")
|
|||
|
|
try:
|
|||
|
|
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|||
|
|
json.dump(manifest, fh, ensure_ascii=False, sort_keys=True, indent=2)
|
|||
|
|
os.replace(tmp, self._manifest_path())
|
|||
|
|
except BaseException:
|
|||
|
|
if os.path.exists(tmp):
|
|||
|
|
os.unlink(tmp)
|
|||
|
|
raise
|
|||
|
|
|
|||
|
|
def _guard_archive_sealed(self, target: Path) -> None:
|
|||
|
|
"""WORM 模拟:常规写路径拒绝改写已密封的归档文件(manifest 在册 → 抛错)。"""
|
|||
|
|
try:
|
|||
|
|
inside = target.resolve().parent == self.archive_dir.resolve()
|
|||
|
|
except OSError:
|
|||
|
|
inside = False
|
|||
|
|
if not inside:
|
|||
|
|
return
|
|||
|
|
manifest = self._read_manifest()
|
|||
|
|
if manifest and target.name in (manifest.get("files") or {}):
|
|||
|
|
raise ValueError(
|
|||
|
|
f"WORM 模拟拒绝:归档文件 {target.name} 已密封(manifest 在册),不可改写")
|
|||
|
|
|
|||
|
|
def _seal_archive(self, archive_path: Path, records: list[dict[str, Any]],
|
|||
|
|
archived_at: str,
|
|||
|
|
key_material: tuple[bytes, str] | None = None) -> dict[str, Any]:
|
|||
|
|
"""密封单个归档文件:sha256 + merkle root + 归档时间 + 覆盖范围 → 清单。
|
|||
|
|
|
|||
|
|
可选加密归档(key_material 非 None):归档文件内容替换为 aes-256-gcm 信封,
|
|||
|
|
manifest 记录 ciphertextHash(密文文件哈希)+ plaintextHash(解密明文哈希)
|
|||
|
|
双层校验 + 算法/版本标记;密钥缺失/错误/篡改由 verify_archive 显式报告。
|
|||
|
|
密封后归档文件置只读(模拟 WORM 介质不可覆写);清单登记为不可变承诺,
|
|||
|
|
此后 archive_old 对同月文件不再追加(自动改用 <month>.<n>.jsonl 新文件)。
|
|||
|
|
"""
|
|||
|
|
from server.agent_core.audit_merkle import merkle_root # 惰性导入(避免环)
|
|||
|
|
manifest = self._read_manifest() or {
|
|||
|
|
"mode": "worm-simulated",
|
|||
|
|
"tenant": self.tenant_uuid,
|
|||
|
|
"worldKey": self.world_key,
|
|||
|
|
"ledger": self.path.name,
|
|||
|
|
"note": WORM_NOTE,
|
|||
|
|
}
|
|||
|
|
files = manifest.setdefault("files", {})
|
|||
|
|
plaintext_bytes = archive_path.read_bytes() # archive_old 已写 JSONL
|
|||
|
|
if key_material is not None:
|
|||
|
|
envelope = _encrypt_bytes(plaintext_bytes, key_material)
|
|||
|
|
_atomic_write(archive_path, json.dumps(
|
|||
|
|
envelope, ensure_ascii=False, sort_keys=True).encode("utf-8"))
|
|||
|
|
ciphertext_sha = _file_sha256(archive_path)
|
|||
|
|
plaintext_sha = envelope["plaintextHash"]
|
|||
|
|
encryption_meta = {
|
|||
|
|
"enabled": True,
|
|||
|
|
"format": envelope["format"],
|
|||
|
|
"scheme": envelope["scheme"],
|
|||
|
|
"kdf": envelope["kdf"],
|
|||
|
|
"salt": envelope["salt"],
|
|||
|
|
"iv": envelope["iv"],
|
|||
|
|
"ciphertextHash": ciphertext_sha,
|
|||
|
|
"plaintextHash": plaintext_sha,
|
|||
|
|
}
|
|||
|
|
else:
|
|||
|
|
ciphertext_sha = plaintext_sha = _file_sha256(archive_path)
|
|||
|
|
encryption_meta = {"enabled": False, "format": None, "scheme": None}
|
|||
|
|
root = merkle_root(records)
|
|||
|
|
ats = [r.get("at") or "" for r in records]
|
|||
|
|
ids = [str(r.get("eventId") or "") for r in records if r.get("eventId")]
|
|||
|
|
try:
|
|||
|
|
os.chmod(archive_path, 0o444) # 只读位(Windows/类 Unix)
|
|||
|
|
readonly = True
|
|||
|
|
except OSError:
|
|||
|
|
readonly = False
|
|||
|
|
entry = {
|
|||
|
|
"sha256": ciphertext_sha,
|
|||
|
|
"ciphertextHash": ciphertext_sha,
|
|||
|
|
"plaintextHash": plaintext_sha,
|
|||
|
|
"merkleRoot": root,
|
|||
|
|
"archivedAt": archived_at,
|
|||
|
|
"coverage": {
|
|||
|
|
"firstAt": ats[0] if ats else None,
|
|||
|
|
"lastAt": ats[-1] if ats else None,
|
|||
|
|
"firstEventId": ids[0] if ids else None,
|
|||
|
|
"lastEventId": ids[-1] if ids else None,
|
|||
|
|
"records": len(records),
|
|||
|
|
},
|
|||
|
|
"readonly": readonly,
|
|||
|
|
"encryption": encryption_meta,
|
|||
|
|
}
|
|||
|
|
files[archive_path.name] = entry
|
|||
|
|
manifest["updatedAt"] = archived_at
|
|||
|
|
self._write_manifest(manifest)
|
|||
|
|
return entry
|
|||
|
|
|
|||
|
|
def _read_jsonl(self, path: Path) -> list[dict[str, Any]]:
|
|||
|
|
"""读取 JSONL 记录(损坏行标注 _corrupt,不中断;供归档 merkle 复算)。
|
|||
|
|
|
|||
|
|
仅用于明文归档;加密归档由 verify_archive 先解密再解析(_parse_jsonl)。
|
|||
|
|
"""
|
|||
|
|
if not path.exists():
|
|||
|
|
return []
|
|||
|
|
try:
|
|||
|
|
text = path.read_text(encoding="utf-8")
|
|||
|
|
except OSError:
|
|||
|
|
return []
|
|||
|
|
return _parse_jsonl(text)
|
|||
|
|
|
|||
|
|
def verify_archive(self, key: bytes | None = None) -> dict[str, Any]:
|
|||
|
|
"""WORM 归档完整性验证:逐文件密文哈希 + 解密 + 明文哈希 + merkle root 复算。
|
|||
|
|
|
|||
|
|
加密归档:ciphertextHash 与文件哈希不一致 → tampered(密文层篡改);
|
|||
|
|
解密(密钥缺失 → decryptFailed "encryption-key-missing";密钥错误/信封损坏 →
|
|||
|
|
decryptFailed "decryption-failed",均显式失败不静默);plaintextHash 不一致 →
|
|||
|
|
tampered(明文层篡改);merkle 复算不一致 → merkleMismatch(语义层异常)。
|
|||
|
|
明文归档行为不变(仅 sha256 + merkle 校验)。
|
|||
|
|
key: 可选密钥材料 bytes(32B 密钥或口令);缺省从 env/密钥文件解析。
|
|||
|
|
返回结构兼容网关 /api/gov/archive/verify(P0 只读)。
|
|||
|
|
"""
|
|||
|
|
manifest = self._read_manifest()
|
|||
|
|
key_material: bytes | None
|
|||
|
|
if key is not None:
|
|||
|
|
key_material = key
|
|||
|
|
else:
|
|||
|
|
material = _key_material()
|
|||
|
|
key_material = material[0] if material else None
|
|||
|
|
summary = {
|
|||
|
|
"files": 0, "verified": 0, "tampered": 0, "missing": 0,
|
|||
|
|
"merkleMismatch": 0, "unsealed": 0, "noManifest": manifest is None,
|
|||
|
|
"decryptFailed": 0,
|
|||
|
|
}
|
|||
|
|
tampered: list[dict[str, Any]] = []
|
|||
|
|
missing: list[dict[str, Any]] = []
|
|||
|
|
merkle_mismatch: list[dict[str, Any]] = []
|
|||
|
|
decrypt_failed: list[dict[str, Any]] = []
|
|||
|
|
unsealed: list[str] = []
|
|||
|
|
encrypted_files = 0
|
|||
|
|
archive_dir = self.archive_dir
|
|||
|
|
if archive_dir.exists():
|
|||
|
|
from server.agent_core.audit_merkle import merkle_root # 惰性导入(避免环)
|
|||
|
|
files = (manifest or {}).get("files") or {}
|
|||
|
|
for name, meta in files.items():
|
|||
|
|
summary["files"] += 1
|
|||
|
|
path = archive_dir / name
|
|||
|
|
if not path.exists():
|
|||
|
|
summary["missing"] += 1
|
|||
|
|
missing.append({"file": name, "expected": meta.get("sha256"),
|
|||
|
|
"reason": "missing"})
|
|||
|
|
continue
|
|||
|
|
actual = _file_sha256(path)
|
|||
|
|
enc = meta.get("encryption") or {}
|
|||
|
|
if enc.get("enabled"):
|
|||
|
|
encrypted_files += 1
|
|||
|
|
if actual != meta.get("ciphertextHash"):
|
|||
|
|
summary["tampered"] += 1
|
|||
|
|
tampered.append({"file": name,
|
|||
|
|
"expected": meta.get("ciphertextHash"),
|
|||
|
|
"actual": actual,
|
|||
|
|
"reason": "ciphertext-hash-mismatch"})
|
|||
|
|
continue
|
|||
|
|
if key_material is None:
|
|||
|
|
summary["decryptFailed"] += 1
|
|||
|
|
decrypt_failed.append({"file": name,
|
|||
|
|
"reason": "encryption-key-missing",
|
|||
|
|
"note": ENCRYPTION_NOTE})
|
|||
|
|
continue
|
|||
|
|
try:
|
|||
|
|
plaintext = _decrypt_bytes(_read_envelope(path), key_material)
|
|||
|
|
except ValueError as exc:
|
|||
|
|
summary["decryptFailed"] += 1
|
|||
|
|
decrypt_failed.append({"file": name, "reason": "decryption-failed",
|
|||
|
|
"detail": str(exc)})
|
|||
|
|
continue
|
|||
|
|
if hashlib.sha256(plaintext).hexdigest() != meta.get("plaintextHash"):
|
|||
|
|
summary["tampered"] += 1
|
|||
|
|
tampered.append({"file": name,
|
|||
|
|
"expected": meta.get("plaintextHash"),
|
|||
|
|
"actual": hashlib.sha256(plaintext).hexdigest(),
|
|||
|
|
"reason": "plaintext-hash-mismatch"})
|
|||
|
|
continue
|
|||
|
|
root = merkle_root(_parse_jsonl(plaintext.decode("utf-8")))
|
|||
|
|
if root != meta.get("merkleRoot"):
|
|||
|
|
summary["merkleMismatch"] += 1
|
|||
|
|
merkle_mismatch.append({"file": name,
|
|||
|
|
"expected": meta.get("merkleRoot"),
|
|||
|
|
"actual": root,
|
|||
|
|
"reason": "merkle-root-mismatch"})
|
|||
|
|
continue
|
|||
|
|
summary["verified"] += 1
|
|||
|
|
continue
|
|||
|
|
# 明文归档(既有路径)
|
|||
|
|
if actual != meta.get("sha256"):
|
|||
|
|
summary["tampered"] += 1
|
|||
|
|
tampered.append({"file": name, "expected": meta.get("sha256"),
|
|||
|
|
"actual": actual, "reason": "sha256-mismatch"})
|
|||
|
|
continue
|
|||
|
|
root = merkle_root(self._read_jsonl(path)) # 记录级语义校验
|
|||
|
|
if root != meta.get("merkleRoot"):
|
|||
|
|
summary["merkleMismatch"] += 1
|
|||
|
|
merkle_mismatch.append({"file": name,
|
|||
|
|
"expected": meta.get("merkleRoot"),
|
|||
|
|
"actual": root,
|
|||
|
|
"reason": "merkle-root-mismatch"})
|
|||
|
|
continue
|
|||
|
|
summary["verified"] += 1
|
|||
|
|
for pth in sorted(archive_dir.iterdir()): # 未登记文件显式提示
|
|||
|
|
if (pth.is_file() and not pth.name.startswith(".")
|
|||
|
|
and pth.name not in files):
|
|||
|
|
summary["unsealed"] += 1
|
|||
|
|
unsealed.append(pth.name)
|
|||
|
|
# 无清单且无文件 = 空态(无归档可验),完整性判为空真;有文件无清单则 files>0 → 不可验
|
|||
|
|
ok = (summary["tampered"] == 0 and summary["missing"] == 0
|
|||
|
|
and summary["merkleMismatch"] == 0 and summary["decryptFailed"] == 0
|
|||
|
|
and (manifest is not None or summary["files"] == 0))
|
|||
|
|
return {
|
|||
|
|
"mode": "worm-simulated",
|
|||
|
|
"ok": ok,
|
|||
|
|
"manifest": manifest,
|
|||
|
|
"summary": summary,
|
|||
|
|
"tampered": tampered,
|
|||
|
|
"missing": missing,
|
|||
|
|
"merkleMismatch": merkle_mismatch,
|
|||
|
|
"decryptFailed": decrypt_failed,
|
|||
|
|
"unsealed": unsealed,
|
|||
|
|
"encryption": {
|
|||
|
|
"enabled": encrypted_files > 0,
|
|||
|
|
"count": encrypted_files,
|
|||
|
|
"scheme": ENCRYPTION_SCHEME if encrypted_files else None,
|
|||
|
|
"note": ENCRYPTION_NOTE,
|
|||
|
|
},
|
|||
|
|
"note": WORM_NOTE,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def status(self, events: list[dict[str, Any]]) -> dict[str, Any]:
|
|||
|
|
"""当前锚定状态:最新记录 vs 当前事件根;无记录时返回待锚定根。"""
|
|||
|
|
records = self.read()
|
|||
|
|
current_root = audit_root(events)
|
|||
|
|
if not records:
|
|||
|
|
return {
|
|||
|
|
"anchored": False,
|
|||
|
|
"ok": False,
|
|||
|
|
"currentRoot": current_root,
|
|||
|
|
"currentCount": len(events),
|
|||
|
|
"records": 0,
|
|||
|
|
"latest": None,
|
|||
|
|
"reason": "not-anchored",
|
|||
|
|
}
|
|||
|
|
latest = records[-1]
|
|||
|
|
if latest.get("_corrupt"):
|
|||
|
|
return {
|
|||
|
|
"anchored": True,
|
|||
|
|
"ok": False,
|
|||
|
|
"currentRoot": current_root,
|
|||
|
|
"currentCount": len(events),
|
|||
|
|
"records": len(records),
|
|||
|
|
"latest": None,
|
|||
|
|
"reason": "corrupt-ledger",
|
|||
|
|
}
|
|||
|
|
match = latest.get("root") == current_root and latest.get("count") == len(events)
|
|||
|
|
return {
|
|||
|
|
"anchored": True,
|
|||
|
|
"ok": match,
|
|||
|
|
"currentRoot": current_root,
|
|||
|
|
"currentCount": len(events),
|
|||
|
|
"records": len(records),
|
|||
|
|
"latest": {
|
|||
|
|
"root": latest.get("root"),
|
|||
|
|
"count": latest.get("count"),
|
|||
|
|
"eventId": latest.get("eventId"),
|
|||
|
|
"at": latest.get("at"),
|
|||
|
|
},
|
|||
|
|
"reason": None if match else "mismatch",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def verify(self, events: list[dict[str, Any]]) -> dict[str, Any]:
|
|||
|
|
"""校验:锚定存在且最新记录与当前事件根一致(篡改检测)。"""
|
|||
|
|
return self.status(events)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _now_iso() -> str:
|
|||
|
|
from server.timeutil import fmt_dt
|
|||
|
|
from datetime import datetime
|
|||
|
|
return fmt_dt(datetime.now())
|