-
Notifications
You must be signed in to change notification settings - Fork 5
/
eac.py
165 lines (120 loc) · 5.55 KB
/
eac.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
#!/usr/bin/python
import re
import sys
import enum
import argparse
import contextlib
import pprp
CHECKSUM_MIN_VERSION = (1, 0, 1) # V1.0 beta 1
def eac_checksum(text):
# Ignore newlines
text = text.replace('\r', '').replace('\n', '')
# Fuzzing reveals BOMs are also ignored
text = text.replace('\ufeff', '').replace('\ufffe', '')
# Setup Rijndael-256 with a 256-bit blocksize
cipher = pprp.crypto_3.rijndael(
# Probably SHA256('super secret password') but it doesn't actually matter
key=bytes.fromhex('9378716cf13e4265ae55338e940b376184da389e50647726b35f6f341ee3efd9'),
block_size=256 // 8
)
# Encode the text as UTF-16-LE
plaintext = text.encode('utf-16-le')
# The IV is all zeroes so we don't have to handle it
signature = b'\x00' * 32
# Process it block-by-block
for i in range(0, len(plaintext), 32):
# Zero-pad the last block, if necessary
plaintext_block = plaintext[i:i + 32].ljust(32, b'\x00')
# CBC mode (XOR the previous ciphertext block into the plaintext)
cbc_plaintext = bytes(a ^ b for a, b in zip(signature, plaintext_block))
# New signature is the ciphertext.
signature = cipher.encrypt(cbc_plaintext)
# Textual signature is just the hex representation
return signature.hex().upper()
def extract_info(text):
version = None
# Find the line with the version number, breaking at the first non-empty line
for line in text.splitlines():
if version is None and line.startswith('Exact Audio Copy'):
version_text = line.replace('Exact Audio Copy ', '').split(' from', 1)[0]
major_minor, *beta = version_text[1:].split(' beta ', 1)
major, minor = map(int, major_minor.split('.'))
if beta:
beta = int(beta[0])
else:
beta = float('+inf') # so V1.0 > v1.0 beta 1
version = (major, minor, beta)
elif re.match(r'[a-zA-Z]', line):
break
if '\r\n\r\n==== Log checksum' not in text:
signature = None
else:
text, signature_parts = text.split('\r\n\r\n==== Log checksum', 1)
signature = signature_parts.split()[0].strip()
return text, version, signature
def eac_verify(data):
# Log is encoded as Little Endian UTF-16
text = data.decode('utf-16-le')
# Strip off the BOM
if text.startswith('\ufeff'):
text = text[1:]
# Null bytes screw it up
if '\x00' in text:
text = text[:text.index('\x00')]
# EAC crashes if there are more than 2^14 bytes in a line
if any(len(l) + 1 > 2**13 for l in text.split('\n')):
raise RuntimeError('EAC cannot handle lines longer than 2^13 chars')
unsigned_text, version, old_signature = extract_info(text)
return unsigned_text, version, old_signature, eac_checksum(unsigned_text)
class FixedFileType(argparse.FileType):
def __call__(self, string):
file = super().__call__(string)
# Properly handle stdin/stdout with 'b' mode
if 'b' in self._mode and file in (sys.stdin, sys.stdout):
return file.buffer
return file
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Verifies and resigns EAC logs')
subparsers = parser.add_subparsers(dest='command', required=True)
verify_parser = subparsers.add_parser('verify', help='verify a log')
verify_parser.add_argument('files', type=FixedFileType(mode='rb'), nargs='+', help='input log file(s)')
sign_parser = subparsers.add_parser('sign', help='sign or fix an existing log')
sign_parser.add_argument('--force', action='store_true', help='forces signing even if EAC version is too old')
sign_parser.add_argument('input_file', type=FixedFileType(mode='rb'), help='input log file')
sign_parser.add_argument('output_file', type=FixedFileType(mode='wb'), help='output log file')
args = parser.parse_args()
if args.command == 'sign':
with contextlib.closing(args.input_file) as handle:
try:
data, version, old_signature, actual_signature = eac_verify(handle.read())
except ValueError as e:
print(args.input_file, ': ', e, sep='')
sys.exit(1)
if not args.force and (version is None or version < CHECKSUM_MIN_VERSION):
raise ValueError('EAC version is too old to be signed')
data += f'\r\n\r\n==== Log checksum {actual_signature} ====\r\n'
with contextlib.closing(args.output_file or args.input_file) as handle:
handle.write(b'\xff\xfe' + data.encode('utf-16le'))
elif args.command == 'verify':
max_length = max(len(f.name) for f in args.files)
for file in args.files:
prefix = (file.name + ':').ljust(max_length + 2)
with contextlib.closing(file) as handle:
try:
data, version, old_signature, actual_signature = eac_verify(handle.read())
except RuntimeError as e:
print(prefix, e)
continue
except ValueError as e:
print(prefix, 'Not a log file')
continue
if version is None:
print(prefix, 'Not a log file')
elif old_signature is None:
print(prefix, 'Log file without a signature')
elif old_signature != actual_signature:
print(prefix, 'Malformed')
elif version < CHECKSUM_MIN_VERSION:
print(prefix, 'Forged')
else:
print(prefix, 'OK')