test_envelope.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. """契约1.21 信封加密(KEK/DEK,AES-256-GCM)单元测试。
  2. 覆盖:加解密往返、DMSE 识别、明文兼容、promote 门控(original/ 前缀
  3. 与 KEK 配置双条件)、篡改检测、缺 KEK 拒绝读取、KEK 配置解析校验。
  4. """
  5. from __future__ import annotations
  6. import base64
  7. import os
  8. from pathlib import Path
  9. import pytest
  10. from flask import Flask
  11. from dms.common.errors import InternalError
  12. from dms.storage import envelope
  13. @pytest.fixture()
  14. def kek() -> bytes:
  15. return os.urandom(32)
  16. @pytest.fixture()
  17. def crypto_app(kek):
  18. app = Flask("dms-envelope-test")
  19. app.config["DMS_STORAGE_KEK_BYTES"] = kek
  20. return app
  21. @pytest.fixture()
  22. def plain_app():
  23. app = Flask("dms-envelope-plain-test")
  24. app.config["DMS_STORAGE_KEK_BYTES"] = None
  25. return app
  26. class TestLoadKek:
  27. def test_empty_returns_none(self):
  28. assert envelope.load_kek("") is None
  29. assert envelope.load_kek(" ") is None
  30. def test_valid_base64_32_bytes(self):
  31. raw = os.urandom(32)
  32. assert envelope.load_kek(base64.b64encode(raw).decode()) == raw
  33. def test_invalid_base64_rejected(self):
  34. with pytest.raises(ValueError, match="DMS_STORAGE_KEK"):
  35. envelope.load_kek("!!not-base64!!")
  36. def test_wrong_length_rejected(self):
  37. with pytest.raises(ValueError, match="DMS_STORAGE_KEK"):
  38. envelope.load_kek(base64.b64encode(os.urandom(16)).decode())
  39. class TestEncryptDecryptRoundTrip:
  40. def test_round_trip(self, crypto_app, kek, tmp_path: Path):
  41. plaintext = os.urandom(4096)
  42. source = tmp_path / "plain.bin"
  43. source.write_bytes(plaintext)
  44. target = tmp_path / "encrypted.bin"
  45. envelope.encrypt_file(kek, source, target)
  46. payload = target.read_bytes()
  47. assert payload[:4] == envelope.ENVELOPE_MAGIC
  48. assert payload[4] == envelope.KEK_VERSION
  49. assert envelope.decrypt_bytes(kek, payload) == plaintext
  50. def test_each_file_uses_distinct_dek(self, crypto_app, kek, tmp_path: Path):
  51. source = tmp_path / "plain.bin"
  52. source.write_bytes(b"same content")
  53. first = tmp_path / "a.bin"
  54. second = tmp_path / "b.bin"
  55. envelope.encrypt_file(kek, source, first)
  56. envelope.encrypt_file(kek, source, second)
  57. # DEK/nonce 随机,同明文两次加密产物必须不同
  58. assert first.read_bytes() != second.read_bytes()
  59. def test_wrong_kek_rejected(self, crypto_app, kek, tmp_path: Path):
  60. source = tmp_path / "plain.bin"
  61. source.write_bytes(b"secret")
  62. target = tmp_path / "encrypted.bin"
  63. envelope.encrypt_file(kek, source, target)
  64. with pytest.raises(InternalError, match="解密失败"):
  65. envelope.decrypt_bytes(os.urandom(32), target.read_bytes())
  66. def test_tampered_ciphertext_rejected(self, crypto_app, kek, tmp_path: Path):
  67. source = tmp_path / "plain.bin"
  68. source.write_bytes(b"integrity check")
  69. target = tmp_path / "encrypted.bin"
  70. envelope.encrypt_file(kek, source, target)
  71. payload = bytearray(target.read_bytes())
  72. payload[-1] ^= 0xFF
  73. with pytest.raises(InternalError, match="解密失败"):
  74. envelope.decrypt_bytes(kek, bytes(payload))
  75. def test_truncated_payload_rejected(self, crypto_app, kek):
  76. with pytest.raises(InternalError, match="解密失败"):
  77. envelope.decrypt_bytes(kek, b"DMSE")
  78. with pytest.raises(InternalError, match="解密失败"):
  79. envelope.decrypt_bytes(kek, b"NOPE" + b"\x00" * 64)
  80. class TestIsEnvelope:
  81. def test_recognizes_envelope(self, crypto_app, kek, tmp_path: Path):
  82. source = tmp_path / "plain.bin"
  83. source.write_bytes(b"data")
  84. target = tmp_path / "encrypted.bin"
  85. envelope.encrypt_file(kek, source, target)
  86. assert envelope.is_envelope(target) is True
  87. def test_plain_file_not_envelope(self, tmp_path: Path):
  88. path = tmp_path / "plain.pdf"
  89. path.write_bytes(b"%PDF-1.7 body")
  90. assert envelope.is_envelope(path) is False
  91. def test_missing_file_not_envelope(self, tmp_path: Path):
  92. assert envelope.is_envelope(tmp_path / "missing.bin") is False
  93. class TestReadPlaintextBytes:
  94. def test_plain_passthrough(self, plain_app, tmp_path: Path):
  95. path = tmp_path / "plain.pdf"
  96. content = b"%PDF-1.7 plain content"
  97. path.write_bytes(content)
  98. with plain_app.app_context():
  99. assert envelope.read_plaintext_bytes(path) == content
  100. def test_envelope_decrypted(self, crypto_app, kek, tmp_path: Path):
  101. plaintext = b"confidential bytes"
  102. source = tmp_path / "plain.bin"
  103. source.write_bytes(plaintext)
  104. target = tmp_path / "encrypted.bin"
  105. envelope.encrypt_file(kek, source, target)
  106. with crypto_app.app_context():
  107. assert envelope.read_plaintext_bytes(target) == plaintext
  108. def test_envelope_without_kek_rejected(self, plain_app, kek, tmp_path: Path):
  109. source = tmp_path / "plain.bin"
  110. source.write_bytes(b"encrypted at rest")
  111. target = tmp_path / "encrypted.bin"
  112. envelope.encrypt_file(kek, source, target)
  113. with plain_app.app_context():
  114. with pytest.raises(InternalError, match="未配置"):
  115. envelope.read_plaintext_bytes(target)
  116. class TestPlaintextFileFor:
  117. def test_plain_returns_same_path(self, plain_app, tmp_path: Path):
  118. path = tmp_path / "plain.pdf"
  119. path.write_bytes(b"%PDF-1.7 body")
  120. with plain_app.app_context():
  121. with envelope.plaintext_file_for(path) as yielded:
  122. assert yielded == path
  123. assert path.exists()
  124. def test_envelope_yields_temp_and_cleans_up(
  125. self, crypto_app, kek, tmp_path: Path
  126. ):
  127. plaintext = b"decrypt me"
  128. source = tmp_path / "plain.bin"
  129. source.write_bytes(plaintext)
  130. target = tmp_path / "encrypted.bin"
  131. envelope.encrypt_file(kek, source, target)
  132. with crypto_app.app_context():
  133. with envelope.plaintext_file_for(target) as yielded:
  134. assert yielded != target
  135. assert yielded.read_bytes() == plaintext
  136. assert not yielded.exists()
  137. # 密文本体不受临时明文清理影响
  138. assert target.exists()
  139. class TestPromoteEncrypt:
  140. def test_original_prefix_encrypted(self, crypto_app, tmp_path: Path):
  141. temporary = tmp_path / "staged.upload"
  142. temporary.write_bytes(b"new upload")
  143. final = tmp_path / "final.bin"
  144. with crypto_app.app_context():
  145. assert envelope.promote_encrypt(
  146. temporary, final, "original/final.bin"
  147. ) is True
  148. assert final.read_bytes()[:4] == envelope.ENVELOPE_MAGIC
  149. assert not temporary.exists()
  150. def test_non_original_prefix_skipped(self, crypto_app, tmp_path: Path):
  151. temporary = tmp_path / "staged.upload"
  152. temporary.write_bytes(b"voucher pdf bytes")
  153. final = tmp_path / "final.pdf"
  154. with crypto_app.app_context():
  155. assert envelope.promote_encrypt(
  156. temporary, final, "voucher/final.pdf"
  157. ) is False
  158. assert not final.exists()
  159. assert temporary.exists()
  160. def test_no_kek_skipped(self, plain_app, tmp_path: Path):
  161. temporary = tmp_path / "staged.upload"
  162. temporary.write_bytes(b"plain stays plain")
  163. final = tmp_path / "final.bin"
  164. with plain_app.app_context():
  165. assert envelope.promote_encrypt(
  166. temporary, final, "original/final.bin"
  167. ) is False
  168. assert not final.exists()
  169. assert temporary.exists()