|
| 1 | +import base64 |
| 2 | +import hashlib |
| 3 | +import struct |
| 4 | + |
| 5 | + |
| 6 | +class DecryptionError(Exception): |
| 7 | + """Raised for any decryption failure (bad passphrase, corrupt data, padding error, etc.).""" |
| 8 | + pass |
| 9 | + |
| 10 | + |
| 11 | +# --------------------------------------------------------------------------- |
| 12 | +# Minimal pure-Python AES-256-CBC decryption (no external dependencies). |
| 13 | +# |
| 14 | +# Only decryption is implemented — encryption is not needed. Lookup tables |
| 15 | +# are precomputed at import time to avoid per-byte GF(2^8) multiplication |
| 16 | +# in the hot loop (important on Pi Zero). |
| 17 | +# --------------------------------------------------------------------------- |
| 18 | + |
| 19 | +# AES S-box |
| 20 | +_SBOX = ( |
| 21 | + 0x63,0x7c,0x77,0x7b,0xf2,0x6b,0x6f,0xc5,0x30,0x01,0x67,0x2b,0xfe,0xd7,0xab,0x76, |
| 22 | + 0xca,0x82,0xc9,0x7d,0xfa,0x59,0x47,0xf0,0xad,0xd4,0xa2,0xaf,0x9c,0xa4,0x72,0xc0, |
| 23 | + 0xb7,0xfd,0x93,0x26,0x36,0x3f,0xf7,0xcc,0x34,0xa5,0xe5,0xf1,0x71,0xd8,0x31,0x15, |
| 24 | + 0x04,0xc7,0x23,0xc3,0x18,0x96,0x05,0x9a,0x07,0x12,0x80,0xe2,0xeb,0x27,0xb2,0x75, |
| 25 | + 0x09,0x83,0x2c,0x1a,0x1b,0x6e,0x5a,0xa0,0x52,0x3b,0xd6,0xb3,0x29,0xe3,0x2f,0x84, |
| 26 | + 0x53,0xd1,0x00,0xed,0x20,0xfc,0xb1,0x5b,0x6a,0xcb,0xbe,0x39,0x4a,0x4c,0x58,0xcf, |
| 27 | + 0xd0,0xef,0xaa,0xfb,0x43,0x4d,0x33,0x85,0x45,0xf9,0x02,0x7f,0x50,0x3c,0x9f,0xa8, |
| 28 | + 0x51,0xa3,0x40,0x8f,0x92,0x9d,0x38,0xf5,0xbc,0xb6,0xda,0x21,0x10,0xff,0xf3,0xd2, |
| 29 | + 0xcd,0x0c,0x13,0xec,0x5f,0x97,0x44,0x17,0xc4,0xa7,0x7e,0x3d,0x64,0x5d,0x19,0x73, |
| 30 | + 0x60,0x81,0x4f,0xdc,0x22,0x2a,0x90,0x88,0x46,0xee,0xb8,0x14,0xde,0x5e,0x0b,0xdb, |
| 31 | + 0xe0,0x32,0x3a,0x0a,0x49,0x06,0x24,0x5c,0xc2,0xd3,0xac,0x62,0x91,0x95,0xe4,0x79, |
| 32 | + 0xe7,0xc8,0x37,0x6d,0x8d,0xd5,0x4e,0xa9,0x6c,0x56,0xf4,0xea,0x65,0x7a,0xae,0x08, |
| 33 | + 0xba,0x78,0x25,0x2e,0x1c,0xa6,0xb4,0xc6,0xe8,0xdd,0x74,0x1f,0x4b,0xbd,0x8b,0x8a, |
| 34 | + 0x70,0x3e,0xb5,0x66,0x48,0x03,0xf6,0x0e,0x61,0x35,0x57,0xb9,0x86,0xc1,0x1d,0x9e, |
| 35 | + 0xe1,0xf8,0x98,0x11,0x69,0xd9,0x8e,0x94,0x9b,0x1e,0x87,0xe9,0xce,0x55,0x28,0xdf, |
| 36 | + 0x8c,0xa1,0x89,0x0d,0xbf,0xe6,0x42,0x68,0x41,0x99,0x2d,0x0f,0xb0,0x54,0xbb,0x16, |
| 37 | +) |
| 38 | + |
| 39 | +# Inverse S-box (for decryption) |
| 40 | +_INV_SBOX = tuple(_SBOX.index(i) for i in range(256)) |
| 41 | + |
| 42 | +# Round constants |
| 43 | +_RCON = (0x01,0x02,0x04,0x08,0x10,0x20,0x40,0x80,0x1b,0x36) |
| 44 | + |
| 45 | + |
| 46 | +def _gmul(a, b): |
| 47 | + """Galois field multiplication in GF(2^8).""" |
| 48 | + p = 0 |
| 49 | + for _ in range(8): |
| 50 | + if b & 1: |
| 51 | + p ^= a |
| 52 | + hi = a & 0x80 |
| 53 | + a = (a << 1) & 0xff |
| 54 | + if hi: |
| 55 | + a ^= 0x1b |
| 56 | + b >>= 1 |
| 57 | + return p |
| 58 | + |
| 59 | + |
| 60 | +# Precomputed multiplication tables for InvMixColumns constants. |
| 61 | +# Each table maps byte value (0-255) to its product with the constant. |
| 62 | +# This replaces per-byte _gmul calls in the hot loop with O(1) lookups. |
| 63 | +_MUL9 = tuple(_gmul(i, 0x09) for i in range(256)) |
| 64 | +_MUL11 = tuple(_gmul(i, 0x0b) for i in range(256)) |
| 65 | +_MUL13 = tuple(_gmul(i, 0x0d) for i in range(256)) |
| 66 | +_MUL14 = tuple(_gmul(i, 0x0e) for i in range(256)) |
| 67 | + |
| 68 | + |
| 69 | +def _key_expansion(key: bytes) -> list: |
| 70 | + """Expand 256-bit key into 60 32-bit round key words.""" |
| 71 | + nk = 8 # AES-256: 8 words in key |
| 72 | + nr = 14 # AES-256: 14 rounds |
| 73 | + w = list(struct.unpack('>8I', key)) |
| 74 | + for i in range(nk, 4 * (nr + 1)): |
| 75 | + t = w[i - 1] |
| 76 | + if i % nk == 0: |
| 77 | + # RotWord + SubWord + Rcon |
| 78 | + t = ((t << 8) | (t >> 24)) & 0xffffffff |
| 79 | + t = (_SBOX[(t >> 24) & 0xff] << 24 | |
| 80 | + _SBOX[(t >> 16) & 0xff] << 16 | |
| 81 | + _SBOX[(t >> 8) & 0xff] << 8 | |
| 82 | + _SBOX[t & 0xff]) |
| 83 | + t ^= _RCON[i // nk - 1] << 24 |
| 84 | + elif i % nk == 4: |
| 85 | + t = (_SBOX[(t >> 24) & 0xff] << 24 | |
| 86 | + _SBOX[(t >> 16) & 0xff] << 16 | |
| 87 | + _SBOX[(t >> 8) & 0xff] << 8 | |
| 88 | + _SBOX[t & 0xff]) |
| 89 | + w.append(w[i - nk] ^ t) |
| 90 | + return w |
| 91 | + |
| 92 | + |
| 93 | +def _inv_cipher_block(block: bytes, rk: list) -> bytes: |
| 94 | + """Decrypt one 16-byte AES block (AES-256, 14 rounds).""" |
| 95 | + nr = 14 |
| 96 | + s = list(block) |
| 97 | + |
| 98 | + # AddRoundKey (round nr) |
| 99 | + for c in range(4): |
| 100 | + w = rk[nr * 4 + c] |
| 101 | + s[c * 4 + 0] ^= (w >> 24) & 0xff |
| 102 | + s[c * 4 + 1] ^= (w >> 16) & 0xff |
| 103 | + s[c * 4 + 2] ^= (w >> 8) & 0xff |
| 104 | + s[c * 4 + 3] ^= w & 0xff |
| 105 | + |
| 106 | + for rnd in range(nr - 1, 0, -1): |
| 107 | + # InvShiftRows |
| 108 | + s[0*4+1], s[1*4+1], s[2*4+1], s[3*4+1] = s[3*4+1], s[0*4+1], s[1*4+1], s[2*4+1] |
| 109 | + s[0*4+2], s[1*4+2], s[2*4+2], s[3*4+2] = s[2*4+2], s[3*4+2], s[0*4+2], s[1*4+2] |
| 110 | + s[0*4+3], s[1*4+3], s[2*4+3], s[3*4+3] = s[1*4+3], s[2*4+3], s[3*4+3], s[0*4+3] |
| 111 | + |
| 112 | + # InvSubBytes |
| 113 | + s = [_INV_SBOX[b] for b in s] |
| 114 | + |
| 115 | + # AddRoundKey |
| 116 | + for c in range(4): |
| 117 | + w = rk[rnd * 4 + c] |
| 118 | + s[c * 4 + 0] ^= (w >> 24) & 0xff |
| 119 | + s[c * 4 + 1] ^= (w >> 16) & 0xff |
| 120 | + s[c * 4 + 2] ^= (w >> 8) & 0xff |
| 121 | + s[c * 4 + 3] ^= w & 0xff |
| 122 | + |
| 123 | + # InvMixColumns (using precomputed lookup tables) |
| 124 | + ns = list(s) |
| 125 | + for c in range(4): |
| 126 | + i = c * 4 |
| 127 | + a0, a1, a2, a3 = s[i], s[i+1], s[i+2], s[i+3] |
| 128 | + ns[i] = _MUL14[a0] ^ _MUL11[a1] ^ _MUL13[a2] ^ _MUL9[a3] |
| 129 | + ns[i+1] = _MUL9[a0] ^ _MUL14[a1] ^ _MUL11[a2] ^ _MUL13[a3] |
| 130 | + ns[i+2] = _MUL13[a0] ^ _MUL9[a1] ^ _MUL14[a2] ^ _MUL11[a3] |
| 131 | + ns[i+3] = _MUL11[a0] ^ _MUL13[a1] ^ _MUL9[a2] ^ _MUL14[a3] |
| 132 | + s = ns |
| 133 | + |
| 134 | + # Final round (no InvMixColumns) |
| 135 | + # InvShiftRows |
| 136 | + s[0*4+1], s[1*4+1], s[2*4+1], s[3*4+1] = s[3*4+1], s[0*4+1], s[1*4+1], s[2*4+1] |
| 137 | + s[0*4+2], s[1*4+2], s[2*4+2], s[3*4+2] = s[2*4+2], s[3*4+2], s[0*4+2], s[1*4+2] |
| 138 | + s[0*4+3], s[1*4+3], s[2*4+3], s[3*4+3] = s[1*4+3], s[2*4+3], s[3*4+3], s[0*4+3] |
| 139 | + |
| 140 | + # InvSubBytes |
| 141 | + s = [_INV_SBOX[b] for b in s] |
| 142 | + |
| 143 | + # AddRoundKey (round 0) |
| 144 | + for c in range(4): |
| 145 | + w = rk[c] |
| 146 | + s[c * 4 + 0] ^= (w >> 24) & 0xff |
| 147 | + s[c * 4 + 1] ^= (w >> 16) & 0xff |
| 148 | + s[c * 4 + 2] ^= (w >> 8) & 0xff |
| 149 | + s[c * 4 + 3] ^= w & 0xff |
| 150 | + |
| 151 | + return bytes(s) |
| 152 | + |
| 153 | + |
| 154 | +def _aes256_cbc_decrypt(key: bytes, iv: bytes, ciphertext: bytes) -> bytes: |
| 155 | + """AES-256-CBC decryption with PKCS#7 unpadding.""" |
| 156 | + rk = _key_expansion(key) |
| 157 | + blocks = [ciphertext[i:i+16] for i in range(0, len(ciphertext), 16)] |
| 158 | + plaintext = bytearray() |
| 159 | + prev = iv |
| 160 | + for block in blocks: |
| 161 | + decrypted = _inv_cipher_block(block, rk) |
| 162 | + plaintext.extend(b ^ p for b, p in zip(decrypted, prev)) |
| 163 | + prev = block |
| 164 | + |
| 165 | + # PKCS#7 unpadding |
| 166 | + if not plaintext: |
| 167 | + raise DecryptionError("Empty plaintext") |
| 168 | + pad_len = plaintext[-1] |
| 169 | + if pad_len < 1 or pad_len > 16: |
| 170 | + raise DecryptionError("Invalid PKCS#7 padding") |
| 171 | + if plaintext[-pad_len:] != bytes([pad_len]) * pad_len: |
| 172 | + raise DecryptionError("Invalid PKCS#7 padding") |
| 173 | + return bytes(plaintext[:-pad_len]) |
| 174 | + |
| 175 | + |
| 176 | +# --------------------------------------------------------------------------- |
| 177 | +# Public API |
| 178 | +# --------------------------------------------------------------------------- |
| 179 | + |
| 180 | +def decrypt_openssl_aes256cbc(data_b64: str, passphrase: str) -> str: |
| 181 | + """ |
| 182 | + Decrypts OpenSSL-compatible AES-256-CBC data (base64, starts with "U2FsdGVkX1"). |
| 183 | + Matches: openssl enc -aes-256-cbc -pbkdf2 [-iter N] -base64 |
| 184 | +
|
| 185 | + The PBKDF2 iteration count is not stored in the ciphertext, so we try |
| 186 | + common values: 10 000 (OpenSSL default when -iter is omitted) and |
| 187 | + 100 000 (commonly recommended). The first one that yields valid |
| 188 | + PKCS#7 padding and UTF-8 plaintext wins. |
| 189 | +
|
| 190 | + Returns plaintext UTF-8 string (the mnemonic). |
| 191 | + Raises DecryptionError on any failure. |
| 192 | + """ |
| 193 | + try: |
| 194 | + # 1. Base64-decode |
| 195 | + data = base64.b64decode(data_b64.strip()) |
| 196 | + |
| 197 | + # 2. Verify magic header |
| 198 | + if len(data) < 16 or data[:8] != b'Salted__': |
| 199 | + raise DecryptionError("Invalid OpenSSL magic header (expected 'Salted__')") |
| 200 | + |
| 201 | + # 3. Extract salt + ciphertext |
| 202 | + salt = data[8:16] |
| 203 | + ciphertext = data[16:] |
| 204 | + |
| 205 | + if len(ciphertext) % 16 != 0: |
| 206 | + raise DecryptionError("Ciphertext length not multiple of AES block size (16)") |
| 207 | + |
| 208 | + # 4. Try PBKDF2 with common iteration counts |
| 209 | + passphrase_bytes = passphrase.encode('utf-8') |
| 210 | + for iterations in (10_000, 100_000): |
| 211 | + try: |
| 212 | + derived = hashlib.pbkdf2_hmac( |
| 213 | + 'sha256', passphrase_bytes, salt, iterations, dklen=48 |
| 214 | + ) |
| 215 | + plaintext_bytes = _aes256_cbc_decrypt(derived[:32], derived[32:48], ciphertext) |
| 216 | + return plaintext_bytes.decode('utf-8') |
| 217 | + except (DecryptionError, UnicodeDecodeError): |
| 218 | + continue |
| 219 | + |
| 220 | + raise DecryptionError("Decryption failed for all supported iteration counts") |
| 221 | + |
| 222 | + except Exception as e: |
| 223 | + if isinstance(e, DecryptionError): |
| 224 | + raise |
| 225 | + raise DecryptionError(f"Decryption failed: {str(e)}") from e |
0 commit comments