246 lines
9.1 KiB
Python
246 lines
9.1 KiB
Python
"""
|
|
加密引擎模块 (Crypto Engine)
|
|
|
|
流式 AES-256-GCM 分块加密,基于 cryptography.cobblestone (C2SP chunked-encryption)。
|
|
每块 16KiB 独立 GCM 认证,流式处理不整块进内存,适合大文件。
|
|
|
|
流程对应模块图 (02-加密引擎):
|
|
B(取密钥) -> C(初始化流式上下文) -> D(逐块加密) -> F(输出参数 + 密文流)
|
|
|
|
格式说明:
|
|
cobblestone 输出流自带头部 + 分块结构 (magic/版本/随机 IV/密文+tag),
|
|
IV 由库内部管理,不需要也不应该手动维护 iv 列表。
|
|
解密时只需密钥 + context,finalize() 验证整体完整性,篡改即抛 InvalidTag。
|
|
"""
|
|
|
|
import base64
|
|
import io
|
|
import json
|
|
import os
|
|
import shutil
|
|
from pathlib import Path
|
|
from typing import IO, Any, Callable
|
|
|
|
from cryptography.cobblestone import Cobblestone256Decryptor, Cobblestone256Encryptor
|
|
from cryptography.exceptions import InvalidTag
|
|
|
|
# 应用绑定上下文: 防止密文被挪用到其他系统/版本
|
|
CONTEXT = b"7z-encrypt:v1"
|
|
|
|
# 文件名加密独立 context (与文件内容加密隔离, 防交叉)
|
|
NAME_CTX = CONTEXT + b":name"
|
|
|
|
# 默认密钥库位置 (系统级: 环境变量 SZ_KEYRING 优先, 否则 XDG 数据目录)
|
|
# 配置外置原则, 权限 600
|
|
def _default_keyring() -> Path:
|
|
env = os.environ.get("SZ_KEYRING")
|
|
if env:
|
|
return Path(env).expanduser()
|
|
xdg = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
|
|
return Path(xdg) / "7z-encrypt" / "keyring.json"
|
|
|
|
|
|
DEFAULT_KEYRING = _default_keyring()
|
|
|
|
# 旧位置 (v1: 项目目录 config/keyring.json) — 自动迁移到系统位置
|
|
LEGACY_KEYRING = Path(__file__).resolve().parent / "config" / "keyring.json"
|
|
|
|
|
|
class CryptoError(RuntimeError):
|
|
"""加密/解密过程错误"""
|
|
|
|
|
|
class KeyNotFoundError(CryptoError):
|
|
"""密钥 ID 不存在"""
|
|
|
|
|
|
class IntegrityError(CryptoError):
|
|
"""解密验证失败 (密钥错误或数据被篡改)"""
|
|
|
|
|
|
class CryptoEngine:
|
|
"""流式加密引擎, 密钥持久化到 keyring 文件"""
|
|
|
|
def __init__(self, keyring_path: str | Path | None = None) -> None:
|
|
self.keyring_path = Path(keyring_path) if keyring_path else DEFAULT_KEYRING
|
|
self._migrate_legacy_keyring()
|
|
self._keyring = self._load_keyring()
|
|
|
|
def _migrate_legacy_keyring(self) -> None:
|
|
"""旧位置密钥自动迁移到系统位置 (仅默认路径时, 避免密钥"丢失")"""
|
|
if self.keyring_path != DEFAULT_KEYRING:
|
|
return
|
|
if self.keyring_path.exists() or not LEGACY_KEYRING.exists():
|
|
return
|
|
self.keyring_path.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(LEGACY_KEYRING, self.keyring_path)
|
|
os.chmod(self.keyring_path, 0o600)
|
|
print(f"[密钥库] 旧密钥已迁移 -> {self.keyring_path}")
|
|
|
|
# ---------- 密钥库 ----------
|
|
|
|
def _load_keyring(self) -> dict[str, bytes]:
|
|
"""从 keyring 文件加载密钥 (hex -> bytes)"""
|
|
if not self.keyring_path.exists():
|
|
return {}
|
|
data = json.loads(self.keyring_path.read_text(encoding="utf-8"))
|
|
return {kid: bytes.fromhex(hex_str) for kid, hex_str in data.items()}
|
|
|
|
def _save_keyring(self) -> None:
|
|
"""持久化密钥库, 文件权限 600 (仅本人可读写)"""
|
|
self.keyring_path.parent.mkdir(parents=True, exist_ok=True)
|
|
data = {kid: key.hex() for kid, key in self._keyring.items()}
|
|
self.keyring_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
os.chmod(self.keyring_path, 0o600)
|
|
|
|
def get_key(self, key_id: str) -> bytes:
|
|
"""步骤 B: 取密钥。密钥不存在抛 KeyNotFoundError"""
|
|
try:
|
|
return self._keyring[key_id]
|
|
except KeyError:
|
|
raise KeyNotFoundError(f"密钥ID '{key_id}' 未找到 (keyring: {self.keyring_path})") from None
|
|
|
|
def generate_key(self, key_id: str) -> bytes:
|
|
"""生成新密钥 (os.urandom 32B) 并持久化。已存在则不覆盖, 抛错防误用"""
|
|
if key_id in self._keyring:
|
|
raise CryptoError(f"密钥ID '{key_id}' 已存在, 不覆盖 (需轮换请用 rotate_key)")
|
|
key = os.urandom(32)
|
|
self._keyring[key_id] = key
|
|
self._save_keyring()
|
|
return key
|
|
|
|
def rotate_key(self, key_id: str = "default_key") -> bytes:
|
|
"""强制轮换密钥: 删旧密钥 -> 生成新密钥。
|
|
|
|
危险: 旧密钥加密的密文将永久无法解密, 调用方必须确认!
|
|
"""
|
|
self._keyring.pop(key_id, None)
|
|
return self.generate_key(key_id)
|
|
|
|
# ---------- 文件名加密 (元数据零知识: 服务端看不到明文文件名) ----------
|
|
|
|
def encrypt_name(self, name: str, key_id: str = "default_key") -> str:
|
|
"""文件名加密 -> 'enc:' + URL-safe base64 密文 (cobblestone 独立 context)
|
|
|
|
URL-safe: 密文不含 / + =, 避免被服务端/路径逻辑误伤
|
|
"""
|
|
key = self.get_key(key_id)
|
|
enc = Cobblestone256Encryptor(key, NAME_CTX)
|
|
out = io.BytesIO()
|
|
out.write(enc.update(name.encode()))
|
|
out.write(enc.finalize())
|
|
return "enc:" + base64.urlsafe_b64encode(out.getvalue()).decode()
|
|
|
|
def decrypt_name(self, token: str, key_id: str = "default_key") -> str:
|
|
"""解密 'enc:' 前缀文件名, 失败抛 IntegrityError"""
|
|
raw = base64.urlsafe_b64decode(token[4:])
|
|
key = self.get_key(key_id)
|
|
try:
|
|
dec = Cobblestone256Decryptor(key, NAME_CTX)
|
|
out = io.BytesIO()
|
|
out.write(dec.update(raw))
|
|
out.write(dec.finalize())
|
|
return out.getvalue().decode()
|
|
except InvalidTag as e:
|
|
raise IntegrityError("文件名解密失败: 密钥不匹配或数据损坏") from e
|
|
|
|
# ---------- 加解密 ----------
|
|
|
|
def encrypt_stream(
|
|
self,
|
|
src: IO[bytes],
|
|
dst: IO[bytes],
|
|
key_id: str,
|
|
context: bytes = CONTEXT,
|
|
progress: Callable[[int], Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""步骤 A & F: 流式加密。src 读明文 -> dst 写密文, 返回加密参数
|
|
|
|
Args:
|
|
src: 可读二进制流 (文件/BytesIO)
|
|
dst: 可写二进制流
|
|
key_id: 密钥标识
|
|
context: 应用绑定上下文 (默认 7z-encrypt:v1)
|
|
progress: 进度回调, 每读块后调 progress(已读字节)
|
|
"""
|
|
key = self.get_key(key_id)
|
|
enc = Cobblestone256Encryptor(key, context)
|
|
done = 0
|
|
while True:
|
|
chunk = src.read(1 << 16) # 64KiB 读块
|
|
if not chunk:
|
|
break
|
|
dst.write(enc.update(chunk))
|
|
done += len(chunk)
|
|
if progress is not None:
|
|
progress(done)
|
|
dst.write(enc.finalize())
|
|
return {"alg": "cobblestone-aes256gcm", "key_id": key_id, "context": context.decode()}
|
|
|
|
def decrypt_stream(
|
|
self,
|
|
src: IO[bytes],
|
|
dst: IO[bytes],
|
|
key_id: str,
|
|
context: bytes = CONTEXT,
|
|
progress: Callable[[int], Any] | None = None,
|
|
) -> None:
|
|
"""解密流 (进度回调: 每读块后调 progress(已读字节))"""
|
|
key = self.get_key(key_id)
|
|
try:
|
|
dec = Cobblestone256Decryptor(key, context)
|
|
done = 0
|
|
while True:
|
|
chunk = src.read(1 << 16)
|
|
if not chunk:
|
|
break
|
|
dst.write(dec.update(chunk)) # 块级认证: 篡改在这里就抛 InvalidTag
|
|
done += len(chunk)
|
|
if progress is not None:
|
|
progress(done)
|
|
dst.write(dec.finalize()) # finalize 返回剩余明文
|
|
except InvalidTag as e:
|
|
raise IntegrityError("解密验证失败: 密钥不匹配或密文被篡改") from e
|
|
|
|
|
|
# ================= 使用示例 (Usage Example) =================
|
|
if __name__ == "__main__":
|
|
import io
|
|
|
|
engine = CryptoEngine()
|
|
|
|
# 1. 确保密钥存在 (首次运行生成并持久化)
|
|
try:
|
|
engine.get_key("default_key")
|
|
except KeyNotFoundError:
|
|
engine.generate_key("default_key")
|
|
print("[密钥库] 已生成 default_key ->", engine.keyring_path)
|
|
|
|
# 2. 模拟流式加密 (大文件场景: 分块读入)
|
|
plain = (b"This is a streaming encryption test. " * 10000) # ~330KB
|
|
src = io.BytesIO(plain)
|
|
dst = io.BytesIO()
|
|
params = engine.encrypt_stream(src, dst, key_id="default_key")
|
|
|
|
print("=== 加密输出 (JSON) ===")
|
|
print(json.dumps(params, indent=2))
|
|
print(f"明文 {len(plain)}B -> 密文 {len(dst.getvalue())}B (含头部+分块tag开销)")
|
|
|
|
# 3. 流式解密还原
|
|
out = io.BytesIO()
|
|
dst.seek(0)
|
|
engine.decrypt_stream(dst, out, key_id="default_key")
|
|
restored = out.getvalue()
|
|
assert restored == plain, "解密结果与明文不一致!"
|
|
print("解密还原: 一致 ✓")
|
|
|
|
# 4. 篡改检测: 改密文一个字节, finalize 必须抛 IntegrityError
|
|
tampered = bytearray(dst.getvalue())
|
|
tampered[len(tampered) // 2] ^= 0xFF
|
|
try:
|
|
out2 = io.BytesIO()
|
|
engine.decrypt_stream(io.BytesIO(bytes(tampered)), out2, key_id="default_key")
|
|
print("篡改检测: 未检出! (异常)")
|
|
except IntegrityError:
|
|
print("篡改检测: 捕获 IntegrityError ✓")
|