|
10 | 10 | The E01, AFF, AFD, Apple disk image and split-segment paths are exercised by |
11 | 11 | wrapping the same NTFS fixture at test time: small EWF, AFF, UDIF and sparse image |
12 | 12 | writers live in this file (copied from ewfprobe's own test suite, MIT), and a split |
13 | | -set is the raw image cut into numbered pieces. |
| 13 | +set is the raw image cut into numbered pieces. FTK Imager's AD encryption is written |
| 14 | +here too, around a raw set and an E01 set, with the cipher library's CTR mode. |
14 | 15 | The expected values are the fixture's, written out, never read back from the |
15 | 16 | seeker. |
16 | 17 | """ |
@@ -339,6 +340,38 @@ def write_encrypted_sparsebundle(folder, data, band=1 << 20): |
339 | 340 | return folder |
340 | 341 |
|
341 | 342 |
|
| 343 | +AD_PASSWORD = 'raw-image-ad-password' |
| 344 | + |
| 345 | + |
| 346 | +def write_adcrypt(plain_files, out_files, password=AD_PASSWORD): |
| 347 | + """Each of ``plain_files`` encrypted into ``out_files`` as FTK Imager's AD |
| 348 | + encryption writes a set: one AES-256 key, the 512-byte header in the first file |
| 349 | + only, file i in CTR mode from counter i << 64, the counter little endian, and the |
| 350 | + key encrypted under PBKDF2-HMAC-SHA1 of the password's SHA-512.""" |
| 351 | + import hmac # pylint: disable=import-outside-toplevel |
| 352 | + from Crypto.Cipher import AES # pylint: disable=import-outside-toplevel |
| 353 | + from Crypto.Util import Counter # pylint: disable=import-outside-toplevel |
| 354 | + |
| 355 | + def ctr(key, data, first): |
| 356 | + counter = Counter.new(128, initial_value=first, little_endian=True) |
| 357 | + return AES.new(key, AES.MODE_CTR, counter=counter).encrypt(data) |
| 358 | + |
| 359 | + rng = __import__('random').Random(9) |
| 360 | + file_key, salt = rng.randbytes(32), rng.randbytes(16) |
| 361 | + made = hashlib.pbkdf2_hmac('sha1', hashlib.sha512(password.encode()).digest(), salt, |
| 362 | + 1000, 32) |
| 363 | + wrapped = ctr(made, file_key, 0) |
| 364 | + header = (struct.pack('<8sIIhhh2sIIIIII', b'ADCRYPT\x00', 1, 512, -1, -1, -1, |
| 365 | + b'\x00\x00', 3, 2, 1000, 16, 32, 64) |
| 366 | + + salt + wrapped + hmac.new(made, wrapped, 'sha512').digest()).ljust(512, b'\0') |
| 367 | + for index, (src, dst) in enumerate(zip(plain_files, out_files)): |
| 368 | + with open(src, 'rb') as handle: |
| 369 | + body = ctr(file_key, handle.read(), index << 64) |
| 370 | + with open(dst, 'wb') as handle: |
| 371 | + handle.write((header if index == 0 else b'') + body) |
| 372 | + return out_files |
| 373 | + |
| 374 | + |
342 | 375 | def with_mbr(volume_bytes, start_lba=2048): |
343 | 376 | """The volume behind an MBR whose one entry covers it in full, as a disk carries it.""" |
344 | 377 | entry = bytearray(16) |
@@ -948,6 +981,68 @@ def test_a_damaged_encrypted_dmg_is_reported_not_asked_about_again(self): |
948 | 981 | with mock.patch.dict(os.environ, {'RAW_IMAGE_TEST_PW': ENC_PASSWORD}): |
949 | 982 | raw_image.cli_image_password(path, password_env='RAW_IMAGE_TEST_PW') |
950 | 983 |
|
| 984 | + def _ad_raw_set(self): |
| 985 | + """The NTFS fixture as a raw set of two files FTK Imager encrypted.""" |
| 986 | + with open(self.ntfs, 'rb') as handle: |
| 987 | + data = handle.read() |
| 988 | + folder = tempfile.mkdtemp(prefix='raw_image_ad_', dir=self.work) |
| 989 | + half = len(data) // 2 // 512 * 512 |
| 990 | + plain = [] |
| 991 | + for index, piece in enumerate((data[:half], data[half:]), start=1): |
| 992 | + plain.append(os.path.join(folder, f'plain.{index:03d}')) |
| 993 | + with open(plain[-1], 'wb') as handle: |
| 994 | + handle.write(piece) |
| 995 | + out = [os.path.join(folder, f'evidence.{index:03d}') for index in (1, 2)] |
| 996 | + return write_adcrypt(plain, out) |
| 997 | + |
| 998 | + def test_an_ad_encrypted_raw_set_reads_with_its_password_from_either_file(self): |
| 999 | + first, second = self._ad_raw_set() |
| 1000 | + self.assertTrue(raw_image.needs_password(first)) |
| 1001 | + self.assertTrue(raw_image.needs_password(second)) |
| 1002 | + seeker = FileSeekerRaw(second, self.data, password=AD_PASSWORD) |
| 1003 | + self.addCleanup(seeker.cleanup) |
| 1004 | + text = self.log.text() |
| 1005 | + self.assertIn('a raw (dd) image of 2 segments, joined by the reader: evidence.001 ' |
| 1006 | + '.. evidence.002, encrypted (AES-256-CTR) and opened with its password', |
| 1007 | + text) |
| 1008 | + self.assertNotIn('one segment of a split image', text) |
| 1009 | + self.assertNotIn('segments joined in order', text) |
| 1010 | + self.assertEqual(seeker.name_list, self._seeker(self.ntfs).name_list) |
| 1011 | + for rel in ('many/file_0007.txt', 'many/file_0400.txt'): |
| 1012 | + found = seeker.search(f'*/{rel}') |
| 1013 | + self.assertEqual(_sha256(found[0]), self.ntfs_hashes[rel]) |
| 1014 | + self.assertNotIn(AD_PASSWORD, repr(vars(seeker))) |
| 1015 | + |
| 1016 | + def test_an_ad_encrypted_e01_set_reads_with_its_password(self): |
| 1017 | + with open(self.ntfs, 'rb') as handle: |
| 1018 | + data = handle.read() |
| 1019 | + folder = tempfile.mkdtemp(prefix='raw_image_ad_e01_', dir=self.work) |
| 1020 | + plain = write_ewf(folder, 'plain', data, chunks_per_segment=200) |
| 1021 | + self.assertGreater(len(plain), 1) |
| 1022 | + out = write_adcrypt(plain, [p.replace('plain.', 'evidence.') for p in plain]) |
| 1023 | + self.assertTrue(raw_image.needs_password(out[0])) |
| 1024 | + seeker = FileSeekerRaw(out[0], self.data, password=AD_PASSWORD) |
| 1025 | + self.addCleanup(seeker.cleanup) |
| 1026 | + self.assertIn(f'an EWF acquisition of {len(out)} segments', self.log.text()) |
| 1027 | + found = seeker.search('*/many/file_0007.txt') |
| 1028 | + self.assertEqual(_sha256(found[0]), self.ntfs_hashes['many/file_0007.txt']) |
| 1029 | + |
| 1030 | + def test_an_ad_encrypted_set_without_its_password_is_refused_as_such(self): |
| 1031 | + first, _second = self._ad_raw_set() |
| 1032 | + with self.assertRaises(qnxprobe.ImagePasswordError) as caught: |
| 1033 | + FileSeekerRaw(first, self.data) |
| 1034 | + self.assertFalse(caught.exception.wrong) |
| 1035 | + with self.assertRaises(qnxprobe.ImagePasswordError) as caught: |
| 1036 | + FileSeekerRaw(first, self.data, password='not it') |
| 1037 | + self.assertTrue(caught.exception.wrong) |
| 1038 | + with mock.patch.object(raw_image.sys, 'stdin', None): |
| 1039 | + with self.assertRaisesRegex(ValueError, 'evidence.001 is an acquisition FTK Imager ' |
| 1040 | + 'encrypted with AD encryption and opens only'): |
| 1041 | + raw_image.cli_image_password(first) |
| 1042 | + with mock.patch.dict(os.environ, {'RAW_IMAGE_TEST_PW': AD_PASSWORD}): |
| 1043 | + self.assertEqual(raw_image.cli_image_password(first, password_env='RAW_IMAGE_TEST_PW'), |
| 1044 | + AD_PASSWORD) |
| 1045 | + |
951 | 1046 | def test_an_l01_is_refused_as_logical_evidence(self): |
952 | 1047 | folder = tempfile.mkdtemp(prefix='raw_image_l01_', dir=self.work) |
953 | 1048 | path = os.path.join(folder, 'evidence.L01') |
|
0 commit comments