"""crypto.py —— Fernet 对称加密封装(模版/附件密文落盘,明文纪律)。 - encrypt_bytes / decrypt_bytes:bytes 进 bytes 出 - dump_encrypted / load_encrypted:.enc 原子落盘(临时文件 + os.replace) - secure_delete:3 遍覆写(0x00/0xFF/0xAA)+ fsync 后删除,尽力而为 """ from __future__ import annotations import os from pathlib import Path from cryptography.fernet import Fernet, InvalidToken from .settings import get_fernet_key class CryptoError(Exception): """加解密失败(密钥不匹配/数据损坏/输入为空)。""" def _cipher(key: bytes | None = None) -> Fernet: return Fernet(key if key is not None else get_fernet_key()) def encrypt_bytes(data: bytes, key: bytes | None = None) -> bytes: """明文 bytes → Fernet token(URL-safe base64,可直接落盘 .enc)。""" if not data: raise CryptoError("加密内容为空") try: return _cipher(key).encrypt(data) except (ValueError, TypeError) as exc: raise CryptoError(f"加密失败:{exc}") from exc def decrypt_bytes(token: bytes, key: bytes | None = None) -> bytes: """Fernet token → 明文 bytes。密钥不符/数据损坏抛 CryptoError。""" if not token: raise CryptoError("密文为空") try: return _cipher(key).decrypt(token) except InvalidToken as exc: raise CryptoError("解密失败:密钥不匹配或数据已损坏") from exc def dump_encrypted(path: str | Path, data: bytes, key: bytes | None = None) -> Path: """密文落盘(.enc)。原子写:先临时文件再 os.replace。""" target = Path(path) target.parent.mkdir(parents=True, exist_ok=True) token = encrypt_bytes(data, key=key) tmp = target.with_name(target.name + ".tmp") tmp.write_bytes(token) os.replace(tmp, target) return target def load_encrypted(path: str | Path, key: bytes | None = None) -> bytes: """读取 .enc 并解密为明文 bytes。""" target = Path(path) if not target.exists(): raise CryptoError(f"密文不存在:{target}") return decrypt_bytes(target.read_bytes(), key=key) def secure_delete(path: str | Path) -> None: """尽力安全删除:3 遍覆写 + fsync 后删除。 说明:对 SSD 无法保证物理擦除,但可防常规恢复;失败时仍尽力 unlink。 """ target = Path(path) if not target.exists(): return try: size = target.stat().st_size if size > 0: with open(target, "r+b") as f: for pattern in (b"\x00", b"\xff", b"\xaa"): f.seek(0) f.write(pattern * size) f.flush() os.fsync(f.fileno()) target.unlink() except OSError: # 覆写失败也绝不残留明文(本路径本就只存密文) try: target.unlink() except OSError: pass