This commit implements a MinIO specific request encryption/decryption logic. It is not documented, nor listed anywhere except the oficially supported SDKs (Python, GO) use these. Therefore, the logic is reverse engineered from the existing SDKs (https://github.com/minio/minio-py, https://github.com/minio/madmin-go/tree/main) Figuring out request body encrypt/decrypt operation by reading other source code without proper documentation was painful. So, here is my understanding on how they perform encryption. The decryption is simply doing the same thing but decrypting instead of encrypting. 1. First 32 bytes of a request body is SALT. That is a set of random bytes to use together with the secret key to encrypt the data. 2. Next 8 bytes of a request body is NONCE. These are again random bytes used to encrypt the request body and extra bytes from NONCE because NONCE should be 12 bytes in Argon but we use only 8 for MinIO. 3. Next bytes until the last 16th byte is the request body in encrypted form using AesGcmCipherProvider 4. Last 16 bytes are what is called as hmac_tag that is generated by the full length of nonce (12 bytes) in encrypted form. This commit comes with 2 API implementation that makes use of both encrypt and decrypt functionality; 1. admin_list_users: lists users and some of their properties. The response is sent in encrypted form from the server, therefore, we decrypt it using the credentials and the algorith explained above. 2. admin_add_user: adds a new user to the system with access_key and secret_key. Since keys must not leak outside of the system, the request body is encrypted using the client user secret_key and the algorithm explained above.
108 lines
3.0 KiB
Ruby
108 lines
3.0 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "openssl"
|
|
require "securerandom"
|
|
require "argon2/kdf"
|
|
|
|
NONCE_LEN = 8
|
|
SALT_LEN = 32
|
|
|
|
class Minio::Crypto
|
|
class AesGcmCipherProvider
|
|
def self.get_cipher(key, nonce, encrypt: false)
|
|
OpenSSL::Cipher.new("aes-256-gcm").tap do |cipher|
|
|
if encrypt
|
|
cipher.encrypt
|
|
else
|
|
cipher.decrypt
|
|
end
|
|
cipher.key = key
|
|
cipher.iv = nonce
|
|
end
|
|
end
|
|
end
|
|
|
|
def encrypt(payload, password)
|
|
# Generate nonce and salt
|
|
nonce = SecureRandom.random_bytes(NONCE_LEN)
|
|
salt = SecureRandom.random_bytes(SALT_LEN)
|
|
|
|
# Generate the key using Argon2
|
|
key = generate_key(password, salt)
|
|
|
|
# Prepare the padded nonce
|
|
padded_nonce = Array.new(NONCE_LEN + 4, 0)
|
|
nonce.bytes.each_with_index { |byte, index| padded_nonce[index] = byte }
|
|
|
|
# Select cipher provider and create cipher
|
|
cipher_provider = AesGcmCipherProvider
|
|
|
|
# Generate additional data
|
|
add_data = generate_additional_data(cipher_provider, key, padded_nonce.pack("C*"))
|
|
|
|
padded_nonce[8] = 1
|
|
cipher = cipher_provider.get_cipher(key, padded_nonce.pack("C*"), encrypt: true)
|
|
cipher.auth_data = add_data
|
|
|
|
# Encrypt the payload
|
|
encrypted_data = cipher.update(payload) + cipher.final
|
|
mac = cipher.auth_tag
|
|
|
|
# Construct the final encrypted payload
|
|
salt + [0].pack("C") + nonce + encrypted_data + mac
|
|
end
|
|
|
|
def decrypt(payload, password)
|
|
pos = 0
|
|
salt = payload.byteslice(pos, SALT_LEN)
|
|
pos += SALT_LEN
|
|
|
|
cipher_id = payload.byteslice(pos).ord
|
|
pos += 1
|
|
|
|
raise "Unsupported cipher ID: #{cipher_id}" unless cipher_id.zero?
|
|
cipher_provider = AesGcmCipherProvider
|
|
|
|
nonce = payload.byteslice(pos, NONCE_LEN)
|
|
|
|
pos += NONCE_LEN
|
|
|
|
encrypted_data = payload.byteslice(pos...-16)
|
|
hmac_tag = payload.byteslice(-16, 16)
|
|
|
|
key = generate_key(password, salt)
|
|
|
|
# looks like NONCE should have 12 bytes but MinIO uses 8 bytes
|
|
padded_nonce = Array.new(12, 0)
|
|
nonce.bytes.each_with_index { |byte, index| padded_nonce[index] = byte }
|
|
add_data = generate_additional_data(cipher_provider, key, padded_nonce.pack("C*"))
|
|
padded_nonce[8] = 1
|
|
|
|
cipher = cipher_provider.get_cipher(key, padded_nonce.pack("C*"))
|
|
cipher.auth_tag = hmac_tag
|
|
cipher.auth_data = add_data
|
|
|
|
cipher.update(encrypted_data) + cipher.final
|
|
end
|
|
|
|
def generate_key(password, salt)
|
|
Argon2::KDF.argon2id(password.encode, salt: salt, t: 1, m: 16, p: 4, length: 32)
|
|
end
|
|
|
|
def generate_additional_data(cipher_provider, key, padded_nonce)
|
|
# Initialize the cipher with the provided key and nonce
|
|
cipher = cipher_provider.get_cipher(key, padded_nonce, encrypt: true)
|
|
|
|
# In Ruby, for AES-GCM, the tag is generated after finalizing the encryption
|
|
# For this function, we'll perform a dummy encryption to generate the tag
|
|
cipher.auth_data = ""
|
|
cipher.update("") << cipher.final
|
|
|
|
# Construct the new tag array
|
|
new_tag = [0x80] + cipher.auth_tag.bytes
|
|
|
|
# Return the new tag array as a byte string
|
|
new_tag.pack("C*")
|
|
end
|
|
end
|