forked from fastlane/fastlane
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[match] improve encryption internals, solving flaky test (fastlane#21663
) (fastlane#21790) * Rewrite the encryption layer for match, keeping backwards compatibility * match: add companion script to enc * Update documentation related to encryption * rubocop * Rename match_enc file * Remove deprecation warning from OpenSSL * Attempt at lower casing the cipher to allow older rubies to find it * address code review * Adjust CLI usage and arg checks * more doc cleanups * rubocop * Update match/lib/match/encryption/encryption.rb Co-authored-by: Roger Oba <rogerluan.oba@gmail.com> * Update match/lib/match/encryption/encryption.rb Co-authored-by: Roger Oba <rogerluan.oba@gmail.com> * Update bin/match_file Co-authored-by: Roger Oba <rogerluan.oba@gmail.com> * Update match/lib/match/encryption/encryption.rb Co-authored-by: Roger Oba <rogerluan.oba@gmail.com> * Update match/lib/match/encryption/encryption.rb Co-authored-by: Roger Oba <rogerluan.oba@gmail.com> * Monkey patch match_file to support control-c :) * Fix typo * Use keyword parameters in main entry point for encryption * Rubocop * ctrl-c support requires ruby 2.7.1 apparently, so aborting instead * encryption: keyword parameters all the way, also add the test that got lost --------- Co-authored-by: Roger Oba <rogerluan.oba@gmail.com>
- Loading branch information
Showing
6 changed files
with
254 additions
and
40 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,60 @@ | ||
#!/usr/bin/env ruby | ||
require 'match' | ||
|
||
# CLI to encrypt/decrypt files using fastlane match encryption layer | ||
|
||
def usage | ||
puts("USAGE: [encrypt|decrypt] input_path [output_path]") | ||
exit(-1) | ||
end | ||
|
||
if ARGV.count < 2 || ARGV.count > 3 | ||
usage | ||
end | ||
|
||
method_name = ARGV.shift | ||
unless ['encrypt', 'decrypt'].include?(method_name) | ||
usage | ||
end | ||
|
||
input_file = ARGV.shift | ||
|
||
if ARGV.count > 0 | ||
output_file = ARGV.shift | ||
else | ||
output_file = input_file | ||
end | ||
|
||
def ask_password(msg) | ||
ask(msg) do |q| | ||
q.whitespace = :chomp | ||
q.echo = "*" | ||
end | ||
end | ||
|
||
def ask_password_twice | ||
password = ask_password("Enter the password: ") | ||
return "" if password.empty? || password == "\u0003" # CTRL-C char | ||
other = ask_password("Enter the password again: ") | ||
if other == password | ||
return password | ||
else | ||
return nil | ||
end | ||
end | ||
|
||
# read the password | ||
password = nil | ||
loop do | ||
password = ask_password_twice | ||
break unless password.nil? | ||
end | ||
|
||
exit if password.empty? | ||
|
||
begin | ||
Match::Encryption::MatchFileEncryption.new.send(method_name, file_path: input_file, password: password, output_path: output_file) | ||
rescue => e | ||
puts("ERROR #{method_name}ing. [#{e}]. Check your password") | ||
usage | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,153 @@ | ||
require 'base64' | ||
require 'openssl' | ||
require 'securerandom' | ||
|
||
module Match | ||
module Encryption | ||
# This is to keep backwards compatibility with the old fastlane version which used the local openssl installation. | ||
# The encryption parameters in this implementation reflect the old behavior which was the most common default value in those versions. | ||
# As for decryption, 1.0.x OpenSSL and earlier versions use MD5, 1.1.0c and newer uses SHA256, we try both before giving an error | ||
class EncryptionV1 | ||
ALGORITHM = 'aes-256-cbc' | ||
|
||
def encrypt(data:, password:, salt:, hash_algorithm: "MD5") | ||
cipher = ::OpenSSL::Cipher.new(ALGORITHM) | ||
cipher.encrypt | ||
|
||
keyivgen(cipher, password, salt, hash_algorithm) | ||
|
||
encrypted_data = cipher.update(data) | ||
encrypted_data << cipher.final | ||
{ encrypted_data: encrypted_data } | ||
end | ||
|
||
def decrypt(encrypted_data:, password:, salt:, hash_algorithm: "MD5") | ||
cipher = ::OpenSSL::Cipher.new(ALGORITHM) | ||
cipher.decrypt | ||
|
||
keyivgen(cipher, password, salt, hash_algorithm) | ||
|
||
data = cipher.update(encrypted_data) | ||
data << cipher.final | ||
end | ||
|
||
private | ||
|
||
def keyivgen(cipher, password, salt, hash_algorithm) | ||
cipher.pkcs5_keyivgen(password, salt, 1, hash_algorithm) | ||
end | ||
end | ||
|
||
# The newer encryption mechanism, which features a more secure key and IV generation. | ||
# | ||
# The IV is randomly generated and provided unencrypted. | ||
# The salt should be randomly generated and provided unencrypted (like in the current implementation). | ||
# The key is generated with OpenSSL::KDF::pbkdf2_hmac with properly chosen parameters. | ||
# | ||
# Short explanation about salt and IV: https://stackoverflow.com/a/1950674/6324550 | ||
class EncryptionV2 | ||
ALGORITHM = 'aes-256-gcm' | ||
|
||
def encrypt(data:, password:, salt:) | ||
cipher = ::OpenSSL::Cipher.new(ALGORITHM) | ||
cipher.encrypt | ||
|
||
keyivgen(cipher, password, salt) | ||
|
||
encrypted_data = cipher.update(data) | ||
encrypted_data << cipher.final | ||
|
||
auth_tag = cipher.auth_tag | ||
|
||
{ encrypted_data: encrypted_data, auth_tag: auth_tag } | ||
end | ||
|
||
def decrypt(encrypted_data:, password:, salt:, auth_tag:) | ||
cipher = ::OpenSSL::Cipher.new(ALGORITHM) | ||
cipher.decrypt | ||
|
||
keyivgen(cipher, password, salt) | ||
|
||
cipher.auth_tag = auth_tag | ||
|
||
data = cipher.update(encrypted_data) | ||
data << cipher.final | ||
end | ||
|
||
private | ||
|
||
def keyivgen(cipher, password, salt) | ||
keyIv = ::OpenSSL::KDF.pbkdf2_hmac(password, salt: salt, iterations: 10_000, length: 32 + 12 + 24, hash: "sha256") | ||
key = keyIv[0..31] | ||
iv = keyIv[32..43] | ||
auth_data = keyIv[44..-1] | ||
cipher.key = key | ||
cipher.iv = iv | ||
cipher.auth_data = auth_data | ||
end | ||
end | ||
|
||
class MatchDataEncryption | ||
V1_PREFIX = "Salted__" | ||
V2_PREFIX = "match_encrypted_v2__" | ||
|
||
def encrypt(data:, password:, version: 2) | ||
salt = SecureRandom.random_bytes(8) | ||
if version == 2 | ||
e = EncryptionV2.new | ||
encryption = e.encrypt(data: data, password: password, salt: salt) | ||
encrypted_data = V2_PREFIX + salt + encryption[:auth_tag] + encryption[:encrypted_data] | ||
else | ||
e = EncryptionV1.new | ||
encryption = e.encrypt(data: data, password: password, salt: salt) | ||
encrypted_data = V1_PREFIX + salt + encryption[:encrypted_data] | ||
end | ||
Base64.encode64(encrypted_data) | ||
end | ||
|
||
def decrypt(base64encoded_encrypted:, password:) | ||
stored_data = Base64.decode64(base64encoded_encrypted) | ||
if stored_data.start_with?(V2_PREFIX) | ||
salt = stored_data[20..27] | ||
auth_tag = stored_data[28..43] | ||
data_to_decrypt = stored_data[44..-1] | ||
e = EncryptionV2.new | ||
e.decrypt(encrypted_data: data_to_decrypt, password: password, salt: salt, auth_tag: auth_tag) | ||
else | ||
salt = stored_data[8..15] | ||
data_to_decrypt = stored_data[16..-1] | ||
e = EncryptionV1.new | ||
begin | ||
e.decrypt(encrypted_data: data_to_decrypt, password: password, salt: salt) | ||
rescue => _ex | ||
# Note that we are not guaranteed to catch the decryption errors here if the password is wrong | ||
# as there's no integrity checks. | ||
# With a wrong password, there's a 0.4% chance it will decrypt garbage and not fail | ||
# see https://github.com/fastlane/fastlane/issues/21663 | ||
fallback_hash_algorithm = "SHA256" | ||
e.decrypt(encrypted_data: data_to_decrypt, password: password, salt: salt, hash_algorithm: fallback_hash_algorithm) | ||
end | ||
end | ||
end | ||
end | ||
|
||
# The methods of this class will encrypt or decrypt files in place, by default. | ||
class MatchFileEncryption | ||
def encrypt(file_path:, password:, output_path: nil) | ||
output_path = file_path unless output_path | ||
data_to_encrypt = File.binread(file_path) | ||
e = MatchDataEncryption.new | ||
data = e.encrypt(data: data_to_encrypt, password: password) | ||
File.write(output_path, data) | ||
end | ||
|
||
def decrypt(file_path:, password:, output_path: nil) | ||
output_path = file_path unless output_path | ||
content = File.read(file_path) | ||
e = MatchDataEncryption.new | ||
decrypted_data = e.decrypt(base64encoded_encrypted: content, password: password) | ||
File.binwrite(output_path, decrypted_data) | ||
end | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
describe Match do | ||
describe Match::Encryption::MatchDataEncryption do | ||
let(:v1) { Match::Encryption::EncryptionV1.new } | ||
let(:v2) { Match::Encryption::EncryptionV2.new } | ||
let(:e) { Match::Encryption::MatchDataEncryption.new } | ||
let(:salt) { salt = SecureRandom.random_bytes(8) } | ||
|
||
let(:data) { "Hello World" } | ||
let(:password) { '2"QAHg@v(Qp{=*n^' } | ||
|
||
it "decrypts V1 Encryption with default hash" do | ||
encryption = v1.encrypt(data: data, password: password, salt: salt) | ||
encrypted_data = Match::Encryption::MatchDataEncryption::V1_PREFIX + salt + encryption[:encrypted_data] | ||
encoded_encrypted_data = Base64.encode64(encrypted_data) | ||
|
||
expect(e.decrypt(base64encoded_encrypted: encoded_encrypted_data, password: password)).to eq(data) | ||
end | ||
|
||
it "decrypts V1 Encryption with SHA256 hash" do | ||
encryption = v1.encrypt(data: data, password: password, salt: salt, hash_algorithm: "SHA256") | ||
encrypted_data = Match::Encryption::MatchDataEncryption::V1_PREFIX + salt + encryption[:encrypted_data] | ||
encoded_encrypted_data = Base64.encode64(encrypted_data) | ||
|
||
expect(e.decrypt(base64encoded_encrypted: encoded_encrypted_data, password: password)).to eq(data) | ||
end | ||
end | ||
end |