aps-agent/server/agent_core/audit_ledger.py

644 lines
30 KiB
Python
Raw Normal View History

# ============================================================
# 审计独立介质锚定 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())