crypto.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. # -*- coding: utf-8 -*-
  2. # MinIO Python Library for Amazon S3 Compatible Cloud Storage, (C)
  3. # 2015, 2016, 2017 MinIO, Inc.
  4. #
  5. # Licensed under the Apache License, Version 2.0 (the "License");
  6. # you may not use this file except in compliance with the License.
  7. # You may obtain a copy of the License at
  8. #
  9. # http://www.apache.org/licenses/LICENSE-2.0
  10. #
  11. # Unless required by applicable law or agreed to in writing, software
  12. # distributed under the License is distributed on an "AS IS" BASIS,
  13. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  14. # See the License for the specific language governing permissions and
  15. # limitations under the License.
  16. """Cryptography to read and write encrypted MinIO Admin payload"""
  17. from __future__ import absolute_import, annotations
  18. import os
  19. from argon2.low_level import Type, hash_secret_raw
  20. from Crypto.Cipher import AES, ChaCha20_Poly1305
  21. from Crypto.Cipher._mode_gcm import GcmMode
  22. from Crypto.Cipher.ChaCha20_Poly1305 import ChaCha20Poly1305Cipher
  23. try:
  24. from urllib3.response import BaseHTTPResponse # type: ignore[attr-defined]
  25. except ImportError:
  26. from urllib3.response import HTTPResponse as BaseHTTPResponse
  27. #
  28. # Encrypted Message Format:
  29. #
  30. # | 41 bytes HEADER |
  31. # |-------------------------|
  32. # | 16 KiB encrypted chunk |
  33. # | + 16 bytes TAG |
  34. # |-------------------------|
  35. # | .... |
  36. # |-------------------------|
  37. # | ~16 KiB encrypted chunk |
  38. # | + 16 bytes TAG |
  39. # |-------------------------|
  40. #
  41. # HEADER:
  42. #
  43. # | 32 bytes salt |
  44. # |----------------|
  45. # | 1 byte AEAD ID |
  46. # |----------------|
  47. # | 8 bytes NONCE |
  48. # |----------------|
  49. #
  50. _TAG_LEN = 16
  51. _CHUNK_SIZE = 16 * 1024
  52. _MAX_CHUNK_SIZE = _TAG_LEN + _CHUNK_SIZE
  53. _SALT_LEN = 32
  54. _NONCE_LEN = 8
  55. def _get_cipher(
  56. aead_id: int,
  57. key: bytes,
  58. nonce: bytes,
  59. ) -> GcmMode | ChaCha20Poly1305Cipher:
  60. """Get cipher for AEAD ID."""
  61. if aead_id == 0:
  62. return AES.new(key, AES.MODE_GCM, nonce)
  63. if aead_id == 1:
  64. return ChaCha20_Poly1305.new(key=key, nonce=nonce)
  65. raise ValueError(f"Unknown AEAD ID {aead_id}")
  66. def _generate_key(secret: bytes, salt: bytes) -> bytes:
  67. """Generate 256-bit Argon2ID key"""
  68. return hash_secret_raw(
  69. secret=secret,
  70. salt=salt,
  71. time_cost=1,
  72. memory_cost=65536,
  73. parallelism=4,
  74. hash_len=32,
  75. type=Type.ID,
  76. version=19,
  77. )
  78. def _generate_additional_data(
  79. aead_id: int, key: bytes, padded_nonce: bytes
  80. ) -> bytes:
  81. """Generate additional data"""
  82. cipher = _get_cipher(aead_id, key, padded_nonce)
  83. return b"\x00" + cipher.digest()
  84. def _mark_as_last(additional_data: bytes) -> bytes:
  85. """Mark additional data as the last in the sequence"""
  86. return b'\x80' + additional_data[1:]
  87. def _update_nonce_id(nonce: bytes, idx: int) -> bytes:
  88. """Set nonce id (4 last bytes)"""
  89. return nonce + idx.to_bytes(4, byteorder="little")
  90. def encrypt(payload: bytes, password: str) -> bytes:
  91. """Encrypt given payload."""
  92. nonce = os.urandom(_NONCE_LEN)
  93. salt = os.urandom(_SALT_LEN)
  94. key = _generate_key(password.encode(), salt)
  95. aead_id = b"\x00"
  96. padded_nonce = nonce + b"\x00\x00\x00\x00"
  97. additional_data = _generate_additional_data(aead_id[0], key, padded_nonce)
  98. indices = range(0, len(payload), _CHUNK_SIZE)
  99. nonce_id = 0
  100. result = salt + aead_id + nonce
  101. for i in indices:
  102. nonce_id += 1
  103. if i == indices[-1]:
  104. additional_data = _mark_as_last(additional_data)
  105. padded_nonce = _update_nonce_id(nonce, nonce_id)
  106. cipher = _get_cipher(aead_id[0], key, padded_nonce)
  107. cipher.update(additional_data)
  108. encrypted_data, hmac_tag = cipher.encrypt_and_digest(
  109. payload[i:i+_CHUNK_SIZE],
  110. )
  111. result += encrypted_data
  112. result += hmac_tag
  113. return result
  114. class DecryptReader:
  115. """
  116. BufferedIOBase compatible reader represents decrypted data of MinioAdmin
  117. APIs.
  118. """
  119. def __init__(self, response: BaseHTTPResponse, secret: bytes):
  120. self._response = response
  121. self._secret = secret
  122. self._payload = None
  123. header = self._response.read(41)
  124. if len(header) != 41:
  125. raise IOError("insufficient data")
  126. self._salt = header[:32]
  127. self._aead_id = header[32]
  128. self._nonce = header[33:]
  129. self._key = _generate_key(self._secret, self._salt)
  130. padded_nonce = self._nonce + b"\x00\x00\x00\x00"
  131. self._additional_data = _generate_additional_data(
  132. self._aead_id, self._key, padded_nonce
  133. )
  134. self._chunk = b""
  135. self._count = 0
  136. self._is_closed = False
  137. def __enter__(self):
  138. return self
  139. def __exit__(self, exc_type, exc_value, exc_traceback):
  140. return self.close()
  141. def readable(self): # pylint: disable=no-self-use
  142. """Return this is readable."""
  143. return True
  144. def writeable(self): # pylint: disable=no-self-use
  145. """Return this is not writeable."""
  146. return False
  147. def close(self):
  148. """Close response and release network resources."""
  149. self._response.close()
  150. self._response.release_conn()
  151. def _decrypt(self, payload: bytes, last_chunk: bool = False) -> bytes:
  152. """Decrypt given payload."""
  153. self._count += 1
  154. if last_chunk:
  155. self._additional_data = _mark_as_last(self._additional_data)
  156. padded_nonce = _update_nonce_id(self._nonce, self._count)
  157. cipher = _get_cipher(self._aead_id, self._key, padded_nonce)
  158. cipher.update(self._additional_data)
  159. hmac_tag = payload[-_TAG_LEN:]
  160. encrypted_data = payload[:-_TAG_LEN]
  161. decrypted_data = cipher.decrypt_and_verify(encrypted_data, hmac_tag)
  162. return decrypted_data
  163. def _read_chunk(self) -> bool:
  164. """Read a chunk at least one byte more than chunk size."""
  165. if self._is_closed:
  166. return True
  167. while len(self._chunk) != (1 + _MAX_CHUNK_SIZE):
  168. chunk = self._response.read(1 + _MAX_CHUNK_SIZE - len(self._chunk))
  169. self._chunk += chunk
  170. if len(chunk) == 0:
  171. self._is_closed = True
  172. return True
  173. return False
  174. def _read(self) -> bytes:
  175. """Read and decrypt response."""
  176. stop = self._read_chunk()
  177. if len(self._chunk) == 0:
  178. return self._chunk
  179. length = _MAX_CHUNK_SIZE
  180. if len(self._chunk) < length:
  181. length = len(self._chunk)
  182. stop = True
  183. payload = self._chunk[:length]
  184. self._chunk = self._chunk[length:]
  185. return self._decrypt(payload, stop)
  186. def stream(self, num_bytes=32*1024):
  187. """
  188. Stream extracted payload from response data. Upon completion, caller
  189. should call self.close() to release network resources.
  190. """
  191. while True:
  192. data = self._read()
  193. if not data:
  194. break
  195. while data:
  196. result = data
  197. if num_bytes < len(data):
  198. result = data[:num_bytes]
  199. data = data[len(result):]
  200. yield result
  201. def decrypt(response: BaseHTTPResponse, secret_key: str) -> bytes:
  202. """Decrypt response data."""
  203. result = b""
  204. with DecryptReader(response, secret_key.encode()) as reader:
  205. for data in reader.stream():
  206. result += data
  207. return result