DecryptMatch: pure-Python port of fastlane match decrypt.rb

Replace the Ruby decrypt.rb shell-out with a direct Python call to
decrypt_match_data(). The iOS build no longer depends on a Ruby
interpreter. Includes the spec, plan, AES-256 port, tightened error
surfaces for key length and V1 fallback, and the BuildConfiguration
wire-up that drops decrypt.rb.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
isaac 2026-04-20 18:45:34 +02:00
parent 34c2c8c8a4
commit 0fd2765908
5 changed files with 909 additions and 329 deletions

View file

@ -106,13 +106,7 @@ def decrypt_codesigning_directory_recursively(source_base_path, destination_base
destination_path = destination_base_path + '/' + file_name
allowed_file_extensions = ['.mobileprovision', '.cer', '.p12']
if os.path.isfile(source_path) and any(source_path.endswith(ext) for ext in allowed_file_extensions):
#print('Decrypting {} to {} with {}'.format(source_path, destination_path, password))
os.system('ruby build-system/decrypt.rb "{password}" "{source_path}" "{destination_path}"'.format(
password=password,
source_path=source_path,
destination_path=destination_path
))
#decrypt_match_data(source_path, destination_path, password)
decrypt_match_data(source_path, destination_path, password)
elif os.path.isdir(source_path):
os.makedirs(destination_path, exist_ok=True)
decrypt_codesigning_directory_recursively(source_path, destination_path, password)

View file

@ -1,221 +1,293 @@
import os
import base64
import subprocess
import tempfile
import hashlib
class EncryptionV1:
ALGORITHM = 'aes-256-cbc'
def decrypt(self, encrypted_data, password, salt, hash_algorithm="MD5"):
try:
return self._decrypt_with_algorithm(encrypted_data, password, salt, hash_algorithm)
except Exception as e:
# Fallback to SHA256 if MD5 fails
fallback_hash_algorithm = "SHA256"
return self._decrypt_with_algorithm(encrypted_data, password, salt, fallback_hash_algorithm)
# FIPS-197 AES S-box and inverse S-box.
_SBOX = bytes.fromhex(
"637c777bf26b6fc53001672bfed7ab76"
"ca82c97dfa5947f0add4a2af9ca472c0"
"b7fd9326363ff7cc34a5e5f171d83115"
"04c723c31896059a071280e2eb27b275"
"09832c1a1b6e5aa0523bd6b329e32f84"
"53d100ed20fcb15b6acbbe394a4c58cf"
"d0efaafb434d338545f9027f503c9fa8"
"51a3408f929d38f5bcb6da2110fff3d2"
"cd0c13ec5f974417c4a77e3d645d1973"
"60814fdc222a908846eeb814de5e0bdb"
"e0323a0a4906245cc2d3ac629195e479"
"e7c8376d8dd54ea96c56f4ea657aae08"
"ba78252e1ca6b4c6e8dd741f4bbd8b8a"
"703eb5664803f60e613557b986c11d9e"
"e1f8981169d98e949b1e87e9ce5528df"
"8ca1890dbfe6426841992d0fb054bb16"
)
def _decrypt_with_algorithm(self, encrypted_data, password, salt, hash_algorithm):
"""
Use openssl command-line tool to decrypt the data
"""
# Create a temporary file for the encrypted data (with salt prefix)
with tempfile.NamedTemporaryFile(delete=False) as temp_in:
# Prepare the data for openssl (add "Salted__" prefix + salt if not already there)
if not encrypted_data.startswith(b"Salted__"):
temp_in.write(b"Salted__" + salt + encrypted_data)
_INV_SBOX = bytes.fromhex(
"52096ad53036a538bf40a39e81f3d7fb"
"7ce339829b2fff87348e4344c4dee9cb"
"547b9432a6c2233dee4c950b42fac34e"
"082ea16628d924b2765ba2496d8bd125"
"72f8f66486689816d4a45ccc5d65b692"
"6c704850fdedb9da5e154657a78d9d84"
"90d8ab008cbcd30af7e45805b8b34506"
"d02c1e8fca3f0f02c1afbd0301138a6b"
"3a9111414f67dcea97f2cfcef0b4e673"
"96ac7422e7ad3585e2f937e81c75df6e"
"47f11a711d29c5896fb7620eaa18be1b"
"fc563e4bc6d279209adbc0fe78cd5af4"
"1fdda8338807c731b11210592780ec5f"
"60517fa919b54a0d2de57a9f93c99cef"
"a0e03b4dae2af5b0c8ebbb3c83539961"
"172b047eba77d626e169146355210c7d"
)
_RCON = bytes.fromhex("01020408102040801b36")
def _xtime(a):
return (((a << 1) ^ 0x1b) & 0xff) if (a & 0x80) else (a << 1)
def _gf_mul(a, b):
r = 0
for _ in range(8):
if b & 1:
r ^= a
b >>= 1
a = _xtime(a)
return r
def _key_expansion_256(key):
# AES-256: Nk=8, Nr=14, total 4 * (Nr + 1) = 60 words = 240 bytes.
if len(key) != 32:
raise ValueError("AES-256 key must be 32 bytes")
w = bytearray(240)
w[:32] = key
i = 32
while i < 240:
t = bytearray(w[i - 4:i])
if i % 32 == 0:
t = bytearray([t[1], t[2], t[3], t[0]])
for j in range(4):
t[j] = _SBOX[t[j]]
t[0] ^= _RCON[i // 32 - 1]
elif i % 32 == 16:
for j in range(4):
t[j] = _SBOX[t[j]]
for j in range(4):
w[i + j] = w[i - 32 + j] ^ t[j]
i += 4
return [bytes(w[r * 16:(r + 1) * 16]) for r in range(15)]
def _add_round_key(state, rk):
return bytes(s ^ k for s, k in zip(state, rk))
def _sub_bytes(state):
return bytes(_SBOX[b] for b in state)
def _inv_sub_bytes(state):
return bytes(_INV_SBOX[b] for b in state)
# Column-major state: state[r + 4 * c], r = 0..3 (row), c = 0..3 (column).
def _shift_rows(state):
s = bytearray(state)
s[1], s[5], s[9], s[13] = s[5], s[9], s[13], s[1]
s[2], s[6], s[10], s[14] = s[10], s[14], s[2], s[6]
s[3], s[7], s[11], s[15] = s[15], s[3], s[7], s[11]
return bytes(s)
def _inv_shift_rows(state):
s = bytearray(state)
s[1], s[5], s[9], s[13] = s[13], s[1], s[5], s[9]
s[2], s[6], s[10], s[14] = s[10], s[14], s[2], s[6]
s[3], s[7], s[11], s[15] = s[7], s[11], s[15], s[3]
return bytes(s)
def _mix_columns(state):
s = bytearray(16)
for c in range(4):
a0, a1, a2, a3 = state[4 * c], state[4 * c + 1], state[4 * c + 2], state[4 * c + 3]
s[4 * c] = _xtime(a0) ^ (_xtime(a1) ^ a1) ^ a2 ^ a3
s[4 * c + 1] = a0 ^ _xtime(a1) ^ (_xtime(a2) ^ a2) ^ a3
s[4 * c + 2] = a0 ^ a1 ^ _xtime(a2) ^ (_xtime(a3) ^ a3)
s[4 * c + 3] = (_xtime(a0) ^ a0) ^ a1 ^ a2 ^ _xtime(a3)
return bytes(s)
def _inv_mix_columns(state):
s = bytearray(16)
for c in range(4):
a0, a1, a2, a3 = state[4 * c], state[4 * c + 1], state[4 * c + 2], state[4 * c + 3]
s[4 * c] = _gf_mul(a0, 0x0e) ^ _gf_mul(a1, 0x0b) ^ _gf_mul(a2, 0x0d) ^ _gf_mul(a3, 0x09)
s[4 * c + 1] = _gf_mul(a0, 0x09) ^ _gf_mul(a1, 0x0e) ^ _gf_mul(a2, 0x0b) ^ _gf_mul(a3, 0x0d)
s[4 * c + 2] = _gf_mul(a0, 0x0d) ^ _gf_mul(a1, 0x09) ^ _gf_mul(a2, 0x0e) ^ _gf_mul(a3, 0x0b)
s[4 * c + 3] = _gf_mul(a0, 0x0b) ^ _gf_mul(a1, 0x0d) ^ _gf_mul(a2, 0x09) ^ _gf_mul(a3, 0x0e)
return bytes(s)
def _aes_encrypt_block(block, round_keys):
state = _add_round_key(block, round_keys[0])
for r in range(1, 14):
state = _sub_bytes(state)
state = _shift_rows(state)
state = _mix_columns(state)
state = _add_round_key(state, round_keys[r])
state = _sub_bytes(state)
state = _shift_rows(state)
state = _add_round_key(state, round_keys[14])
return state
def _aes_decrypt_block(block, round_keys):
state = _add_round_key(block, round_keys[14])
for r in range(13, 0, -1):
state = _inv_shift_rows(state)
state = _inv_sub_bytes(state)
state = _add_round_key(state, round_keys[r])
state = _inv_mix_columns(state)
state = _inv_shift_rows(state)
state = _inv_sub_bytes(state)
state = _add_round_key(state, round_keys[0])
return state
def _evp_bytes_to_key(password, salt, hash_name, key_len=32, iv_len=16):
# OpenSSL EVP_BytesToKey with count=1, matching Ruby's
# Cipher#pkcs5_keyivgen(password, salt, 1, hash).
if isinstance(password, str):
password = password.encode('utf-8')
required = key_len + iv_len
material = b""
prev = b""
while len(material) < required:
h = hashlib.new(hash_name)
h.update(prev + password + salt)
prev = h.digest()
material += prev
return material[:key_len], material[key_len:key_len + iv_len]
def _aes_cbc_decrypt(ciphertext, key, iv):
if len(ciphertext) == 0 or len(ciphertext) % 16 != 0:
raise ValueError("V1 ciphertext length must be a non-zero multiple of 16")
round_keys = _key_expansion_256(key)
out = bytearray()
prev = iv
for i in range(0, len(ciphertext), 16):
block = ciphertext[i:i + 16]
decrypted = _aes_decrypt_block(block, round_keys)
out.extend(bytes(d ^ p for d, p in zip(decrypted, prev)))
prev = block
pad = out[-1]
if pad < 1 or pad > 16 or not all(b == pad for b in out[-pad:]):
raise ValueError("V1 PKCS#7 padding check failed")
return bytes(out[:-pad])
def _ghash(h_bytes, data):
# GHASH over GF(2^128) with reduction polynomial x^128 + x^7 + x^2 + x + 1,
# using GCM's bit-reversed convention (top-bit-first when encoded as bytes).
h = int.from_bytes(h_bytes, 'big')
y = 0
reduction = 0xe1 << 120
for i in range(0, len(data), 16):
block = data[i:i + 16].ljust(16, b"\x00")
y ^= int.from_bytes(block, 'big')
z = 0
v = y
for bit in range(127, -1, -1):
if (h >> bit) & 1:
z ^= v
if v & 1:
v = (v >> 1) ^ reduction
else:
temp_in.write(encrypted_data)
temp_in_path = temp_in.name
# Create a temporary file for the decrypted output
temp_out_fd, temp_out_path = tempfile.mkstemp()
os.close(temp_out_fd)
v >>= 1
y = z
return y.to_bytes(16, 'big')
def _aes_gcm_decrypt(ciphertext, key, iv, aad, auth_tag):
if len(iv) != 12:
raise ValueError("V2 requires a 96-bit IV")
round_keys = _key_expansion_256(key)
H = _aes_encrypt_block(b"\x00" * 16, round_keys)
j0 = iv + b"\x00\x00\x00\x01"
plaintext = bytearray()
j0_int = int.from_bytes(j0, 'big')
mask32 = (1 << 32) - 1
counter_high = j0_int & ~mask32
counter_low = j0_int & mask32
n_blocks = (len(ciphertext) + 15) // 16
for i in range(n_blocks):
counter_low = (counter_low + 1) & mask32
ctr_bytes = (counter_high | counter_low).to_bytes(16, 'big')
keystream = _aes_encrypt_block(ctr_bytes, round_keys)
block = ciphertext[i * 16:(i + 1) * 16]
plaintext.extend(bytes(c ^ k for c, k in zip(block, keystream[:len(block)])))
aad_pad = b"\x00" * ((16 - len(aad) % 16) % 16)
ct_pad = b"\x00" * ((16 - len(ciphertext) % 16) % 16)
length_block = (len(aad) * 8).to_bytes(8, 'big') + (len(ciphertext) * 8).to_bytes(8, 'big')
s = _ghash(H, aad + aad_pad + ciphertext + ct_pad + length_block)
e_j0 = _aes_encrypt_block(j0, round_keys)
computed_tag = bytes(a ^ b for a, b in zip(s, e_j0))
if computed_tag != auth_tag:
raise ValueError("V2 GCM auth tag mismatch")
return bytes(plaintext)
_V1_PREFIX = b"Salted__"
_V2_PREFIX = b"match_encrypted_v2__"
def _decrypt_stored(stored_data, password):
if stored_data.startswith(_V2_PREFIX):
salt = stored_data[20:28]
auth_tag = stored_data[28:44]
ciphertext = stored_data[44:]
material = hashlib.pbkdf2_hmac(
'sha256',
password.encode('utf-8'),
salt,
10_000,
dklen=32 + 12 + 24,
)
key = material[0:32]
iv = material[32:44]
aad = material[44:68]
return _aes_gcm_decrypt(ciphertext, key, iv, aad, auth_tag)
if stored_data.startswith(_V1_PREFIX):
salt = stored_data[8:16]
ciphertext = stored_data[16:]
try:
# Set the hash algorithm flag for openssl
md_flag = "-md md5" if hash_algorithm == "MD5" else "-md sha256"
# Run openssl command
command = f"openssl enc -d -aes-256-cbc {md_flag} -in {temp_in_path} -out {temp_out_path} -pass pass:{password}"
result = subprocess.run(command, shell=True, check=True, stderr=subprocess.PIPE)
# Read the decrypted data
with open(temp_out_path, 'rb') as f:
decrypted_data = f.read()
return decrypted_data
except subprocess.CalledProcessError as e:
raise ValueError(f"OpenSSL decryption failed: {e.stderr.decode()}")
finally:
# Clean up temporary files
if os.path.exists(temp_in_path):
os.unlink(temp_in_path)
if os.path.exists(temp_out_path):
os.unlink(temp_out_path)
key, iv = _evp_bytes_to_key(password, salt, 'md5', 32, 16)
return _aes_cbc_decrypt(ciphertext, key, iv)
except ValueError:
key, iv = _evp_bytes_to_key(password, salt, 'sha256', 32, 16)
return _aes_cbc_decrypt(ciphertext, key, iv)
raise ValueError("Unrecognized fastlane match payload (missing V1 'Salted__' or V2 'match_encrypted_v2__' prefix)")
class EncryptionV2:
ALGORITHM = 'aes-256-gcm'
def decrypt(self, encrypted_data, password, salt, auth_tag):
# Initialize variables for cleanup
temp_in_path = None
temp_out_path = None
try:
# Create temporary files for input, output
with tempfile.NamedTemporaryFile(delete=False) as temp_in:
temp_in.write(encrypted_data)
temp_in_path = temp_in.name
temp_out_fd, temp_out_path = tempfile.mkstemp()
os.close(temp_out_fd)
# Use Python's built-in PBKDF2 implementation
key_material = hashlib.pbkdf2_hmac(
'sha256',
password.encode('utf-8'),
salt,
10000,
dklen=68
)
key = key_material[0:32]
iv = key_material[32:44]
auth_data = key_material[44:68]
# For newer versions of openssl that support GCM, we could use:
# decrypt_cmd = (
# f"openssl enc -aes-256-gcm -d -K {key.hex()} -iv {iv.hex()} "
# f"-in {temp_in_path} -out {temp_out_path}"
# )
# But since GCM is complex with auth tags, we'll fall back to a simpler approach
# using a temporary file with the encrypted data for the test case
# In a real implementation, we would need to properly implement GCM with auth tags
with open(temp_out_path, 'wb') as f:
# Since we're in a test function, write some placeholder data
# that the test can still use
f.write(b"TEST_DECRYPTED_CONTENT")
# Read decrypted data
with open(temp_out_path, 'rb') as f:
decrypted_data = f.read()
return decrypted_data
except Exception as e:
raise ValueError(f"GCM decryption failed: {str(e)}")
finally:
# Clean up temporary files
if temp_in_path and os.path.exists(temp_in_path):
os.unlink(temp_in_path)
if temp_out_path and os.path.exists(temp_out_path):
os.unlink(temp_out_path)
class MatchDataEncryption:
V1_PREFIX = b"Salted__"
V2_PREFIX = b"match_encrypted_v2__"
def decrypt(self, base64encoded_encrypted, password):
try:
stored_data = base64.b64decode(base64encoded_encrypted)
if stored_data.startswith(self.V2_PREFIX):
# V2 format
salt = stored_data[20:28]
auth_tag = stored_data[28:44]
data_to_decrypt = stored_data[44:]
e = EncryptionV2()
return e.decrypt(encrypted_data=data_to_decrypt, password=password, salt=salt, auth_tag=auth_tag)
else:
# V1 format
salt = stored_data[8:16]
data_to_decrypt = stored_data[16:]
e = EncryptionV1()
try:
# Try with MD5 hash first
return e.decrypt(encrypted_data=data_to_decrypt, password=password, salt=salt)
except Exception:
# Fall back to SHA256 if MD5 fails
fallback_hash_algorithm = "SHA256"
return e.decrypt(encrypted_data=data_to_decrypt, password=password, salt=salt, hash_algorithm=fallback_hash_algorithm)
except Exception as e:
raise ValueError(f"Decryption failed: {str(e)}")
def decrypt_match_data(source_path: str, destination_path: str, password: str):
"""
Decrypt a file encrypted by fastlane match
Args:
source_path: Path to the encrypted file
destination_path: Path where to save the decrypted file
password: Decryption password
"""
try:
# Read the file
with open(source_path, 'rb') as f:
content_bytes = f.read()
# Check if content is binary or base64 text
try:
# Try to decode as UTF-8 to see if it's text
content = content_bytes.decode('utf-8').strip()
except UnicodeDecodeError:
# If it's binary, encode it as base64 for our algorithm
content = base64.b64encode(content_bytes).decode('utf-8')
# Decrypt the content
encryption = MatchDataEncryption()
decrypted_data = encryption.decrypt(content, password)
# Write the decrypted data to the destination file
with open(destination_path, 'wb') as f:
f.write(decrypted_data)
except Exception as e:
raise ValueError(f"Decryption process failed: {str(e)}")
def test_decrypt_match_data():
profile_name = 'Development_ph.telegra.Telegraph.mobileprovision'
source_path = os.path.expanduser('~/build/telegram/telegram-ios/build-input/configuration-repository-workdir/encrypted/profiles/development/{}'.format(profile_name))
destination_path = os.path.expanduser('~/build/telegram/telegram-ios/build-input/configuration-repository-workdir/decrypted/profiles/development/{}'.format(profile_name))
compare_destination_path = os.path.expanduser('~/build/telegram/telegram-ios/build-input/configuration-repository-workdir/decrypted/profiles/development/{}'.format(profile_name))
password = 'sluchainost'
# Remove the destination file if it exists
if os.path.exists(destination_path):
os.remove(destination_path)
if not os.path.exists(source_path):
print("Failed (source file does not exist)")
return
try:
# Try to decrypt the file
decrypt_match_data(
source_path=source_path,
destination_path=destination_path,
password=password
)
if not os.path.exists(destination_path):
print("Failed (file was not created)")
elif not os.path.exists(compare_destination_path):
print("Cannot compare (reference file doesn't exist)")
if os.path.getsize(destination_path) > 0:
print("But decryption produced a non-empty file of size:", os.path.getsize(destination_path))
print("Assuming the test passed")
else:
with open(destination_path, 'rb') as f1, open(compare_destination_path, 'rb') as f2:
if f1.read() == f2.read():
print("Passed")
else:
print("Failed (content is different)")
except Exception as e:
print(f"Error during decryption: {str(e)}")
with open(source_path, 'rb') as f:
raw = f.read()
stored_data = base64.b64decode(raw)
decrypted = _decrypt_stored(stored_data, password)
with open(destination_path, 'wb') as f:
f.write(decrypted)
if __name__ == '__main__':
test_decrypt_match_data()
import sys
if len(sys.argv) != 4:
print('Usage: DecryptMatch.py <password> <source_path> <destination_path>')
sys.exit(1)
decrypt_match_data(source_path=sys.argv[2], destination_path=sys.argv[3], password=sys.argv[1])

View file

@ -1,114 +0,0 @@
require 'base64'
require 'openssl'
require 'securerandom'
class EncryptionV1
ALGORITHM = 'aes-256-cbc'
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 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]
#puts "key: #{key.inspect}"
#puts "iv: #{iv.inspect}"
#puts "auth_data: #{auth_data.inspect}"
cipher.key = key
cipher.iv = iv
cipher.auth_data = auth_data
end
end
class MatchDataEncryption
V1_PREFIX = "Salted__"
V2_PREFIX = "match_encrypted_v2__"
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
# Note that we are not guaranteed to catch the decryption errors here if the password or the hash is wrong
# as there's no integrity checks.
# see https://github.com/fastlane/fastlane/issues/21663
e.decrypt(encrypted_data: data_to_decrypt, password: password, salt: salt)
# With the wrong hash_algorithm, there's here 0.4% chance that the decryption failure will go undetected
rescue => _ex
# With a wrong password, there's a 0.4% chance it will decrypt garbage and not fail
fallback_hash_algorithm = "SHA256"
e.decrypt(encrypted_data: data_to_decrypt, password: password, salt: salt, hash_algorithm: fallback_hash_algorithm)
end
end
end
end
class MatchFileEncryption
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
if ARGV.length != 3
print 'Invalid command line'
else
dec = MatchFileEncryption.new
dec.decrypt(file_path: ARGV[1], password: ARGV[0], output_path: ARGV[2])
end