251 lines
7.3 KiB
Python
251 lines
7.3 KiB
Python
# -*- coding: utf-8 -*-
|
|
# MinIO Python Library for Amazon S3 Compatible Cloud Storage, (C)
|
|
# 2015, 2016, 2017 MinIO, Inc.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Cryptography to read and write encrypted MinIO Admin payload"""
|
|
|
|
from __future__ import absolute_import, annotations
|
|
|
|
import os
|
|
|
|
from argon2.low_level import Type, hash_secret_raw
|
|
from Crypto.Cipher import AES, ChaCha20_Poly1305
|
|
from Crypto.Cipher._mode_gcm import GcmMode
|
|
from Crypto.Cipher.ChaCha20_Poly1305 import ChaCha20Poly1305Cipher
|
|
|
|
try:
|
|
from urllib3.response import BaseHTTPResponse # type: ignore[attr-defined]
|
|
except ImportError:
|
|
from urllib3.response import HTTPResponse as BaseHTTPResponse
|
|
|
|
#
|
|
# Encrypted Message Format:
|
|
#
|
|
# | 41 bytes HEADER |
|
|
# |-------------------------|
|
|
# | 16 KiB encrypted chunk |
|
|
# | + 16 bytes TAG |
|
|
# |-------------------------|
|
|
# | .... |
|
|
# |-------------------------|
|
|
# | ~16 KiB encrypted chunk |
|
|
# | + 16 bytes TAG |
|
|
# |-------------------------|
|
|
#
|
|
# HEADER:
|
|
#
|
|
# | 32 bytes salt |
|
|
# |----------------|
|
|
# | 1 byte AEAD ID |
|
|
# |----------------|
|
|
# | 8 bytes NONCE |
|
|
# |----------------|
|
|
#
|
|
|
|
|
|
_TAG_LEN = 16
|
|
_CHUNK_SIZE = 16 * 1024
|
|
_MAX_CHUNK_SIZE = _TAG_LEN + _CHUNK_SIZE
|
|
_SALT_LEN = 32
|
|
_NONCE_LEN = 8
|
|
|
|
|
|
def _get_cipher(
|
|
aead_id: int,
|
|
key: bytes,
|
|
nonce: bytes,
|
|
) -> GcmMode | ChaCha20Poly1305Cipher:
|
|
"""Get cipher for AEAD ID."""
|
|
if aead_id == 0:
|
|
return AES.new(key, AES.MODE_GCM, nonce)
|
|
if aead_id == 1:
|
|
return ChaCha20_Poly1305.new(key=key, nonce=nonce)
|
|
raise ValueError("Unknown AEAD ID {aead_id}")
|
|
|
|
|
|
def _generate_key(secret: bytes, salt: bytes) -> bytes:
|
|
"""Generate 256-bit Argon2ID key"""
|
|
return hash_secret_raw(
|
|
secret=secret,
|
|
salt=salt,
|
|
time_cost=1,
|
|
memory_cost=65536,
|
|
parallelism=4,
|
|
hash_len=32,
|
|
type=Type.ID,
|
|
version=19,
|
|
)
|
|
|
|
|
|
def _generate_additional_data(
|
|
aead_id: int, key: bytes, padded_nonce: bytes
|
|
) -> bytes:
|
|
"""Generate additional data"""
|
|
cipher = _get_cipher(aead_id, key, padded_nonce)
|
|
return b"\x00" + cipher.digest()
|
|
|
|
|
|
def _mark_as_last(additional_data: bytes) -> bytes:
|
|
"""Mark additional data as the last in the sequence"""
|
|
return b'\x80' + additional_data[1:]
|
|
|
|
|
|
def _update_nonce_id(nonce: bytes, idx: int) -> bytes:
|
|
"""Set nonce id (4 last bytes)"""
|
|
return nonce + idx.to_bytes(4, byteorder="little")
|
|
|
|
|
|
def encrypt(payload: bytes, password: str) -> bytes:
|
|
"""Encrypt given payload."""
|
|
nonce = os.urandom(_NONCE_LEN)
|
|
salt = os.urandom(_SALT_LEN)
|
|
key = _generate_key(password.encode(), salt)
|
|
aead_id = b"\x00"
|
|
padded_nonce = nonce + b"\x00\x00\x00\x00"
|
|
additional_data = _generate_additional_data(aead_id[0], key, padded_nonce)
|
|
|
|
indices = range(0, len(payload), _CHUNK_SIZE)
|
|
nonce_id = 0
|
|
result = salt + aead_id + nonce
|
|
for i in indices:
|
|
nonce_id += 1
|
|
if i == indices[-1]:
|
|
additional_data = _mark_as_last(additional_data)
|
|
padded_nonce = _update_nonce_id(nonce, nonce_id)
|
|
cipher = _get_cipher(aead_id[0], key, padded_nonce)
|
|
cipher.update(additional_data)
|
|
encrypted_data, hmac_tag = cipher.encrypt_and_digest(
|
|
payload[i:i+_CHUNK_SIZE],
|
|
)
|
|
|
|
result += encrypted_data
|
|
result += hmac_tag
|
|
|
|
return result
|
|
|
|
|
|
class DecryptReader:
|
|
"""
|
|
BufferedIOBase compatible reader represents decrypted data of MinioAdmin
|
|
APIs.
|
|
"""
|
|
|
|
def __init__(self, response: BaseHTTPResponse, secret: bytes):
|
|
self._response = response
|
|
self._secret = secret
|
|
self._payload = None
|
|
|
|
header = self._response.read(41)
|
|
if len(header) != 41:
|
|
raise IOError("insufficient data")
|
|
self._salt = header[:32]
|
|
self._aead_id = header[32]
|
|
self._nonce = header[33:]
|
|
self._key = _generate_key(self._secret, self._salt)
|
|
padded_nonce = self._nonce + b"\x00\x00\x00\x00"
|
|
self._additional_data = _generate_additional_data(
|
|
self._aead_id, self._key, padded_nonce
|
|
)
|
|
self._chunk = b""
|
|
self._count = 0
|
|
self._is_closed = False
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_value, exc_traceback):
|
|
return self.close()
|
|
|
|
def readable(self): # pylint: disable=no-self-use
|
|
"""Return this is readable."""
|
|
return True
|
|
|
|
def writeable(self): # pylint: disable=no-self-use
|
|
"""Return this is not writeable."""
|
|
return False
|
|
|
|
def close(self):
|
|
"""Close response and release network resources."""
|
|
self._response.close()
|
|
self._response.release_conn()
|
|
|
|
def _decrypt(self, payload: bytes, last_chunk: bool = False) -> bytes:
|
|
"""Decrypt given payload."""
|
|
self._count += 1
|
|
if last_chunk:
|
|
self._additional_data = _mark_as_last(self._additional_data)
|
|
|
|
padded_nonce = _update_nonce_id(self._nonce, self._count)
|
|
cipher = _get_cipher(self._aead_id, self._key, padded_nonce)
|
|
cipher.update(self._additional_data)
|
|
|
|
hmac_tag = payload[-_TAG_LEN:]
|
|
encrypted_data = payload[:-_TAG_LEN]
|
|
decrypted_data = cipher.decrypt_and_verify(encrypted_data, hmac_tag)
|
|
return decrypted_data
|
|
|
|
def _read_chunk(self) -> bool:
|
|
"""Read a chunk at least one byte more than chunk size."""
|
|
if self._is_closed:
|
|
return True
|
|
|
|
while len(self._chunk) != (1 + _MAX_CHUNK_SIZE):
|
|
chunk = self._response.read(1 + _MAX_CHUNK_SIZE - len(self._chunk))
|
|
self._chunk += chunk
|
|
if len(chunk) == 0:
|
|
self._is_closed = True
|
|
return True
|
|
|
|
return False
|
|
|
|
def _read(self) -> bytes:
|
|
"""Read and decrypt response."""
|
|
stop = self._read_chunk()
|
|
|
|
if len(self._chunk) == 0:
|
|
return self._chunk
|
|
|
|
if len(self._chunk) < _MAX_CHUNK_SIZE:
|
|
return self._decrypt(self._chunk, True)
|
|
|
|
payload = self._chunk[:_MAX_CHUNK_SIZE]
|
|
self._chunk = self._chunk[_MAX_CHUNK_SIZE:]
|
|
return self._decrypt(payload, stop)
|
|
|
|
def stream(self, num_bytes=32*1024):
|
|
"""
|
|
Stream extracted payload from response data. Upon completion, caller
|
|
should call self.close() to release network resources.
|
|
"""
|
|
while True:
|
|
data = self._read()
|
|
while data:
|
|
result = data
|
|
if num_bytes < len(data):
|
|
result = data[:num_bytes]
|
|
data = data[len(result):]
|
|
yield result
|
|
else:
|
|
break
|
|
|
|
|
|
def decrypt(response: BaseHTTPResponse, secret_key: str) -> bytes:
|
|
"""Decrypt response data."""
|
|
result = b""
|
|
with DecryptReader(response, secret_key.encode()) as reader:
|
|
for data in reader.stream():
|
|
result += data
|
|
return result
|