envelope.py 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. """应用层信封加密(KEK/DEK,AES-256-GCM)。
  2. 契约 1.21:配置 DMS_STORAGE_KEK(base64 编码的 32 字节密钥)后,
  3. original/ 新落盘文件按以下格式加密存储:
  4. DMSE + kekVersion(1B) + 包裹DEK长度(4B, 大端) + KEK包裹的DEK + 密文
  5. 其中:
  6. - "KEK包裹的DEK" = 包裹nonce(12B) + AES-256-GCM(KEK) 加密的 DEK 密文(含认证标签)
  7. - "密文" = 数据nonce(12B) + AES-256-GCM(DEK) 加密的文件明文(含认证标签)
  8. - DEK 每文件随机 32 字节,nonce 均 12 字节
  9. 读路径按 DMSE 头自动识别密文/明文实现明文兼容;
  10. 未配置 KEK 时维持明文落盘,存量文件不回迁加密。
  11. """
  12. from __future__ import annotations
  13. import base64
  14. import os
  15. import struct
  16. import tempfile
  17. from contextlib import contextmanager
  18. from pathlib import Path
  19. from typing import Iterator
  20. from cryptography.exceptions import InvalidTag
  21. from cryptography.hazmat.primitives.ciphers.aead import AESGCM
  22. from flask import current_app
  23. from dms.common.errors import InternalError
  24. ENVELOPE_MAGIC = b"DMSE"
  25. KEK_VERSION = 1
  26. _KEK_LENGTH = 32
  27. _DEK_LENGTH = 32
  28. _NONCE_LENGTH = 12
  29. _GCM_TAG_LENGTH = 16
  30. # 包裹 DEK 长度防御性上限(正常为 12+32+16=60 字节)
  31. _WRAPPED_DEK_MAX = 1024
  32. def load_kek(raw_value: str) -> bytes | None:
  33. """解析并校验 DMS_STORAGE_KEK:base64 编码的 32 字节密钥;空值返回 None。"""
  34. value = raw_value.strip()
  35. if not value:
  36. return None
  37. try:
  38. kek = base64.b64decode(value, validate=True)
  39. except ValueError as exc:
  40. raise ValueError("DMS_STORAGE_KEK必须是base64编码的32字节密钥") from exc
  41. if len(kek) != _KEK_LENGTH:
  42. raise ValueError("DMS_STORAGE_KEK必须是base64编码的32字节密钥")
  43. return kek
  44. def _current_kek() -> bytes | None:
  45. return current_app.config.get("DMS_STORAGE_KEK_BYTES")
  46. def _require_kek() -> bytes:
  47. kek = _current_kek()
  48. if kek is None:
  49. raise InternalError("存储加密密钥未配置,无法读取加密文件")
  50. return kek
  51. def is_envelope(path: Path) -> bool:
  52. """按 DMSE 魔数识别信封加密文件。"""
  53. try:
  54. with path.open("rb") as stream:
  55. return stream.read(len(ENVELOPE_MAGIC)) == ENVELOPE_MAGIC
  56. except OSError:
  57. return False
  58. def encrypt_file(kek: bytes, source: Path, target: Path) -> None:
  59. """将明文文件加密为信封格式落盘。"""
  60. plaintext = source.read_bytes()
  61. dek = os.urandom(_DEK_LENGTH)
  62. wrap_nonce = os.urandom(_NONCE_LENGTH)
  63. data_nonce = os.urandom(_NONCE_LENGTH)
  64. wrapped_dek = wrap_nonce + AESGCM(kek).encrypt(wrap_nonce, dek, None)
  65. ciphertext = data_nonce + AESGCM(dek).encrypt(data_nonce, plaintext, None)
  66. header = (
  67. ENVELOPE_MAGIC
  68. + bytes([KEK_VERSION])
  69. + struct.pack(">I", len(wrapped_dek))
  70. + wrapped_dek
  71. )
  72. target.write_bytes(header + ciphertext)
  73. def decrypt_bytes(kek: bytes, payload: bytes) -> bytes:
  74. """解迷信封格式字节串,返回明文;格式损坏或认证失败统一报内部错误。"""
  75. try:
  76. if len(payload) < len(ENVELOPE_MAGIC) + 1 + 4:
  77. raise ValueError("envelope too short")
  78. if payload[: len(ENVELOPE_MAGIC)] != ENVELOPE_MAGIC:
  79. raise ValueError("bad magic")
  80. if payload[4] != KEK_VERSION:
  81. raise ValueError("unsupported kek version")
  82. (wrapped_length,) = struct.unpack(">I", payload[5:9])
  83. if not _NONCE_LENGTH < wrapped_length <= _WRAPPED_DEK_MAX:
  84. raise ValueError("bad wrapped dek length")
  85. offset = 9
  86. wrapped = payload[offset : offset + wrapped_length]
  87. offset += wrapped_length
  88. ciphertext = payload[offset:]
  89. if len(ciphertext) < _NONCE_LENGTH + _GCM_TAG_LENGTH:
  90. raise ValueError("ciphertext too short")
  91. wrap_nonce, wrapped_dek = (
  92. wrapped[:_NONCE_LENGTH],
  93. wrapped[_NONCE_LENGTH:],
  94. )
  95. dek = AESGCM(kek).decrypt(wrap_nonce, wrapped_dek, None)
  96. data_nonce, data = (
  97. ciphertext[:_NONCE_LENGTH],
  98. ciphertext[_NONCE_LENGTH:],
  99. )
  100. return AESGCM(dek).decrypt(data_nonce, data, None)
  101. except (ValueError, InvalidTag) as exc:
  102. raise InternalError("文档文件解密失败或已损坏") from exc
  103. def read_plaintext_bytes(path: Path) -> bytes:
  104. """读取文件明文:DMSE 头密文自动解密,否则按明文原样返回。"""
  105. payload = path.read_bytes()
  106. if not payload.startswith(ENVELOPE_MAGIC):
  107. return payload
  108. return decrypt_bytes(_require_kek(), payload)
  109. @contextmanager
  110. def plaintext_file_for(path: Path) -> Iterator[Path]:
  111. """提供明文文件路径:明文原样返回;密文解密至临时文件,退出后清理。"""
  112. if not is_envelope(path):
  113. yield path
  114. return
  115. plaintext = read_plaintext_bytes(path)
  116. handle = tempfile.NamedTemporaryFile(delete=False)
  117. try:
  118. handle.write(plaintext)
  119. handle.close()
  120. yield Path(handle.name)
  121. finally:
  122. Path(handle.name).unlink(missing_ok=True)
  123. def promote_encrypt(temporary: Path, final: Path, relative_path: str) -> bool:
  124. """promote 门控:original/ 且已配置 KEK 时加密落盘并清理暂存,返回 True。"""
  125. kek = _current_kek()
  126. if kek is None or not relative_path.startswith("original/"):
  127. return False
  128. encrypt_file(kek, temporary, final)
  129. temporary.unlink(missing_ok=True)
  130. return True