|
#!/usr/bin/env python3 |
|
# /// script |
|
# requires-python = ">=3.9" |
|
# dependencies = [ |
|
# "cryptography", |
|
# ] |
|
# /// |
|
""" |
|
Codeship jet decrypt alternative - Docker不要の復号化ツール |
|
|
|
Usage: uv run jet_decrypt.py <encrypted_file> <output_file> <key_file> |
|
Example: uv run jet_decrypt.py secret.env.encrypted secret.env codeship.aes |
|
""" |
|
|
|
import sys |
|
import base64 |
|
import hashlib |
|
import hmac |
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes |
|
from cryptography.hazmat.backends import default_backend |
|
|
|
V2_HEADER = "codeship:v2" |
|
|
|
|
|
def main(): |
|
if len(sys.argv) < 4: |
|
print("Usage: python3 jet_decrypt.py <encrypted_file> <output_file> <key_file>") |
|
print("Example: python3 jet_decrypt.py secret.env.encrypted secret.env codeship.aes") |
|
sys.exit(1) |
|
|
|
encrypted_file = sys.argv[1] |
|
output_file = sys.argv[2] |
|
key_file = sys.argv[3] |
|
|
|
# Read and decode key (base64 encoded 256-bit key) |
|
with open(key_file, 'r') as f: |
|
key_b64 = f.read().strip() |
|
|
|
key = base64.b64decode(key_b64) |
|
if len(key) != 32: |
|
print(f"Invalid key length: expected 32 bytes, got {len(key)}") |
|
sys.exit(1) |
|
|
|
print(f"Key loaded: {len(key)} bytes") |
|
|
|
# Read encrypted file |
|
with open(encrypted_file, 'r') as f: |
|
content = f.read() |
|
|
|
# Check for v2 header |
|
if content.startswith(V2_HEADER): |
|
print("Detected v2 format") |
|
payload = content[len(V2_HEADER):].strip() |
|
decrypted = decrypt_v2(payload, key) |
|
else: |
|
print("No v2 header, trying v1 format...") |
|
payload = content.strip() |
|
decrypted = decrypt_v1(payload, key) |
|
|
|
if decrypted is None: |
|
print("All decryption methods failed!") |
|
sys.exit(1) |
|
|
|
# Write output |
|
with open(output_file, 'wb') as f: |
|
f.write(decrypted) |
|
|
|
print(f"Successfully decrypted to: {output_file}") |
|
print(f"Preview (first 200 chars): {decrypted[:200].decode('utf-8', errors='replace')}") |
|
|
|
|
|
def decrypt_v2(payload: str, key: bytes) -> bytes: |
|
"""Decrypt v2 format - tries multiple methods""" |
|
try: |
|
data = base64.b64decode(payload) |
|
except Exception as e: |
|
print(f"Base64 decode error: {e}") |
|
return None |
|
|
|
print(f"Decoded data length: {len(data)} bytes") |
|
print(f"First 32 bytes (hex): {data[:32].hex()}") |
|
|
|
methods = [ |
|
("AES-256-GCM (nonce=12)", lambda: decrypt_aes_gcm(data, key, nonce_size=12)), |
|
("AES-256-GCM (nonce=16)", lambda: decrypt_aes_gcm(data, key, nonce_size=16)), |
|
("AES-256-CBC+HMAC (hmac+iv+ct)", lambda: decrypt_aes_cbc_hmac_prefix(data, key)), |
|
("AES-256-CBC+HMAC (iv+ct+hmac)", lambda: decrypt_aes_cbc_hmac_suffix(data, key)), |
|
("AES-256-CBC (iv+ct)", lambda: decrypt_aes_cbc(data, key)), |
|
("AES-256-CFB (iv+ct)", lambda: decrypt_aes_cfb(data, key)), |
|
("AES-256-CTR (iv+ct)", lambda: decrypt_aes_ctr(data, key)), |
|
] |
|
|
|
for name, method in methods: |
|
try: |
|
result = method() |
|
if result and is_printable(result): |
|
print(f"Success with: {name}") |
|
# v2 format: first 32 chars are checksum (MD5 hex), remove it |
|
result = strip_v2_checksum(result) |
|
return result |
|
except Exception as e: |
|
print(f"{name} failed: {e}") |
|
|
|
return None |
|
|
|
|
|
def strip_v2_checksum(data: bytes) -> bytes: |
|
"""Remove v2 checksum (32 hex chars) from start of decrypted data""" |
|
try: |
|
text = data.decode('utf-8') |
|
# Check if first 32 chars look like hex checksum |
|
if len(text) >= 32: |
|
potential_checksum = text[:32] |
|
if all(c in '0123456789abcdef' for c in potential_checksum.lower()): |
|
print(f"Stripped v2 checksum: {potential_checksum}") |
|
# Also strip newline after checksum if present |
|
remaining = text[32:] |
|
if remaining.startswith('\n'): |
|
remaining = remaining[1:] |
|
return remaining.encode('utf-8') |
|
return data |
|
except UnicodeDecodeError: |
|
return data |
|
|
|
|
|
def decrypt_v1(payload: str, key: bytes) -> bytes: |
|
"""Decrypt v1 format""" |
|
try: |
|
data = base64.b64decode(payload) |
|
except Exception as e: |
|
print(f"Base64 decode error: {e}") |
|
return None |
|
|
|
methods = [ |
|
("AES-256-CBC", lambda: decrypt_aes_cbc(data, key)), |
|
("AES-256-CFB", lambda: decrypt_aes_cfb(data, key)), |
|
("AES-256-CTR", lambda: decrypt_aes_ctr(data, key)), |
|
] |
|
|
|
for name, method in methods: |
|
try: |
|
result = method() |
|
if result and is_printable(result): |
|
print(f"Success with: {name}") |
|
return result |
|
except Exception as e: |
|
print(f"{name} failed: {e}") |
|
|
|
return None |
|
|
|
|
|
def decrypt_aes_gcm(data: bytes, key: bytes, nonce_size: int = 12) -> bytes: |
|
"""AES-256-GCM decryption""" |
|
if len(data) < nonce_size + 16: # nonce + min tag |
|
raise ValueError("Data too short for GCM") |
|
|
|
nonce = data[:nonce_size] |
|
ciphertext_and_tag = data[nonce_size:] |
|
|
|
# GCM tag is typically last 16 bytes |
|
cipher = Cipher(algorithms.AES(key), modes.GCM(nonce, ciphertext_and_tag[-16:]), backend=default_backend()) |
|
decryptor = cipher.decryptor() |
|
plaintext = decryptor.update(ciphertext_and_tag[:-16]) + decryptor.finalize() |
|
|
|
return plaintext |
|
|
|
|
|
def decrypt_aes_cbc_hmac_prefix(data: bytes, key: bytes) -> bytes: |
|
"""AES-256-CBC with HMAC at start: hmac(32) + iv(16) + ciphertext""" |
|
if len(data) < 48: |
|
raise ValueError("Data too short") |
|
|
|
expected_mac = data[:32] |
|
iv = data[32:48] |
|
ciphertext = data[48:] |
|
|
|
# Verify HMAC |
|
mac = hmac.new(key, data[32:], hashlib.sha256) |
|
if not hmac.compare_digest(expected_mac, mac.digest()): |
|
raise ValueError("HMAC verification failed") |
|
|
|
return decrypt_aes_cbc_core(ciphertext, key, iv) |
|
|
|
|
|
def decrypt_aes_cbc_hmac_suffix(data: bytes, key: bytes) -> bytes: |
|
"""AES-256-CBC with HMAC at end: iv(16) + ciphertext + hmac(32)""" |
|
if len(data) < 48: |
|
raise ValueError("Data too short") |
|
|
|
expected_mac = data[-32:] |
|
iv = data[:16] |
|
ciphertext = data[16:-32] |
|
|
|
# Verify HMAC |
|
mac = hmac.new(key, data[:-32], hashlib.sha256) |
|
if not hmac.compare_digest(expected_mac, mac.digest()): |
|
raise ValueError("HMAC verification failed") |
|
|
|
return decrypt_aes_cbc_core(ciphertext, key, iv) |
|
|
|
|
|
def decrypt_aes_cbc(data: bytes, key: bytes) -> bytes: |
|
"""AES-256-CBC: iv(16) + ciphertext""" |
|
if len(data) < 32: |
|
raise ValueError("Data too short") |
|
|
|
iv = data[:16] |
|
ciphertext = data[16:] |
|
|
|
return decrypt_aes_cbc_core(ciphertext, key, iv) |
|
|
|
|
|
def decrypt_aes_cbc_core(ciphertext: bytes, key: bytes, iv: bytes) -> bytes: |
|
"""Core AES-CBC decryption with PKCS7 unpadding""" |
|
if len(ciphertext) % 16 != 0: |
|
raise ValueError("Ciphertext not multiple of block size") |
|
|
|
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend()) |
|
decryptor = cipher.decryptor() |
|
plaintext = decryptor.update(ciphertext) + decryptor.finalize() |
|
|
|
# PKCS7 unpad |
|
padding_len = plaintext[-1] |
|
if padding_len > 16 or padding_len == 0: |
|
raise ValueError("Invalid PKCS7 padding") |
|
|
|
for i in range(padding_len): |
|
if plaintext[-(i+1)] != padding_len: |
|
raise ValueError("Invalid PKCS7 padding bytes") |
|
|
|
return plaintext[:-padding_len] |
|
|
|
|
|
def decrypt_aes_cfb(data: bytes, key: bytes) -> bytes: |
|
"""AES-256-CFB: iv(16) + ciphertext""" |
|
if len(data) < 16: |
|
raise ValueError("Data too short") |
|
|
|
iv = data[:16] |
|
ciphertext = data[16:] |
|
|
|
cipher = Cipher(algorithms.AES(key), modes.CFB(iv), backend=default_backend()) |
|
decryptor = cipher.decryptor() |
|
plaintext = decryptor.update(ciphertext) + decryptor.finalize() |
|
|
|
return plaintext |
|
|
|
|
|
def decrypt_aes_ctr(data: bytes, key: bytes) -> bytes: |
|
"""AES-256-CTR: nonce(16) + ciphertext""" |
|
if len(data) < 16: |
|
raise ValueError("Data too short") |
|
|
|
nonce = data[:16] |
|
ciphertext = data[16:] |
|
|
|
cipher = Cipher(algorithms.AES(key), modes.CTR(nonce), backend=default_backend()) |
|
decryptor = cipher.decryptor() |
|
plaintext = decryptor.update(ciphertext) + decryptor.finalize() |
|
|
|
return plaintext |
|
|
|
|
|
def is_printable(data: bytes) -> bool: |
|
"""Check if decrypted data looks like valid text""" |
|
try: |
|
text = data.decode('utf-8') |
|
# Check if mostly printable ASCII or valid UTF-8 |
|
printable_count = sum(1 for c in text if c.isprintable() or c in '\n\r\t') |
|
return printable_count > len(text) * 0.8 |
|
except UnicodeDecodeError: |
|
return False |
|
|
|
|
|
if __name__ == "__main__": |
|
main() |