Iterator[Run]: + current_type = None + current_sector = None + current_count = 0 + + for run_type, sector, count in it: + if current_type is None: + current_type = run_type + current_sector = sector + current_count = count + continue + + if current_type != run_type or current_sector + current_count != sector: + yield (current_type, current_sector, current_count) + + current_type = run_type + current_sector = sector + current_count = count + else: + current_count += count + + if current_type is not None: + yield (current_type, current_sector, current_count) + + +def is_bde_volume(fh: BinaryIO) -> bool: + stored_position = fh.tell() + try: + fh.seek(0) + BootSector(fh) + return True + except ValueError: + return False + finally: + fh.seek(stored_position) diff --git a/dissect/fve/bde/c_bde.py b/dissect/fve/bde/c_bde.py new file mode 100644 index 0000000..b766fc1 --- /dev/null +++ b/dissect/fve/bde/c_bde.py @@ -0,0 +1,377 @@ +from uuid import UUID + +from dissect.cstruct import cstruct + +bde_def = """ +/* ======== Volume header information ======== */ + +typedef struct _FVE_GUID_RECOGNITION { + CHAR Guid[16]; + QWORD InformationOffset[3]; +} FVE_GUID_RECOGNITION; + +typedef struct _FVE_EOW_GUID_RECOGNITION { + CHAR Guid[16]; + QWORD InformationOffset[3]; + QWORD EowOffset[2]; +} FVE_EOW_GUID_RECOGNITION; + +typedef struct _BIOS_PARAMETER_BLOCK { + USHORT BytesPerSector; + UCHAR SectorsPerCluster; + USHORT ReservedSectors; + UCHAR Fats; + USHORT RootEntries; + USHORT Sectors; + UCHAR Media; + USHORT SectorsPerFat; + USHORT SectorsPerTrack; + USHORT Heads; + ULONG HiddenSectors; + ULONG LargeSectors; +} BIOS_PARAMETER_BLOCK; + +typedef struct _BOOT_SECTOR { + CHAR Jump[3]; + CHAR Oem[8]; + BIOS_PARAMETER_BLOCK Bpb; + CHAR Unused0[20]; + union { + ULONG64 InformationLcn; + ULONG64 Mft2StartLcn; + }; + CHAR Unused1[8]; + ULONG64 PartitionLength; + CHAR Unused2[28]; + UCHAR BytesPerSectorShift; + UCHAR SectorsPerClusterShift; + CHAR Unused3[402]; +} BOOT_SECTOR; + +/* ======== FVE information and dataset ======== */ + +enum FVE_STATE { + DECRYPTED = 1, /* Decrypted state */ + SWITCHING_DIRTY = 2, /* In-progress encryption or decryption of large volumes */ + /* StateSize will be non-zero, and there will be a conversion log */ + PAUSED = 3, /* Seen on Vista volume with paused encryption/decryption */ + ENCRYPTED = 4, /* The most common state */ + SWITCHING = 5, /* In-progress encryption or decryption of small volumes */ + /* Seen when detaching VHD during encryption/decryption of small disks */ +}; + +enum FVE_KEY_TYPE { + NONE = 0x0000, + EXTERNAL = 0x0005, /* External VMKs have a USE_KEY with this key type */ + + STRETCH_KEY = 0x1000, + STRETCH_KEY_1 = 0x1001, + AES_CCM_256_0 = 0x2000, + AES_CCM_256_1 = 0x2001, + EXTERN_KEY = 0x2002, + VMK = 0x2003, + AES_CCM_256_2 = 0x2004, + HASH_256 = 0x2005, + + AES_128_DIFFUSER = 0x8000, + AES_256_DIFFUSER = 0x8001, + AES_128 = 0x8002, + AES_256 = 0x8003, + AES_XTS_128 = 0x8004, + AES_XTS_256 = 0x8005, +}; + +flag FVE_KEY_PROTECTOR { + CLEAR = 0x0000, /* Also known as "obfuscated" */ + TPM = 0x0100, + EXTERNAL = 0x0200, /* Startup key */ + TPM_PIN = 0x0400, + RECOVERY_PASSWORD = 0x0800, /* Recovery password */ + PASSPHRASE = 0x2000, /* User passphrase */ +}; + +flag FVE_KEY_FLAG { + NONE = 0x00, + ENHANCED_PIN = 0x04, + ENHANCED_CRYPTO = 0x10, + PBKDF2 = 0x40, +}; + +enum FVE_DATUM_ROLE : USHORT { + PROPERTY = 0x0000, + + UNKNOWN_1 = 0x0001, + + VOLUME_MASTER_KEY_INFO = 0x0002, + FULL_VOLUME_ENCRYPTION_KEY = 0x0003, + VALIDATION = 0x0004, + + UNKNOWN_5 = 0x0005, + + STARTUP_KEY = 0x0006, + DESCRIPTION = 0x0007, + + UNKNOWN_8 = 0x0008, + UNKNOWN_9 = 0x0009, + UNKNOWN_A = 0x000A, + AUTO_UNLOCK = 0x000B, + FULL_VOLUME_ENCRYPTION_KEY_2 = 0x000C, + UNKNOWN_D = 0x000D, + UNKNOWN_E = 0x000E, + + VIRTUALIZATION_INFO = 0x000F, + VALIDATION_HASH = 0x0011, +}; + +enum FVE_DATUM_TYPE : USHORT { + ERASED = 0x0000, + KEY = 0x0001, + UNICODE = 0x0002, + STRETCH_KEY = 0x0003, + USE_KEY = 0x0004, + AES_CCM_ENCRYPTED_KEY = 0x0005, + TPM_ENCRYPTED_BLOB = 0x0006, + VALIDATION_INFO = 0x0007, + VOLUME_MASTER_KEY_INFO = 0x0008, + EXTERNAL_INFO = 0x0009, + UPDATE = 0x000A, + ERROR_LOG = 0x000B, + ASYMMETRIC_ENCRYPTED_KEY = 0x000C, + EXPORTED_KEY = 0x000D, + PUBLIC_KEY_INFO = 0x000E, + VIRTUALIZATION_INFO = 0x000F, + SIMPLE_1 = 0x0010, + SIMPLE_2 = 0x0011, + CONCAT_HASH_KEY = 0x0012, + SIMPLE_3 = 0x0013, + SIMPLE_LARGE = 0x0014, + BACKUP_INFO = 0x0015, +}; + +typedef struct _FVE_INFORMATION { + CHAR Signature[8]; + USHORT HeaderSize; + USHORT Version; + USHORT CurrentState; + USHORT NextState; + ULONG64 StateOffset; + ULONG StateSize; + ULONG VirtualizedSectors; + ULONG64 InformationOffset[3]; + union { + ULONG64 Mft2StartLcn; + ULONG64 VirtualizedBlockOffset; + }; +} FVE_INFORMATION; + +typedef struct _FVE_DATASET { + ULONG Size; + ULONG Version; + ULONG StartOffset; + ULONG EndOffset; + CHAR Identification[16]; + ULONG NonceCounter; + USHORT FvekType; + USHORT _Unknown; + ULONG64 CreationTime; +} FVE_DATASET; + +typedef struct _FVE_DATUM { + USHORT Size; + USHORT Role; + USHORT Type; + USHORT Flags; +} FVE_DATUM; + +typedef struct _FVE_VALIDATION { + USHORT Size; + USHORT Version; + ULONG Crc32; + // FVE_DATUM IntegrityCheck; +} FVE_VALIDATION; + +/* ======== FVE datums ======== */ + +typedef struct _FVE_DATUM_SIMPLE { + ULONG Data; +} FVE_DATUM_SIMPLE; + +typedef struct _FVE_DATUM_SIMPLE_LARGE { + ULONG64 Data; +} FVE_DATUM_SIMPLE_LARGE; + +typedef struct _FVE_DATUM_GUID { + CHAR Guid[16]; +} FVE_DATUM_GUID; + +typedef struct _FVE_DATUM_KEY { + USHORT KeyType; + USHORT KeyFlags; + // CHAR Data[]; +} FVE_DATUM_KEY; + +typedef struct _FVE_DATUM_UNICODE { + // wchar Text[]; +} FVE_DATUM_UNICODE; + +typedef struct _FVE_DATUM_STRETCH_KEY { + USHORT KeyType; + USHORT KeyFlags; + CHAR Salt[16]; +} FVE_DATUM_STRETCH_KEY; + +typedef struct _FVE_DATUM_USE_KEY { + USHORT KeyType; + USHORT KeyFlags; +} FVE_DATUM_USE_KEY; + +typedef struct _FVE_NONCE { + ULONG64 DateTime; + ULONG Counter; +} FVE_NONCE; + +typedef struct _FVE_DATUM_AESCCM_ENC { + FVE_NONCE Nonce; + CHAR MAC[16]; + // CHAR Data[]; +} FVE_DATUM_AESCCM_ENC; + +typedef struct _FVE_DATUM_TPM_ENC_BLOB { + ULONG PcrBitmap; + // CHAR Data[]; +} FVE_DATUM_TPM_ENC_BLOB; + +typedef struct _FVE_DATUM_VALIDATION_ENTRY { + ULONG _Unknown1; + ULONG _Unknown2; + CHAR Hash[32]; +} FVE_DATUM_VALIDATION_ENTRY; + +typedef struct _FVE_DATUM_VALIDATION_INFO { + // FVE_DATUM_VALIDATION_ENTRY AllowList[]; +} FVE_DATUM_VALIDATION_INFO; + +typedef struct _FVE_DATUM_VMK_INFO { + CHAR Identifier[16]; + ULONG64 DateTime; + USHORT _Unknown1; + USHORT Priority; +} FVE_DATUM_VMK_INFO; + +typedef struct _FVE_DATUM_EXTERNAL_INFO { + CHAR Identifier[16]; + ULONG64 DateTime; +} FVE_DATUM_EXTERNAL_INFO; + +typedef struct _FVE_DATUM_UPDATE { + // Unknown +} FVE_DATUM_UPDATE; + +typedef struct _FVE_DATUM_ERROR_LOG { + // Unknown +} FVE_DATUM_ERROR_LOG; + +typedef struct _FVE_DATUM_ASYM_ENC_BLOB { + // CHAR Data[]; +} FVE_DATUM_ASYM_ENC_BLOB; + +typedef struct _FVE_DATUM_EXPORTED_PUBLIC_KEY { + // CHAR Data[]; +} FVE_DATUM_EXPORTED_PUBLIC_KEY; + +typedef struct _FVE_DATUM_PUBLIC_KEY_INFO { + // CHAR Data[]; +} FVE_DATUM_PUBLIC_KEY_INFO; + +typedef struct _FVE_DATUM_VIRTUALIZATION_INFO { + ULONG64 VirtualizedBlockOffset; + ULONG64 VirtualizedBlockSize; +} FVE_DATUM_VIRTUALIZATION_INFO; + +typedef struct _FVE_DATUM_CONCAT_HASH_KEY { + // Unknown +} FVE_DATUM_CONCAT_HASH_KEY; + +typedef struct _FVE_DATUM_BACKUP_INFO { + // Unknown +} FVE_DATUM_BACKUP_INFO; + +typedef struct _FVE_DATUM_AESCBC256_HMAC_SHA512_ENC { + CHAR Iv[16]; + CHAR Mac[64]; + // CHAR Data[]; +} FVE_DATUM_AESCBC256_HMAC_SHA512_ENC; + +/* ======== EOW structures ======== */ + +typedef struct _FVE_EOW_INFORMATION { + CHAR HeaderSignature[8]; + USHORT HeaderSize; + USHORT Size; + ULONG SectorSize; + ULONG _Unknown1; + ULONG ChunkSize; + ULONG ConvLogSize; + ULONG _Unknown2; + ULONG RegionCount; + ULONG Crc32; + ULONG64 EowOffset[2]; + ULONG64 BitmapOffsets[(Size - HeaderSize) / 8]; +} FVE_EOW_INFORMATION; + +typedef struct _FVE_EOW_BITMAP { + CHAR HeaderSignature[10]; + USHORT HeaderSize; + ULONG Size; + ULONG _Unknown1; + ULONG64 RegionOffset; + ULONG64 RegionSize; + ULONG64 ConvLogOffset; + ULONG RecordOffset[2]; + ULONG RecordSize; + ULONG Crc32; +} FVE_EOW_BITMAP; + +typedef struct _FVE_EOW_BITMAP_RECORD { + CHAR HeaderSignature[10]; + USHORT HeaderSize; + ULONG Size; + ULONG BitmapSize; + ULONG64 SequenceNumber; + ULONG Flags; + ULONG Crc32; + // ULONG Bitmap[]; +} FVE_EOW_BITMAP_RECORD; +""" + +c_bde = cstruct().load(bde_def) + +FVE_STATE = c_bde.FVE_STATE +FVE_KEY_TYPE = c_bde.FVE_KEY_TYPE +FVE_KEY_FLAG = c_bde.FVE_KEY_FLAG +FVE_KEY_PROTECTOR = c_bde.FVE_KEY_PROTECTOR + +FVE_DATUM_ROLE = c_bde.FVE_DATUM_ROLE +FVE_DATUM_TYPE = c_bde.FVE_DATUM_TYPE + +# Volume signatures +BITLOCKER_SIGNATURE = b"-FVE-FS-" +BITLOCKER_TO_GO_SIGNATURE = b"MSWIN4.1" + +EOW_SIGNATURE = b"FVE-EOW\x00" +EOW_BM_SIGNATURE = b"FVE-EOWBM\x00" +EOW_BR_SIGNATURE = b"FVE-EOWBR\x00" + +CONV_MAGIC = b"FVEHDRLO"[::-1] + +INFORMATION_OFFSET_GUID = UUID("4967d63b-2e29-4ad8-8399-f6a339e3d001") +EOW_INFORMATION_OFFSET_GUID = UUID("92a84d3b-dd80-4d0e-9e4e-b1e3284eaed8") + +CIPHER_MAP = { + FVE_KEY_TYPE.AES_128_DIFFUSER: "aes-cbc-128-elephant", + FVE_KEY_TYPE.AES_256_DIFFUSER: "aes-cbc-256-elephant", + FVE_KEY_TYPE.AES_128: "aes-cbc-128-eboiv", + FVE_KEY_TYPE.AES_256: "aes-cbc-256-eboiv", + FVE_KEY_TYPE.AES_XTS_128: "aes-xts-128-plain64", + FVE_KEY_TYPE.AES_XTS_256: "aes-xts-256-plain64", +} diff --git a/dissect/fve/bde/eow.py b/dissect/fve/bde/eow.py new file mode 100644 index 0000000..19bd49d --- /dev/null +++ b/dissect/fve/bde/eow.py @@ -0,0 +1,219 @@ +# References: +# - fvevol.sys + +from __future__ import annotations + +from binascii import crc32 +from functools import cached_property +from io import BytesIO +from typing import BinaryIO, Iterator + +from dissect.fve.bde.c_bde import ( + EOW_BM_SIGNATURE, + EOW_BR_SIGNATURE, + EOW_SIGNATURE, + c_bde, +) +from dissect.fve.exceptions import InvalidHeaderError + + +class EowInformation: + """Bitlocker EOW Information.""" + + def __init__(self, fh: BinaryIO, offset: int): + self.fh = fh + self.offset = offset + fh.seek(offset) + + self.header = c_bde.FVE_EOW_INFORMATION(fh) + if self.header.HeaderSignature != EOW_SIGNATURE: + raise InvalidHeaderError("Invalid EOW information signature") + + _crc32 = self.header.Crc32 + self.header.Crc32 = 0 + self._valid_checksum = crc32(self.header.dumps()) == _crc32 + self.header.Crc32 = _crc32 + + def is_valid(self) -> bool: + return self._valid_checksum + + @property + def size(self) -> int: + return self.header.Size + + @property + def chunk_size(self) -> int: + return self.header.ChunkSize + + @property + def conv_log_size(self) -> int: + return self.header.ConvLogSize + + @cached_property + def bitmaps(self) -> list[EowBitmap]: + result = [] + + for offset in self.header.BitmapOffsets: + result.append(EowBitmap(self.fh, offset)) + + return result + + +class EowBitmap: + """Bitlocker EOW Bitmap. + + A bitmap contains multiple bitmap records, but only one record is active. The active record is + determined by the Lsn field in the header. The record with the highest Lsn is the active record. + + It looks like the number of bitmap records is hardcoded to 2, but let's keep the implementation + flexible. + """ + + def __init__(self, fh: BinaryIO, offset: int): + self.fh = fh + self.offset = offset + fh.seek(offset) + + self.header = c_bde.FVE_EOW_BITMAP(fh) + if self.header.HeaderSignature != EOW_BM_SIGNATURE: + raise ValueError("Invalid EOW bitmap signature") + + _crc32 = self.header.Crc32 + self.header.Crc32 = 0 + remainder = fh.read(self.header.RecordOffset[0] - self.header.HeaderSize) + self._valid_checksum = crc32(self.header.dumps() + remainder) == _crc32 + self.header.Crc32 = _crc32 + + self._record_data = fh.read(self.header.Size - self.header.RecordOffset[0]) + + def __repr__(self) -> str: + return f"" + + def is_valid(self) -> bool: + return self._valid_checksum + + def runs(self, chunk: int, length: int) -> Iterator[tuple[int, int]]: + yield from self.active_record.runs(chunk, length) + + @property + def size(self) -> int: + return self.header.Size + + @property + def region_offset(self) -> int: + return self.header.RegionOffset + + @property + def region_size(self) -> int: + return self.header.RegionSize + + @property + def conv_log_offset(self) -> int: + return self.header.ConvLogOffset + + @cached_property + def active_record(self) -> EowBitmapRecord: + latest_record = None + + for record in self.records: + if latest_record is None: + latest_record = record + continue + + if record.sequence_number > latest_record.sequence_number: + latest_record = record + + return latest_record + + @cached_property + def records(self) -> list[EowBitmapRecord]: + result = [] + + buf = BytesIO(self._record_data) + base = self.header.RecordOffset[0] + for offset in self.header.RecordOffset: + buf.seek(offset - base) + result.append(EowBitmapRecord(buf)) + + return result + + +class EowBitmapRecord: + """Bitlocker EOW Bitmap Record. + + The record holding the actual bitmap. Each bit indicates a chunk with the size defined by + the EOW information. The Lsn is the sequence number of that record. + + The flags are currently unknown, but seem related to an encrypted/decrypted state. + """ + + def __init__(self, fh: BinaryIO): + self.fh = fh + self.header = c_bde.FVE_EOW_BITMAP_RECORD(fh) + if self.header.HeaderSignature != EOW_BR_SIGNATURE: + raise ValueError("Invalid EOW bitmap record signature") + + _crc32 = self.header.Crc32 + self.header.Crc32 = 0 + self._data = memoryview(fh.read(self.header.Size - self.header.HeaderSize)) + self._valid_checksum = crc32(self.header.dumps() + self._data) == _crc32 + self.header.Crc32 = _crc32 + + def __repr__(self) -> str: + return f" " + + def is_valid(self) -> bool: + return self._valid_checksum + + def runs(self, chunk: int, length: int) -> Iterator[tuple[int, int]]: + yield from _iter_bitmap(self.bitmap, self.bitmap_size, chunk, length) + + @property + def size(self) -> int: + return self.header.Size + + @property + def bitmap(self) -> bytes: + return self._data + + @property + def bitmap_size(self) -> int: + return self.header.BitmapSize + + @property + def sequence_number(self) -> int: + return self.header.SequenceNumber + + +def _iter_bitmap(bitmap: bytes, size: int, start: int, count: int) -> Iterator[tuple[int, int]]: + byte_idx, bit_idx = divmod(start, 8) + remaining_bits = size - start + current_bit = (bitmap[byte_idx] & (1 << bit_idx)) >> bit_idx + current_count = 0 + + for byte in bitmap[byte_idx:]: + if count == 0 or remaining_bits == 0: + break + + if (current_bit, byte) == (0, 0) or (current_bit, byte) == (1, 0xFF): + max_count = min(count, remaining_bits, 8 - bit_idx) + current_count += max_count + remaining_bits -= max_count + count -= max_count + bit_idx = 0 + else: + for cur_bit_idx in range(bit_idx, min(count, remaining_bits, 8)): + bit_set = (byte & (1 << cur_bit_idx)) >> cur_bit_idx + + if bit_set == current_bit: + current_count += 1 + else: + yield (current_bit, current_count) + current_bit = bit_set + current_count = 1 + + remaining_bits -= 1 + count -= 1 + + if current_count: + yield (current_bit, current_count) diff --git a/dissect/fve/bde/information.py b/dissect/fve/bde/information.py new file mode 100644 index 0000000..03b023d --- /dev/null +++ b/dissect/fve/bde/information.py @@ -0,0 +1,730 @@ +from __future__ import annotations + +import datetime +import hashlib +from binascii import crc32 +from functools import cached_property +from io import BytesIO +from typing import BinaryIO, Iterator +from uuid import UUID + +from Crypto.Cipher import AES +from dissect.util import ts + +from dissect.fve.bde.c_bde import ( + BITLOCKER_SIGNATURE, + FVE_DATUM_ROLE, + FVE_DATUM_TYPE, + FVE_KEY_FLAG, + FVE_KEY_PROTECTOR, + FVE_KEY_TYPE, + FVE_STATE, + c_bde, +) +from dissect.fve.exceptions import InvalidHeaderError + + +class Information: + """Bitlocker Information. + + Parses Bitlocker Information and Dataset at a specified offset. + + Bitlocker Information consists of a small header, a Dataset and at least a CRC32 validation check. + The CRC32 Validation information is positioned after the Information buffer. + + The ``StateOffset`` field contains the offset to a conversion log, but it also doubles as a "watermark", + containing the offset up until where the Bitlocker encryption is active. + The conversion log as pointed to by the ``StateOffset`` seems to only be used by older Bitlocker + implementations. It looks like more modern implementations (Windows 10+) seem to prefer EOW. + """ + + def __init__(self, fh: BinaryIO, offset: int): + self.offset = offset + fh.seek(offset) + + self.header = c_bde.FVE_INFORMATION(fh) + if self.header.Signature != BITLOCKER_SIGNATURE: + raise InvalidHeaderError("Invalid BDE information signature") + + # Datums are lazily parsed so we can safely parse the dataset header + self.dataset = Dataset(fh) + + fh.seek(offset) + self._buf = fh.read(self.size) + + self.validation = Validation(fh) + self._valid_checksum = crc32(self._buf) == self.validation.crc32 + + def __repr__(self) -> str: + return ( + f"<{self.__class__.__name__} " + f"offset=0x{self.offset:x} current_state={self.current_state} next_state={self.next_state}>" + ) + + def is_valid(self) -> bool: + """Validate the integrity of this Information block.""" + # TODO add sha256 check + return self._valid_checksum + + def check_integrity(self, key: KeyDatum | bytes) -> bool: + """Check the integrity of this Information block.""" + if self.validation.integrity_check: + datum = self.validation.integrity_check.unbox(key) + return hashlib.sha256(self._buf).digest() == datum.data + return self.is_valid() + + @property + def size(self) -> int: + stored_size = self.header.HeaderSize + if self.version >= 2: + stored_size <<= 4 + return stored_size + + @property + def version(self) -> int: + return self.header.Version + + @property + def current_state(self) -> FVE_STATE: + return FVE_STATE(self.header.CurrentState) + + @property + def next_state(self) -> FVE_STATE: + return FVE_STATE(self.header.NextState) + + @property + def state_offset(self) -> int: + return self.header.StateOffset + + @property + def state_size(self) -> int: + return self.header.StateSize + + @property + def virtualized_sectors(self) -> int: + return self.header.VirtualizedSectors + + @property + def virtualized_block_offset(self) -> int: + return self.header.VirtualizedBlockOffset + + @property + def information_offset(self) -> list[int]: + return self.header.InformationOffset + + +class Validation: + """Bitlocker Information Validation. + + The Validation structure is a small piece of data positioned after the Information buffer. + It contains a CRC32 value of the entire Information buffer. It also contains an integrity check + datum, which is a AES-CCM encrypted datum, encrypted with the same key that decrypts the FVEK. + Decrypting the integrity check yields you a SHA256 digest, which must match the entire Information buffer. + """ + + def __init__(self, fh: BinaryIO): + self.validation = c_bde.FVE_VALIDATION(fh) + self.integrity_check = None + if self.version >= 2: # I think + self.integrity_check = Datum.from_fh(fh) + + @property + def version(self) -> int: + return self.validation.Version + + @property + def crc32(self) -> int: + return self.validation.Crc32 + + +class Dataset: + """Bitlocker Information Dataset. + + The dataset is a simple data structure, consisting of a small header and one or more "datum". + Each datum has a role and type, and you can query the dataset for datums with a specific role or type. + Querying the dataset means iterating the datum array until you found the datum you're looking for. + """ + + def __init__(self, fh: BinaryIO): + offset = fh.tell() + self.header = c_bde.FVE_DATASET(fh) + self.identifier = UUID(bytes_le=self.header.Identification) + + fh.seek(offset) + self._buf = fh.read(self.header.Size) + + def __iter__(self) -> Iterator[Datum]: + yield from self.data + + @cached_property + def data(self) -> list[Datum]: + """Return the list of Datum in this Dataset.""" + result = [] + + buf = BytesIO(memoryview(self._buf)[self.header.StartOffset :]) + remaining = self.header.EndOffset - self.header.StartOffset + while remaining >= Datum.MINIMAL_SIZE: + datum = Datum.from_fh(buf) + result.append(datum) + + remaining -= datum.size + + return result + + @property + def fvek_type(self) -> FVE_KEY_TYPE: + return FVE_KEY_TYPE(self.header.FvekType) + + def find_datum(self, role: FVE_DATUM_ROLE, type_: FVE_DATUM_TYPE) -> Iterator[Datum]: + """Find one or more datum specified by role and type.""" + for datum in self: + if (datum.role == role or role is None) and (datum.type == type_ or type_ is None): + yield datum + + def find_description(self) -> str | None: + """Find the description datum.""" + for datum in self.find_datum(FVE_DATUM_ROLE.DESCRIPTION, FVE_DATUM_TYPE.UNICODE): + return datum.text + + def find_virtualization_info(self) -> VirtualizationInfoDatum | None: + """Find the virtualization info datum.""" + for datum in self.find_datum(FVE_DATUM_ROLE.VIRTUALIZATION_INFO, FVE_DATUM_TYPE.VIRTUALIZATION_INFO): + return datum + + def find_startup_key(self) -> ExternalInfoDatum | None: + """Find the external startup/recovery key information.""" + for datum in self.find_datum(FVE_DATUM_ROLE.STARTUP_KEY, FVE_DATUM_TYPE.EXTERNAL_INFO): + return datum + + def find_fvek(self) -> AesCcmEncryptedDatum | None: + """Find the encrypted FVEK.""" + for datum in self.find_datum(FVE_DATUM_ROLE.FULL_VOLUME_ENCRYPTION_KEY, FVE_DATUM_TYPE.AES_CCM_ENCRYPTED_KEY): + return datum + + def find_vmk( + self, + protector_type: FVE_KEY_PROTECTOR | None = None, + min_priority: int = 0x0000, + max_priority: int = 0x7FFF, + mask: int = 0xFF00, + ) -> Iterator[VmkInfoDatum]: + """Find one or more VMK datum specified by key priority.""" + for datum in self.find_datum(FVE_DATUM_ROLE.VOLUME_MASTER_KEY_INFO, FVE_DATUM_TYPE.VOLUME_MASTER_KEY_INFO): + if datum.priority.value < min_priority or datum.priority.value > max_priority: + continue + + if protector_type is None or datum.priority & mask == protector_type: + yield datum + + def find_clear_vmk(self) -> VmkInfoDatum | None: + """Find the clear key VMK (for paused volumes).""" + for vmk in self.find_vmk(FVE_KEY_PROTECTOR.CLEAR, max_priority=0xFF, mask=0x0000): + return vmk + + def find_external_vmk(self) -> Iterator[VmkInfoDatum]: + """Find the external VMK.""" + yield from self.find_vmk(FVE_KEY_PROTECTOR.EXTERNAL) + + def find_recovery_vmk(self) -> Iterator[VmkInfoDatum]: + """Find the recovery VMK.""" + yield from self.find_vmk(FVE_KEY_PROTECTOR.RECOVERY_PASSWORD) + + def find_passphrase_vmk(self) -> Iterator[VmkInfoDatum]: + """Find the passphrase VMK.""" + yield from self.find_vmk(FVE_KEY_PROTECTOR.PASSPHRASE) + + +class Datum: + """Bitlocker Dataset Datum. + + A Datum is the main metadata structure in Bitlocker. It's a small data structure, specifying a + size, role and type, followed by the necessary data to interpret that datum type. + + Datums can be "complex", in which case they can contain nested datums. These nested datums always + have the PROPERTY role. + + Datums can also have a data segment. A data segment is present if a datum is not complex, but contains + data beyond the size of that datums' type structure. + + Originally, this information is stored in a table, also containing a type's minimal size. This implementation + doesn't currently do that, instead relying on the reading from a file handle with cstruct. Whatever is left + on the file handle is the data segment. + """ + + __struct__ = None + __complex__ = False + + MINIMAL_SIZE = len(c_bde.FVE_DATUM) + + def __init__(self, fh: BinaryIO): + self.header = c_bde.FVE_DATUM(fh) + self._data = fh.read(self.data_size) + + buf = BytesIO(self._data) + self._datum = self.__struct__(buf) if self.__struct__ else None + self.data_segment = buf.read() if not self.__complex__ else None + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} role={self.role.name} type={self.type.name}>" + + @property + def role(self) -> FVE_DATUM_ROLE: + return FVE_DATUM_ROLE(self.header.Role) + + @property + def type(self) -> FVE_DATUM_TYPE: + return FVE_DATUM_TYPE(self.header.Type) + + @property + def size(self) -> int: + return self.header.Size + + @property + def data_size(self) -> int: + return self.size - self.MINIMAL_SIZE + + @cached_property + def properties(self) -> list[Datum]: + """Return a list of property datum if this datum is complex.""" + result = [] + + if self.__complex__: + remaining = self.data_size - len(self.__struct__) + buf = BytesIO(memoryview(self._data)[len(self.__struct__) :]) + while remaining >= self.MINIMAL_SIZE: + nested = Datum.from_fh(buf) + result.append(nested) + + remaining -= nested.size + + return result + + @classmethod + def from_fh(cls, fh: BinaryIO) -> Datum: + """Read a datum from a file handle.""" + offset = fh.tell() + header = c_bde.FVE_DATUM(fh) + fh.seek(offset) + + datum_type_map = { + FVE_DATUM_TYPE.KEY: KeyDatum, + FVE_DATUM_TYPE.UNICODE: UnicodeDatum, + FVE_DATUM_TYPE.STRETCH_KEY: StretchKeyDatum, + FVE_DATUM_TYPE.USE_KEY: UseKeyDatum, + FVE_DATUM_TYPE.AES_CCM_ENCRYPTED_KEY: AesCcmEncryptedDatum, + FVE_DATUM_TYPE.TPM_ENCRYPTED_BLOB: TpmEncryptedBlobDatum, + FVE_DATUM_TYPE.VALIDATION_INFO: ValidationInfoDatum, + FVE_DATUM_TYPE.VOLUME_MASTER_KEY_INFO: VmkInfoDatum, + FVE_DATUM_TYPE.EXTERNAL_INFO: ExternalInfoDatum, + FVE_DATUM_TYPE.UPDATE: UpdateDatum, + FVE_DATUM_TYPE.ERROR_LOG: ErrorLogDatum, + FVE_DATUM_TYPE.ASYMMETRIC_ENCRYPTED_KEY: AsymmetricEncryptedDatum, + FVE_DATUM_TYPE.EXPORTED_KEY: ExportedPublicKeyDatum, + FVE_DATUM_TYPE.PUBLIC_KEY_INFO: PublicKeyInfoDatum, + FVE_DATUM_TYPE.VIRTUALIZATION_INFO: VirtualizationInfoDatum, + FVE_DATUM_TYPE.SIMPLE_1: SimpleDatum, + FVE_DATUM_TYPE.SIMPLE_2: SimpleDatum, + FVE_DATUM_TYPE.CONCAT_HASH_KEY: ConcatHashKeyDatum, + FVE_DATUM_TYPE.SIMPLE_3: SimpleDatum, + FVE_DATUM_TYPE.SIMPLE_LARGE: SimpleLargeDatum, + FVE_DATUM_TYPE.BACKUP_INFO: BackupInfoDatum, + } + datum_type = FVE_DATUM_TYPE(header.Type) + + return datum_type_map.get(datum_type, Datum)(fh) + + @classmethod + def from_bytes(cls, buf: bytes) -> Datum: + """Read a datum from raw bytes.""" + return cls.from_fh(BytesIO(buf)) + + def find_property(self, type_: FVE_DATUM_TYPE | None) -> Iterator[Datum]: + """Find one or more datum with a specified type within the properties.""" + for datum in self.properties: + if datum.type == type_ or type_ is None: + yield datum + + +class SimpleDatum(Datum): + __struct__ = c_bde.FVE_DATUM_SIMPLE + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} role={self.role.name} data={self.data}>" + + @property + def data(self) -> int: + return self._datum.Data + + +class SimpleLargeDatum(Datum): + __struct__ = c_bde.FVE_DATUM_SIMPLE_LARGE + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} role={self.role.name} data={self.data}>" + + @property + def data(self) -> int: + return self._datum.Data + + +class GuidDatum(Datum): + __struct__ = c_bde.FVE_DATUM_GUID + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} role={self.role.name} guid={self.guid}>" + + @property + def guid(self) -> UUID: + return UUID(bytes_le=self._datum.Guid) + + +class KeyDatum(Datum): + __struct__ = c_bde.FVE_DATUM_KEY + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} role={self.role.name} key_type={self.key_type} key_flags={self.key_flags}>" + + @property + def key_type(self) -> FVE_KEY_TYPE: + return FVE_KEY_TYPE(self._datum.KeyType) + + @property + def key_flags(self) -> FVE_KEY_FLAG: + return FVE_KEY_FLAG(self._datum.KeyFlags) + + @property + def data(self) -> bytes: + return self._data[len(KeyDatum.__struct__) :] + + +class UnicodeDatum(Datum): + def __repr__(self) -> str: + return f"<{self.__class__.__name__} role={self.role.name} text={self.text}>" + + @property + def text(self) -> str: + return self._data.decode("utf-16-le").rstrip("\x00") + + +class StretchKeyDatum(Datum): + __struct__ = c_bde.FVE_DATUM_STRETCH_KEY + __complex__ = True + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} role={self.role.name} key_type={self.key_type} key_flags={self.key_flags}>" + + @property + def key_type(self) -> FVE_KEY_TYPE: + return FVE_KEY_TYPE(self._datum.KeyType) + + @property + def key_flags(self) -> FVE_KEY_FLAG: + return FVE_KEY_FLAG(self._datum.KeyFlags) + + @property + def salt(self) -> bytes: + return self._datum.Salt + + +class UseKeyDatum(Datum): + __struct__ = c_bde.FVE_DATUM_USE_KEY + __complex__ = True + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} role={self.role.name} key_type={self.key_type} key_flags={self.key_flags}>" + + @property + def key_type(self) -> FVE_KEY_TYPE: + return FVE_KEY_TYPE(self._datum.KeyType) + + @property + def key_flags(self) -> FVE_KEY_FLAG: + return FVE_KEY_FLAG(self._datum.KeyFlags) + + +class AesCcmEncryptedDatum(Datum): + __struct__ = c_bde.FVE_DATUM_AESCCM_ENC + + def __repr__(self) -> str: + return ( + f"<{self.__class__.__name__} role={self.role.name} " + f"nonce_time={self.nonce_time} nonce_counter={self.nonce_counter}>" + ) + + @property + def nonce(self) -> bytes: + return self._data[: len(c_bde.FVE_NONCE)] + + @property + def nonce_time(self) -> datetime.datetime | int: + try: + return ts.wintimestamp(self._datum.Nonce.DateTime) + except ValueError: + return self._datum.Nonce.DateTime + + @property + def nonce_counter(self) -> int: + return self._datum.Nonce.Counter + + @property + def mac(self) -> bytes: + return self._datum.MAC + + @property + def data(self) -> bytes: + return self._data[len(self.__struct__) :] + + def unbox(self, key: KeyDatum | bytes) -> Datum: + key = key.data if isinstance(key, KeyDatum) else key + cipher = AES.new(key, AES.MODE_CCM, nonce=self.nonce) + decrypted_data = cipher.decrypt_and_verify(self.data, self.mac) + return Datum.from_bytes(decrypted_data) + + +class TpmEncryptedBlobDatum(Datum): + __struct__ = c_bde.FVE_DATUM_TPM_ENC_BLOB + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} role={self.role.name} pcr_bitmap={self.pcr_bitmap}>" + + @property + def pcr_bitmap(self) -> int: + return self._datum.PcrBitmap + + @property + def data(self) -> bytes: + return self._data[len(self.__struct__) :] + + +class ValidationEntry: + def __init__(self, fh): + self._entry = c_bde.FVE_DATUM_VALIDATION_ENTRY(fh) + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} hash={self.hash}>" + + @property + def hash(self) -> bytes: + return self._entry.Hash + + +class ValidationInfoDatum(Datum): + def __repr__(self) -> str: + return f"<{self.__class__.__name__} role={self.role.name} allow_list={self.allow_list}>" + + @property + def allow_list(self) -> list[ValidationEntry]: + fh = BytesIO(self._data) + return [ValidationEntry(fh) for _ in range(len(self._data) // len(c_bde.FVE_DATUM_VALIDATION_ENTRY))] + + +class VmkInfoDatum(Datum): + __struct__ = c_bde.FVE_DATUM_VMK_INFO + __complex__ = True + + def __repr__(self) -> str: + return ( + f"<{self.__class__.__name__} role={self.role.name} identifier={self.identifier} " + f"datetime={self.datetime} priority={self.priority}>" + ) + + @property + def identifier(self) -> UUID: + return UUID(bytes_le=self._datum.Identifier) + + @property + def datetime(self) -> datetime.datetime: + return ts.wintimestamp(self._datum.DateTime) + + @property + def priority(self) -> FVE_KEY_PROTECTOR: + return FVE_KEY_PROTECTOR(self._datum.Priority) + + def decrypt(self, key: KeyDatum | bytes) -> KeyDatum: + encrypted_key = self.aes_ccm_encrypted_key() + return encrypted_key.unbox(key) + + def label(self) -> str: + for datum in self.find_property(FVE_DATUM_TYPE.UNICODE): + return datum.text + + def asymmetric_encrypted_key(self) -> AsymmetricEncryptedDatum: + for datum in self.find_property(FVE_DATUM_TYPE.ASYMMETRIC_ENCRYPTED_KEY): + return datum + + def exported_key(self) -> ExportedPublicKeyDatum: + for datum in self.find_property(FVE_DATUM_TYPE.EXPORTED_KEY): + return datum + + def tpm_encrypted_blob(self) -> TpmEncryptedBlobDatum: + for datum in self.find_property(FVE_DATUM_TYPE.TPM_ENCRYPTED_BLOB): + return datum + + def aes_ccm_encrypted_key(self) -> AesCcmEncryptedDatum: + for datum in self.find_property(FVE_DATUM_TYPE.AES_CCM_ENCRYPTED_KEY): + return datum + + def public_key_info(self) -> PublicKeyInfoDatum: + for datum in self.find_property(FVE_DATUM_TYPE.PUBLIC_KEY_INFO): + return datum + + def use_keys(self) -> list[UseKeyDatum]: + return list(self.find_property(FVE_DATUM_TYPE.USE_KEY)) + + def use_key(self, key_type: FVE_KEY_TYPE) -> UseKeyDatum: + for datum in self.use_keys(): + if key_type is None or datum.key_type == key_type: + return datum + + def stretch_keys(self) -> list[StretchKeyDatum]: + return list(self.find_property(FVE_DATUM_TYPE.STRETCH_KEY)) + + def stretch_key(self, key_type: FVE_KEY_TYPE) -> StretchKeyDatum: + for datum in self.stretch_keys(): + if key_type is None or datum.key_type == key_type: + return datum + + def clear_key(self) -> KeyDatum: + for datum in self.find_property(FVE_DATUM_TYPE.KEY): + return datum + + def is_enhanced_pin(self) -> bool: + for stretch_key in self.stretch_keys(): + if stretch_key.key_type == FVE_KEY_TYPE.AES_CCM_256_2 and stretch_key.key_flags & FVE_KEY_FLAG.ENHANCED_PIN: + return True + + def is_enhanced_crypto(self) -> bool: + for stretch_key in self.stretch_keys(): + if ( + stretch_key.key_type == FVE_KEY_TYPE.AES_CCM_256_2 + and stretch_key.key_flags & FVE_KEY_FLAG.ENHANCED_CRYPTO + ): + return True + + def uses_pbkdf2(self) -> bool: + for stretch_key in self.stretch_keys(): + if ( + stretch_key.type in (FVE_KEY_TYPE.STRETCH_KEY, FVE_KEY_TYPE.STRETCH_KEY_1, FVE_KEY_TYPE.AES_CCM_256_2) + and stretch_key.key_flags & FVE_KEY_FLAG.PBKDF2 + ): + return True + + +class ExternalInfoDatum(Datum): + __struct__ = c_bde.FVE_DATUM_EXTERNAL_INFO + __complex__ = True + + def __repr__(self) -> str: + return ( + f"<{self.__class__.__name__} role={self.role.name} identifier={self.identifier} datetime={self.datetime}>" + ) + + @property + def identifier(self) -> UUID: + return UUID(bytes_le=self._datum.Identifier) + + @property + def datetime(self) -> datetime.datetime: + return ts.wintimestamp(self._datum.DateTime) + + def label(self) -> str | None: + for datum in self.find_property(FVE_DATUM_TYPE.UNICODE): + return datum.text + + def external_key(self) -> KeyDatum | None: + for datum in self.find_property(FVE_DATUM_TYPE.KEY): + return datum + + +class UpdateDatum(Datum): + __struct__ = c_bde.FVE_DATUM_UPDATE + __complex__ = True + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} role={self.role.name}>" + + +class ErrorLogDatum(Datum): + __struct__ = c_bde.FVE_DATUM_ERROR_LOG + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} role={self.role.name}>" + + +class AsymmetricEncryptedDatum(Datum): + def __repr__(self) -> str: + return f"<{self.__class__.__name__} role={self.role.name}>" + + @property + def data(self) -> bytes: + return self._data + + +class ExportedPublicKeyDatum(Datum): + def __repr__(self) -> str: + return f"<{self.__class__.__name__} role={self.role.name}>" + + @property + def data(self) -> bytes: + return self._data + + +class PublicKeyInfoDatum(Datum): + def __repr__(self) -> str: + return f"<{self.__class__.__name__} role={self.role.name}>" + + @property + def data(self) -> bytes: + return self._data + + +class VirtualizationInfoDatum(Datum): + __struct__ = c_bde.FVE_DATUM_VIRTUALIZATION_INFO + + def __repr__(self) -> str: + return ( + f"<{self.__class__.__name__} role={self.role.name} " + f"virtualized_block_offset=0x{self.virtualized_block_offset:x} " + f"virtualized_block_size=0x{self.virtualized_block_size:x}>" + ) + + @property + def virtualized_block_offset(self) -> int: + return self._datum.VirtualizedBlockOffset + + @property + def virtualized_block_size(self) -> int: + return self._datum.VirtualizedBlockSize + + +class ConcatHashKeyDatum(Datum): + __struct__ = c_bde.FVE_DATUM_CONCAT_HASH_KEY + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} role={self.role.name}>" + + +class BackupInfoDatum(Datum): + __struct__ = c_bde.FVE_DATUM_BACKUP_INFO + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} role={self.role.name}>" + + +class AesCbc256HmacSha512EncryptedDatum(Datum): + __struct__ = c_bde.FVE_DATUM_AESCBC256_HMAC_SHA512_ENC + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} role={self.role.name}>" + + @property + def iv(self) -> bytes: + return self._datum.Iv + + @property + def mac(self) -> bytes: + return self._datum.Mac + + @property + def data(self) -> bytes: + return self._data[len(self.__struct__) :] diff --git a/dissect/fve/bde/keys.py b/dissect/fve/bde/keys.py new file mode 100644 index 0000000..0169956 --- /dev/null +++ b/dissect/fve/bde/keys.py @@ -0,0 +1,70 @@ +import hashlib +import struct + + +def stretch(password: bytes, salt: bytes, rounds: int = 0x100000) -> bytes: + """Stretch a password with a specified salt. + + Bitlocker uses this as the key derivation algorithm. + """ + # Stretch data looks like the following: + # chained hash | user hash | salt | counter + # SHA256 digest length is 32 bytes + # Salt length is 16 bytes + # Counter is a uint64 + if len(password) != 32: + raise ValueError("Invalid password length") + + if len(salt) != 16: + raise ValueError("Invalid salt length") + + data = bytearray(32 + 32 + 16 + 8) + view = memoryview(data) + + view[32:64] = password + view[64:80] = salt + + for i in range(rounds): + view[80:] = i.to_bytes(8, "little") + view[:32] = hashlib.sha256(view).digest() + + return bytes(view[:32]) + + +def derive_user_key(user_password: str) -> bytes: + """Derive an AES key from a given user passphrase.""" + return hashlib.sha256(hashlib.sha256(user_password.encode("utf-16-le")).digest()).digest() + + +def derive_recovery_key(recovery_password: str) -> bytes: + """Derive an AES key from a given recovery password.""" + check_recovery_password(recovery_password) + + blocks = recovery_password.split("-") + key = b"".join(struct.pack(" bool: + """Check if a given recovery password is valid.""" + blocks = recovery_password.split("-") + if len(blocks) != 8: + raise ValueError("Invalid recovery password: invalid length") + + for block in blocks: + if not block.isdigit(): + raise ValueError("Invalid recovery password: contains non-numeric value") + + value = int(block) + if value % 11: + raise ValueError("Invalid recovery password: block not divisible by 11") + + if value >= 2**16 * 11: + raise ValueError("Invalid recovery password: larger than 2 ** 16 * 11 (720896)") + + digits = list(map(int, block)) + checksum = (digits[0] - digits[1] + digits[2] - digits[3] + digits[4]) % 11 + if checksum != digits[5]: + raise ValueError("Invalid recovery password: invalid block checksum") + + return True diff --git a/dissect/fve/crypto/__init__.py b/dissect/fve/crypto/__init__.py new file mode 100644 index 0000000..f40ca40 --- /dev/null +++ b/dissect/fve/crypto/__init__.py @@ -0,0 +1,116 @@ +from typing import Optional + +from Crypto.Cipher import AES + +from dissect.fve.crypto import _pycryptodome +from dissect.fve.crypto.base import Cipher, Plain, Plain64, Plain64BE + +# Only pycryptodome is supported right now +_pycryptodome.install() + + +CIPHER_MODE_MAP = { + "ecb": AES.MODE_FVE_ECB, + "cbc": AES.MODE_FVE_CBC, + "xts": AES.MODE_FVE_XTS, +} + +IV_MODE_MAP = { + "plain": Plain, + "plain64": Plain64, + "plain64be": Plain64BE, + "eboiv": _pycryptodome.EBOIV, + "essiv": _pycryptodome.ESSIV, + "elephant": _pycryptodome.Elephant, +} + + +def create_cipher( + spec: str, key: bytes, key_size: Optional[int] = None, sector_size: int = 512, iv_sector_size: int = 512 +) -> Cipher: + """Create a cipher object according to a given cipher specification and key. + + For more information on the cipher specification, read the documentation on :func:`parse_cipher_spec`. + + Args: + spec: The cipher specification to parse. + key: The key to initialize the cipher with. + key_size: Optional key size that overrides the specification key size. + sector_size: Optional sector size. + """ + cipher_name, cipher_mode, key_size, iv_name, iv_options = parse_cipher_spec( + spec, key_size=key_size, key_size_hint=len(key) * 8 + ) + + if cipher_name != "aes": + raise ValueError("Only AES support is implemented") + + mode = CIPHER_MODE_MAP.get(cipher_mode) + if not mode: + raise ValueError(f"Invalid cipher mode: {cipher_name}-{cipher_mode} (from {spec})") + + iv = IV_MODE_MAP.get(iv_name) + if not iv: + raise ValueError(f"Invalid iv mode: {iv_name}:{iv_options} (from {spec})") + + return AES.new( + key, + mode, + key_size=key_size, + iv_mode=iv, + iv_options=iv_options, + sector_size=sector_size, + iv_sector_size=iv_sector_size, + ) + + +def parse_cipher_spec( + spec: str, key_size: Optional[int] = None, key_size_hint: Optional[int] = None +) -> tuple[str, str, int, str, Optional[str]]: + """Parse a cipher specification into a tuple of (cipher, mode, key size, iv mode, iv options). + + Inspired by and accepts LUKS/dm-crypt-like cipher specifications in the form of:: + + cipher-mode-keysize-iv:ivopts + + The ``mode``, ``keysize``, ``iv`` and ``ivopts`` are optional and will default to ``cbc``, + the ``key_size`` argument and ``plain`` respectively. + + Args: + spec: The cipher specification to parse. + key_size: Optional key size that overrides the specification key size. + key_size_hint: Optional key size hint for the amount of bits that the key actually has. + """ + cipher_name, _, tmp = spec.partition("-") + cipher_mode, _, tmp = tmp.partition("-") + + result_key_size = key_size_hint + specified_key_size, _, tmp = tmp.partition("-") + if specified_key_size.isdigit(): + result_key_size = int(specified_key_size) + else: + if tmp: + raise ValueError("Unexpected cipher spec format") + tmp = specified_key_size + + if key_size: + result_key_size = key_size + + if not result_key_size: + raise ValueError("Missing key size") + + iv_name = None + iv_options = None + iv_name, _, iv_options = tmp.partition(":") + + if not cipher_mode: + cipher_mode = "cbc" + + if not iv_name: + iv_name = "plain" + iv_options = None + + if cipher_mode == "xts" and key_size_hint == result_key_size: + result_key_size //= 2 + + return cipher_name, cipher_mode, result_key_size, iv_name, iv_options or None diff --git a/dissect/fve/crypto/_pycryptodome.py b/dissect/fve/crypto/_pycryptodome.py new file mode 100644 index 0000000..be9d94a --- /dev/null +++ b/dissect/fve/crypto/_pycryptodome.py @@ -0,0 +1,263 @@ +from __future__ import annotations + +import hashlib +import platform +import sys +from typing import Any, Callable + +from Crypto.Cipher import AES, _extra_modes +from Crypto.Util import _raw_api + +from dissect.fve.crypto import elephant +from dissect.fve.crypto.base import DECRYPT, ENCRYPT, IV, Cipher + +if platform.python_implementation() == "CPython": + # On CPython, our own "pure Python" XOR is somehow faster than the one from pycryptodome + from dissect.fve.crypto.utils import xor +else: + # On PyPy the opposite is true, and also just use this as the default fallback + from Crypto.Util.strxor import strxor as xor + +POINTER_SIZE = 8 if sys.maxsize > 2**32 else 4 + + +if _raw_api.backend == "cffi": + + def get_iv_view(cipher, size): + return _raw_api.ffi.cast(_raw_api.uint8_t_type, cipher._state.get() + POINTER_SIZE)[0:size] + +elif _raw_api.backend == "ctypes": + import ctypes + + def get_iv_view(cipher, size): + return ctypes.cast(cipher._state.get().value + POINTER_SIZE, ctypes.POINTER(ctypes.c_char * size))[0] + +else: + + def get_iv_view(cipher, size): + raise NotImplementedError("Unsupported pycryptodome backend") + + +# Sanity check if fast IV is available +def _fast_iv_works() -> bool: + try: + magic = b"\x69" * 16 + cipher = AES.new(b"\x00" * 16, AES.MODE_CBC, iv=magic) + return _raw_api.get_raw_buffer(get_iv_view(cipher, 16)) == magic + except Exception: + return False + + +FAST_IV = _fast_iv_works() + + +class EcbMode(Cipher): + """ECB mode implementation for FVE crypto.""" + + def __init__( + self, + factory: Any, + key: bytes, + key_size: int, + iv_mode: type[IV], + iv_options: str, + sector_size: int = 512, + iv_sector_size: int = 512, + ): + if key_size not in (128, 256): + raise ValueError(f"Incorrect key size for ECB mode ({key_size} bits)") + super().__init__(key, key_size, factory.block_size, iv_mode, iv_options, sector_size, iv_sector_size) + + self._cipher = AES.new(key[: self.key_size_bytes], AES.MODE_ECB) + + def _crypt_sector(self, mode: int, buffer: bytearray, iv: bytes) -> None: + (self._cipher.encrypt if mode == ENCRYPT else self._cipher.decrypt)(buffer, output=buffer) + + +class CbcMode(Cipher): + """CBC mode implementation for FVE crypto.""" + + def __init__( + self, + factory: Any, + key: bytes, + key_size: int, + iv_mode: type[IV], + iv_options: str, + sector_size: int = 512, + iv_sector_size: int = 512, + ): + if key_size not in (128, 256): + raise ValueError(f"Incorrect key size for CBC mode ({key_size} bits)") + super().__init__(key, key_size, factory.block_size, iv_mode, iv_options, sector_size, iv_sector_size) + + if FAST_IV: + self._cipher = AES.new(key[: self.key_size_bytes], AES.MODE_CBC, iv=b"\x00" * self.block_size) + self._iv_view = get_iv_view(self._cipher, self.block_size) + else: + self._key = key[: self.key_size_bytes] + + def _crypt_sector(self, mode: int, buffer: bytearray, iv: bytes) -> None: + if FAST_IV: + self._iv_view[0 : self.block_size] = iv + cipher = self._cipher + else: + cipher = AES.new(self._key, AES.MODE_CBC, iv=iv) + + (cipher.encrypt if mode == ENCRYPT else cipher.decrypt)(buffer, output=buffer) + + +class XtsMode(Cipher): + """XTS mode implementation for FVE crypto.""" + + def __init__( + self, + factory: Any, + key: bytes, + key_size: int, + iv_mode: type[IV], + iv_options: str, + sector_size: int = 512, + iv_sector_size: int = 512, + ): + if (len(key), key_size) not in ((32, 128), (64, 256)): + raise ValueError(f"Incorrect key size for XTS mode ({len(key)} bytes, {key_size} bits)") + super().__init__(key, key_size, key_size // 8, iv_mode, iv_options, sector_size, iv_sector_size) + + self._aes_cipher = factory.new(key[: self.block_size], factory.MODE_ECB) + self._tweak_cipher = factory.new(key[self.block_size :], factory.MODE_ECB) + + def _crypt_sector(self, mode: int, buffer: bytearray, iv: bytes) -> None: + tweak = self._tweak_cipher.encrypt(iv) + _t = int.from_bytes(tweak, "little") + + crypt = self._aes_cipher.encrypt if mode == ENCRYPT else self._aes_cipher.decrypt + + view = buffer + block_size = self.block_size + + for _ in range(self.sector_size // 16): + block_slice = view[:16] + xor(block_slice, tweak[:16], output=block_slice) + crypt(block_slice, output=block_slice) + xor(tweak[:16], block_slice, output=block_slice) + + _t <<= 1 + if _t & (1 << 128): + _t ^= (1 << 128) | (0x87) + tweak = (_t & ((1 << (block_size * 8)) - 1)).to_bytes(block_size, "little") + + view = view[16:] + + +class EBOIV(IV): + """Encrypted byte-offset IV. + + Specific to Bitlocker. + """ + + def __init__(self, cipher: Cipher, key: bytes, iv_options: str | None = None): + super().__init__(cipher, key) + self._ecb_cipher = AES.new(key, AES.MODE_ECB) + + def generate(self, mode: int, iv: bytearray, data: bytearray, sector: int = 0) -> None: + iv[:] = self._ecb_cipher.encrypt((sector * self.cipher.sector_size).to_bytes(16, "little")) + + +class ESSIV(IV): + """Encrypted sector|salt IV. + + The sector number is encrypted with the bulk cipher using a salt as key. The salt should be + derived from the bulk cipher's key via hashing. + """ + + def __init__(self, cipher: Cipher, key: bytes, iv_options: str | None = "sha256"): + super().__init__(cipher, key) + # Only support one cipher mode for now + self._cipher = AES.new(hashlib.new(iv_options, key).digest(), AES.MODE_ECB) + + def generate(self, mode: int, iv: bytearray, data: bytearray, sector: int = 0) -> None: + self._cipher.encrypt(sector.to_bytes(self.cipher.block_size, "little"), output=iv) + + +class Elephant(IV): + """Extended eboiv with Elephant diffuser. + + Specific to Bitlocker. The key is always 64 bytes, but you need to take only the + amount of bytes for the key size that you're working with. + """ + + def __init__(self, cipher: Cipher, key: bytes, iv_options: str | None = None): + super().__init__(cipher, key) + self._ecb_cipher = AES.new(key[32 : 32 + cipher.key_size_bytes], AES.MODE_ECB) + self._eboiv = EBOIV(cipher, key[: cipher.key_size_bytes]) + + self._sector_key = bytearray(32) + self._sector_key_view = memoryview(self._sector_key) + + def _elephant(self, mode: int, data: bytearray, sector: int) -> None: + sector_size = self.cipher.sector_size + sector_key_view = self._sector_key_view + + # Generate the IV and sector key + iv = bytearray((sector * sector_size).to_bytes(16, "little")) + self._ecb_cipher.encrypt(iv, output=sector_key_view[:16]) + iv[15] = 0x80 + self._ecb_cipher.encrypt(iv, output=sector_key_view[16:]) + + if mode == DECRYPT: + # Apply diffuser B + elephant.diffuser_b_decrypt(data, sector_size) + + # Apply diffuser A + elephant.diffuser_a_decrypt(data, sector_size) + + # Apply sector key + xor(data, self._sector_key * (sector_size // 32), output=data) + + if mode == ENCRYPT: + # Apply diffuser A + elephant.diffuser_a_encrypt(data, sector_size) + + # Apply diffuser B + elephant.diffuser_b_encrypt(data, sector_size) + + def generate(self, mode: int, iv: bytearray, data: bytearray, sector: int = 0) -> None: + if mode == ENCRYPT: + self._elephant(mode, data, sector) + self._eboiv.generate(mode, iv, data, sector) + + def post(self, mode: int, data: bytearray, sector: int = 0) -> None: + if mode == DECRYPT: + self._elephant(mode, data, sector) + + +def _create_cipher_factory(mode: type[Cipher]) -> Callable[..., Cipher]: + def cipher_factory(factory: Any, **kwargs) -> Cipher: + try: + key = kwargs.pop("key") + key_size = kwargs.pop("key_size") + iv_mode = kwargs.pop("iv_mode") + except KeyError as e: + raise TypeError("Missing parameter:" + str(e)) + + sector_size = kwargs.pop("sector_size", 512) + iv_sector_size = kwargs.pop("iv_sector_size", 512) + iv_options = kwargs.pop("iv_options", None) + + return mode(factory, key, key_size, iv_mode, iv_options, sector_size, iv_sector_size) + + return cipher_factory + + +def install() -> None: + """Install the cipher modes into pycryptotome.""" + + # Only support AES for now + AES.MODE_FVE_ECB = 50 + AES.MODE_FVE_CBC = 51 + AES.MODE_FVE_XTS = 52 + + _extra_modes[AES.MODE_FVE_ECB] = _create_cipher_factory(EcbMode) + _extra_modes[AES.MODE_FVE_CBC] = _create_cipher_factory(CbcMode) + _extra_modes[AES.MODE_FVE_XTS] = _create_cipher_factory(XtsMode) diff --git a/dissect/fve/crypto/base.py b/dissect/fve/crypto/base.py new file mode 100644 index 0000000..990fe7b --- /dev/null +++ b/dissect/fve/crypto/base.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +ENCRYPT = 0 +DECRYPT = 1 + + +class Cipher: + def __init__( + self, + key: bytes, + key_size: int, + block_size: int, + iv_mode: type[IV], + iv_options: str, + sector_size: int = 512, + iv_sector_size: int = 512, + ): + self.key = key + self.key_size = key_size + self.key_size_bytes = key_size // 8 + self.block_size = block_size + self.sector_size = sector_size + self.iv_sector_size = iv_sector_size + + self.iv_mode = iv_mode(self, key, iv_options) + + def _crypt_sector(self, mode: int, buffer: bytearray, iv: bytes) -> None: + raise NotImplementedError() + + def _crypt(self, mode: int, ciphertext: bytes, sector: int = 0, output: bytearray | None = None) -> bytes | None: + length = len(ciphertext) + + if length % self.block_size: + raise ValueError("Ciphertext is not aligned to block size") + + out = output or bytearray(length) + out[:] = ciphertext + out_view = memoryview(out) + + iv = bytearray(self.iv_mode.iv_size) + iv_view = memoryview(iv) + + iv_mode = self.iv_mode + sector_size = self.sector_size + sector_increment = sector_size // self.iv_sector_size + + for _ in range(length // sector_size): + out_slice = out_view[:sector_size] + + # Generate the IV + iv_mode.generate(mode, iv_view, out_slice, sector) + + # Do the crypting + self._crypt_sector(mode, out_slice, iv) + + # Perform possible post operations for the IV + iv_mode.post(mode, out_slice, sector) + + out_view = out_view[sector_size:] + sector += sector_increment + + return None if output is not None else bytes(out) + + def encrypt(self, ciphertext: bytes, sector: int = 0, output: bytearray | None = None) -> bytes | None: + return self._crypt(ENCRYPT, ciphertext, sector, output) + + def decrypt(self, ciphertext: bytes, sector: int = 0, output: bytearray | None = None) -> bytes | None: + return self._crypt(DECRYPT, ciphertext, sector, output) + + +class IV: + def __init__(self, cipher: Cipher, key: bytes, iv_options: str | None = None): + self.cipher = cipher + self.iv_size = cipher.block_size + + def generate(self, mode: int, iv: bytearray, data: bytearray, sector: int = 0) -> None: + pass + + def post(self, mode: int, data: bytearray, sector: int = 0) -> None: + pass + + +class Plain(IV): + def generate(self, mode: int, iv: bytearray, data: bytearray, sector: int = 0) -> None: + iv[:] = b"\x00" * self.iv_size + iv[:4] = (sector & 0xFFFFFFFF).to_bytes(4, "little") + + +class Plain64(IV): + def generate(self, mode: int, iv: bytearray, data: bytearray, sector: int = 0) -> None: + iv[:] = b"\x00" * self.iv_size + iv[:8] = sector.to_bytes(8, "little") + + +class Plain64BE(IV): + def generate(self, mode: int, iv: bytearray, data: bytearray, sector: int = 0) -> None: + iv[:] = b"\x00" * self.iv_size + iv[:8] = sector.to_bytes(8, "big") + + +class EBOIV(IV): + def __init__(self, cipher: Cipher, key: bytes, iv_options: str | None = None): + # Implementation specific + raise NotImplementedError() + + +class ESSIV(IV): + def __init__(self, cipher: Cipher, key: bytes, iv_options: str | None = None): + # Implementation specific + raise NotImplementedError() + + +class Elephant(IV): + def __init__(self, cipher: Cipher, key: bytes, iv_options: str | None = None): + # Implementation specific + raise NotImplementedError() diff --git a/dissect/fve/crypto/elephant.py b/dissect/fve/crypto/elephant.py new file mode 100644 index 0000000..476dd40 --- /dev/null +++ b/dissect/fve/crypto/elephant.py @@ -0,0 +1,54 @@ +def diffuser_a_decrypt(buffer: memoryview, sector_size: int) -> None: + a_cycles = 5 + r_a = [9, 0, 13, 0] + int_size = sector_size >> 2 + + buffer_i = buffer.cast("I") + + for _ in range(a_cycles): + for i in range(int_size): + buffer_i[i] = (buffer_i[i] + (buffer_i[i - 2] ^ _rotate_left(buffer_i[i - 5], r_a[i % 4]))) & 0xFFFFFFFF + + +def diffuser_a_encrypt(buffer: memoryview, sector_size: int) -> None: + a_cycles = 5 + r_a = [9, 0, 13, 0] + int_size = sector_size >> 2 + + buffer_i = buffer.cast("I") + + for _ in range(a_cycles): + for i in range(int_size - 1, -1, -1): + buffer_i[i] = (buffer_i[i] - (buffer_i[i - 2] ^ _rotate_left(buffer_i[i - 5], r_a[i % 4]))) & 0xFFFFFFFF + + +def diffuser_b_decrypt(buffer: memoryview, sector_size: int) -> None: + b_cycles = 3 + r_b = [0, 10, 0, 25] + int_size = sector_size >> 2 + + buffer_i = buffer.cast("I") + + for _ in range(b_cycles): + for i in range(int_size): + buffer_i[i] = ( + buffer_i[i] + (buffer_i[(i + 2) % int_size] ^ _rotate_left(buffer_i[(i + 5) % int_size], r_b[i % 4])) + ) & 0xFFFFFFFF + + +def diffuser_b_encrypt(buffer: memoryview, sector_size: int) -> None: + b_cycles = 3 + r_b = [0, 10, 0, 25] + int_size = sector_size >> 2 + + buffer_i = buffer.cast("I") + + for _ in range(b_cycles): + for i in range(int_size - 1, -1, -1): + buffer_i[i] = ( + buffer_i[i] - (buffer_i[(i + 2) % int_size] ^ _rotate_left(buffer_i[(i + 5) % int_size], r_b[i % 4])) + ) & 0xFFFFFFFF + + +def _rotate_left(num: int, count: int) -> int: + return ((num << count) | (num >> (32 - count))) & ((0b1 << 32) - 1) diff --git a/dissect/fve/crypto/utils.py b/dissect/fve/crypto/utils.py new file mode 100644 index 0000000..250200f --- /dev/null +++ b/dissect/fve/crypto/utils.py @@ -0,0 +1,17 @@ +import platform + + +# Reference: https://www.da.vidbuchanan.co.uk/blog/python-swar.html +# Sorry David +def xor_pseudo_simd(a: bytes, b: bytes, output: bytearray) -> None: + output[:] = int.to_bytes(int.from_bytes(a, "little") ^ int.from_bytes(b, "little"), len(output), "little") + + +# On PyPy the naive loop is actually faster +# Also just use this as the default fallback, seems safer +def xor_naive(a: bytes, b: bytes, output: bytearray) -> None: + for i in range(len(output)): + output[i] = a[i] ^ b[i] + + +xor = xor_pseudo_simd if platform.python_implementation() == "CPython" else xor_naive diff --git a/dissect/fve/exceptions.py b/dissect/fve/exceptions.py new file mode 100644 index 0000000..05756d7 --- /dev/null +++ b/dissect/fve/exceptions.py @@ -0,0 +1,6 @@ +class Error(Exception): + pass + + +class InvalidHeaderError(Error): + pass diff --git a/dissect/fve/luks/__init__.py b/dissect/fve/luks/__init__.py new file mode 100644 index 0000000..c1c4e76 --- /dev/null +++ b/dissect/fve/luks/__init__.py @@ -0,0 +1,7 @@ +from dissect.fve.luks.luks import LUKS, CryptStream, is_luks_volume + +__all__ = [ + "CryptStream", + "LUKS", + "is_luks_volume", +] diff --git a/dissect/fve/luks/af.py b/dissect/fve/luks/af.py new file mode 100644 index 0000000..ba2ecd8 --- /dev/null +++ b/dissect/fve/luks/af.py @@ -0,0 +1,49 @@ +import hashlib + +from dissect.fve.crypto.utils import xor + +DIGEST_SIZE = { + "sha1": 20, + "sha256": 32, +} + + +def _hash(buf: bytes, hash: str, iv: int) -> bytes: + ctx = hashlib.new(hash) + ctx.update((iv & 0xFFFFFFFF).to_bytes(4, "big")) + ctx.update(buf) + return ctx.digest() + + +def diffuse(buf: bytes, hash: str) -> bytes: + buf_size = len(buf) + digest_size = DIGEST_SIZE[hash] + + view = memoryview(buf) + result = bytearray(buf_size) + + passes, remainder = divmod(buf_size, digest_size) + + for i in range(passes): + result[i * digest_size : (i + 1) * digest_size] = _hash(view[i * digest_size : (i + 1) * digest_size], hash, i) + + if remainder: + result[passes * digest_size : buf_size] = _hash(view[buf_size - remainder : buf_size], hash, passes)[:remainder] + + return bytes(result) + + +def merge(buf: bytes, block_size: int, block_num: int, hash: str) -> bytes: + if block_size * block_num > len(buf): + raise ValueError(f"Unexpected input buffer size ({block_size} * {block_num} != {len(buf)})") + + tmp = bytearray(block_size) + view = memoryview(buf) + + for i in range(block_num - 1): + block = view[i * block_size : (i + 1) * block_size] + xor(block, tmp, output=tmp) + tmp[:] = diffuse(tmp, hash) + + xor(view[(block_num - 1) * block_size : block_num * block_size], tmp, output=tmp) + return bytes(tmp) diff --git a/dissect/fve/luks/c_luks.py b/dissect/fve/luks/c_luks.py new file mode 100644 index 0000000..3832aaa --- /dev/null +++ b/dissect/fve/luks/c_luks.py @@ -0,0 +1,136 @@ +from dissect.cstruct import cstruct + +luks_def = """ +/* =========== LUKS1 =========== */ +#define LUKS_CIPHERNAME_L 32 +#define LUKS_CIPHERMODE_L 32 +#define LUKS_HASHSPEC_L 32 +#define LUKS_DIGESTSIZE 20 // since SHA1 +#define LUKS_HMACSIZE 32 +#define LUKS_SALTSIZE 32 +#define LUKS_NUMKEYS 8 + +// Minimal number of iterations +#define LUKS_MKD_ITERATIONS_MIN 1000 +#define LUKS_SLOT_ITERATIONS_MIN 1000 + +// Iteration time for digest in ms +#define LUKS_MKD_ITERATIONS_MS 125 + +#define LUKS_KEY_DISABLED_OLD 0 +#define LUKS_KEY_ENABLED_OLD 0xCAFE + +#define LUKS_KEY_DISABLED 0x0000DEAD +#define LUKS_KEY_ENABLED 0x00AC71F3 + +#define LUKS_STRIPES 4000 + +// partition header starts with magic +#define LUKS_MAGIC_L 6 + +/* Actually we need only 37, but we don't want struct autoaligning to kick in */ +#define UUID_STRING_L 40 + +/* Offset to keyslot area [in bytes] */ +#define LUKS_ALIGN_KEYSLOTS 4096 + +/* Maximal LUKS header size, for wipe [in bytes] */ +#define LUKS_MAX_KEYSLOT_SIZE 0x1000000 /* 16 MB, up to 32768 bits key */ + +/* Any integer values are stored in network byte order on disk and must be converted */ + +/* DISSECT: Keyblock structure currently separated out due to a cstruct limitation */ +struct luks_keyblock { + uint32_t active; + + /* parameters used for password processing */ + uint32_t passwordIterations; + + char passwordSalt[LUKS_SALTSIZE]; + /* parameters used for AF store/load */ + uint32_t keyMaterialOffset; + uint32_t stripes; +}; + +struct luks_phdr { + char magic[LUKS_MAGIC_L]; + uint16_t version; + char cipherName[LUKS_CIPHERNAME_L]; + char cipherMode[LUKS_CIPHERMODE_L]; + char hashSpec[LUKS_HASHSPEC_L]; + uint32_t payloadOffset; + uint32_t keyBytes; + char mkDigest[LUKS_DIGESTSIZE]; + char mkDigestSalt[LUKS_SALTSIZE]; + uint32_t mkDigestIterations; + char uuid[UUID_STRING_L]; + + /* DISSECT: Keyblock structure currently separated out due to a cstruct limitation */ + luks_keyblock keyblock[LUKS_NUMKEYS]; + + /* Align it to 512 sector size */ + char _padding[432]; +}; + +/* =========== LUKS2 =========== */ + +#define LUKS2_MAGIC_L 6 +#define LUKS2_UUID_L 40 +#define LUKS2_LABEL_L 48 +#define LUKS2_SALT_L 64 +#define LUKS2_CHECKSUM_ALG_L 32 +#define LUKS2_CHECKSUM_L 64 + +#define LUKS2_KEYSLOTS_MAX 32 +#define LUKS2_TOKENS_MAX 32 +#define LUKS2_SEGMENT_MAX 32 + +/* + * LUKS2 header on-disk. + * + * Binary header is followed by JSON area. + * JSON area is followed by keyslot area and data area, + * these are described in JSON metadata. + * + * Note: uuid, csum_alg are intentionally on the same offset as LUKS1 + * (checksum alg replaces hash in LUKS1) + * + * String (char) should be zero terminated. + * Padding should be wiped. + * Checksum is calculated with csum zeroed (+ full JSON area). + */ +struct luks2_hdr_disk { + char magic[LUKS2_MAGIC_L]; + uint16_t version; /* Version 2 */ + uint64_t hdr_size; /* in bytes, including JSON area */ + uint64_t seqid; /* increased on every update */ + char label[LUKS2_LABEL_L]; + char checksum_alg[LUKS2_CHECKSUM_ALG_L]; + uint8_t salt[LUKS2_SALT_L]; /* unique for every header/offset */ + char uuid[LUKS2_UUID_L]; + char subsystem[LUKS2_LABEL_L]; /* owner subsystem label */ + uint64_t hdr_offset; /* offset from device start in bytes */ + char _padding[184]; + uint8_t csum[LUKS2_CHECKSUM_L]; + char _padding4096[7*512]; + /* JSON area starts here */ +}; +""" + +c_luks = cstruct(endian=">").load(luks_def) + +LUKS_MAGIC = b"LUKS\xba\xbe" +LUKS2_MAGIC_1ST = b"LUKS\xba\xbe" +LUKS2_MAGIC_2ND = b"SKUL\xba\xbe" + +SECONDARY_HEADER_OFFSETS = [ + 0x00004000, + 0x00008000, + 0x00010000, + 0x00020000, + 0x00040000, + 0x00080000, + 0x00100000, + 0x00200000, + 0x00400000, +] diff --git a/dissect/fve/luks/luks.py b/dissect/fve/luks/luks.py new file mode 100644 index 0000000..1449634 --- /dev/null +++ b/dissect/fve/luks/luks.py @@ -0,0 +1,315 @@ +# References: +# - https://gitlab.com/cryptsetup/cryptsetup +# - https://gitlab.com/cryptsetup/cryptsetup/-/blob/main/docs/on-disk-format-luks2.pdf + +from __future__ import annotations + +import hashlib +import io +from pathlib import Path +from typing import BinaryIO +from uuid import UUID + +import argon2 +from dissect.util.stream import AlignedStream + +from dissect.fve.crypto import create_cipher +from dissect.fve.luks import af +from dissect.fve.luks.c_luks import ( + LUKS2_MAGIC_1ST, + LUKS2_MAGIC_2ND, + SECONDARY_HEADER_OFFSETS, + c_luks, +) +from dissect.fve.luks.metadata import Digest, Keyslot, Metadata, Segment + + +class LUKS: + """LUKS disk encryption.""" + + def __init__(self, fh: BinaryIO): + self.fh = fh + self.header = None + self.header1 = None + self.header2 = None + + first_offset, second_offset, version = find_luks_headers(fh) + if version is None: + raise ValueError("Not a LUKS volume") + + if version == 1: + header_struct = c_luks.luks_phdr + elif version == 2: + header_struct = c_luks.luks2_hdr_disk + else: + raise ValueError(f"Unsupported LUKS version: {version}") + + if first_offset is not None: + fh.seek(first_offset) + self.header1 = header_struct(fh) + self.header = self.header1 + + if second_offset is not None: + fh.seek(second_offset) + self.header2 = header_struct(fh) + + self.header = self.header2 or self.header1 + + # LUKS1 + self.cipher_name = None + self.cipher_mode = None + self.hash_spec = None + + # LUKS2 + self.label = None + self.checksum_algorithm = None + self.metadata, self.metadata1, self.metadata2 = None, None, None + + self.uuid = UUID(self.header.uuid.strip(b"\x00").decode()) + + if self.header.version == 1: + # LUKS1 + self.metadata = Metadata.from_luks1_header(self.header) + else: + # LUKS2 + self.label = self.header.label.strip(b"\x00").decode() + self.checksum_algorithm = self.header.checksum_alg.strip(b"\x00").decode() + + self.metadata1 = None + if self.header is self.header1: + json_area1 = fh.read(self.header1.hdr_size - 4096).rstrip(b"\x00").decode() + self.metadata1 = Metadata.from_json(json_area1) + self.header2 = c_luks.luks2_hdr_disk(fh) + + json_area2 = fh.read(self.header2.hdr_size - 4096).rstrip(b"\x00").decode() + self.metadata2 = Metadata.from_json(json_area2) + + self.metadata = self.metadata1 or self.metadata2 + + self._active_volume_key = None + self._active_keyslot_id = None + + @property + def unlocked(self) -> bool: + return self._active_volume_key is not None + + @property + def keyslots(self) -> dict[int, Keyslot]: + return self.metadata.keyslots + + def unlock(self, key: bytes, keyslot: int) -> None: + """Unlock the volume with the volume encryption key.""" + if not self._verify_volume_key(key, keyslot): + raise ValueError(f"Invalid volume key for keyslot {keyslot}") + self._active_volume_key = key + self._active_keyslot_id = keyslot + + def unlock_with_key_file(self, path: Path, offset: int = 0, size: int = -1, keyslot: int | None = None) -> None: + with path.open("rb") as fh: + self.unlock_with_key_fh(fh, offset, size, keyslot) + + def unlock_with_key_fh(self, fh: BinaryIO, offset: int = 0, size: int = -1, keyslot: int | None = None) -> None: + fh.seek(offset) + self._unlock_passphrase(fh.read(size), keyslot) + + def unlock_with_passphrase(self, passphrase: str, keyslot: int | None = None) -> None: + """Unlock this volume with a passphrase and optional keyslot hint.""" + self._unlock_passphrase(passphrase.encode(), keyslot) + + def _unlock_passphrase(self, passphrase: bytes, keyslot: int | None = None) -> None: + """Unlock this volume with a passphrase and optional keyslot hint.""" + keyslots = ( + [(keyslot, self.metadata.keyslots[keyslot])] if keyslot is not None else self.metadata.keyslots.items() + ) + + idx = None + vk = None + errors = [] + for idx, keyslot in keyslots: + try: + key = derive_passphrase_key(passphrase, keyslot) + except Exception as exc: + errors.append((idx, exc)) + continue + + try: + vk = self._unlock_volume_key(key, idx) + except Exception as exc: + errors.append((idx, exc)) + continue + + try: + self.unlock(vk, idx) + break + except ValueError: + continue + else: + if errors: + msg = "\n".join(f"{idx}: {exc}" for idx, exc in errors) + raise ValueError(f"No valid keyslot found, but there were errors for the following keyslots:\n{msg}") + raise ValueError("No valid keyslot found") + + def _unlock_volume_key(self, key: bytes, keyslot: int) -> None: + """Unlock the volume key using the given encryption key and keyslot.""" + keyslot_obj = self.metadata.keyslots[keyslot] + + self.fh.seek(keyslot_obj.area.offset) + area = self.fh.read(keyslot_obj.key_size * keyslot_obj.af.stripes) + + cipher = create_cipher(keyslot_obj.area.encryption, key, keyslot_obj.area.key_size * 8) + return af.merge( + cipher.decrypt(area), + keyslot_obj.key_size, + keyslot_obj.af.stripes, + keyslot_obj.af.hash, + ) + + def _verify_volume_key(self, key: bytes, keyslot: int) -> None: + """Verify the given key for the given keyslot.""" + digest = self.find_digest(keyslot) + if digest.type == "pbkdf2": + result = hashlib.pbkdf2_hmac(digest.hash, key, digest.salt, digest.iterations, len(digest.digest)) + else: + # Only the pbkdf2 type is supported in LUKS2 + raise NotImplementedError(f"Unsupported digest algorithm: {digest.type}") + + return result == digest.digest + + def find_digest(self, keyslot: int) -> Digest: + """Find digest metadata corresponding to the given keyslot.""" + digests = [digest for digest in self.metadata.digests.values() if keyslot in digest.keyslots] + if not digests: + raise ValueError(f"No digest found for keyslot {keyslot}") + + return digests[0] + + def find_segment(self, keyslot: int) -> Segment: + """Find segment metadata corresponding to the given keyslot.""" + digest = self.find_digest(keyslot) + segments = [segment for segment_id, segment in self.metadata.segments.items() if segment_id in digest.segments] + if not segments: + raise ValueError(f"No segment found for keyslot {keyslot}") + + if len(segments) > 1: + raise NotImplementedError(f"Keyslot {keyslot} has more than one segment") + + return segments[0] + + def open(self) -> CryptStream: + """Open this volume and return a readable (decrypted) stream.""" + if not self.unlocked: + raise ValueError("Volume is locked") + + # Technically LUKS supports multiple segments, but practically it only ever has one + # Don't bother with supporting multiple segments for now + segment = self.find_segment(self._active_keyslot_id) + + return CryptStream( + self.fh, + segment.encryption, + self._active_volume_key, + self.metadata.keyslots[self._active_keyslot_id].key_size * 8, + segment.offset, + segment.size, + segment.iv_tweak, + segment.sector_size, + ) + + +def derive_passphrase_key(passphrase: bytes, keyslot: Keyslot) -> bytes: + """Derive a key from a passphrase with the given keyslot KDF information. + + Args: + passphrase: The passphrase to derive a key from. + keyslot: The keyslot to use for the derivation. + """ + kdf = keyslot.kdf + + if kdf.type == "pbkdf2": + return hashlib.pbkdf2_hmac(kdf.hash, passphrase, kdf.salt, kdf.iterations, keyslot.key_size) + elif kdf.type.startswith("argon2"): + return argon2.low_level.hash_secret_raw( + passphrase, + kdf.salt, + kdf.time, + kdf.memory, + kdf.cpus, + keyslot.key_size, + {"argon2i": argon2.low_level.Type.I, "argon2id": argon2.low_level.Type.ID}[kdf.type], + ) + else: + raise NotImplementedError(f"Unsupported kdf algorithm: {kdf.type}") + + +class CryptStream(AlignedStream): + """Transparently decrypting stream. + + Technically this is dm-crypt territory, but it's more practical to place it in the LUKS namespace. + + Args: + fh: The original file-like object, usually the encrypted disk. + cipher: The cipher name/specification. + key: The encryption key. + key_size: Optional key size hint. + offset: Optional base offset to the encrypted region. Segment offset in LUKS. + size: Optional size hint. If ``None`` or ``"dynamic"``, determine the size by seeking to the end of ``fh``. + iv_tweak: Optional IV tweak, or offset. + sector_size: Optional sector size. Defaults to 512. + """ + + def __init__( + self, + fh: BinaryIO, + cipher: str, + key: bytes, + key_size: int | None = None, + offset: int = 0, + size: int | str | None = None, + iv_tweak: int = 0, + sector_size: int = 512, + ): + self.fh = fh + self.cipher = create_cipher(cipher, key, key_size or len(key) * 8, sector_size, 512) + self.offset = offset + self.iv_tweak = iv_tweak + self.sector_size = sector_size + + if size in (None, "dynamic"): + size = fh.seek(0, io.SEEK_END) - offset + + super().__init__(size) + + def _read(self, offset: int, length: int) -> bytes: + self.fh.seek(self.offset + offset) + buf = bytearray(self.fh.read(length)) + self.cipher.decrypt(buf, (offset // 512) + self.iv_tweak, buf) + return bytes(buf) + + +def find_luks_headers(fh: BinaryIO) -> tuple[int | None, int | None, int | None]: + stored_position = fh.tell() + + fh.seek(0) + first_header = None + second_header = None + version = None + + if fh.read(c_luks.LUKS2_MAGIC_L) == LUKS2_MAGIC_1ST: + first_header = 0 + version = int.from_bytes(fh.read(2), "big") + + for offset in SECONDARY_HEADER_OFFSETS: + fh.seek(offset) + if fh.read(c_luks.LUKS2_MAGIC_L) == LUKS2_MAGIC_2ND: + second_header = offset + version = int.from_bytes(fh.read(2), "big") + break + + fh.seek(stored_position) + return first_header, second_header, version + + +def is_luks_volume(fh: BinaryIO) -> bool: + """Return whether the file-like object is a LUKS volume.""" + _, _, version = find_luks_headers(fh) + return version is not None diff --git a/dissect/fve/luks/metadata.py b/dissect/fve/luks/metadata.py new file mode 100644 index 0000000..a2e8ec3 --- /dev/null +++ b/dissect/fve/luks/metadata.py @@ -0,0 +1,234 @@ +# NOTE: We can't really use __future__.annotations in this file because the JsonItem parsing is type hinting based. + +import base64 +import json +from dataclasses import dataclass, field, fields +from typing import Any, Optional, Union, get_args, get_origin + +from dissect.fve.luks.c_luks import c_luks + + +@dataclass +class JsonItem: + _raw: Optional[dict] = field(init=False, repr=False) + + @classmethod + def from_json(cls, obj: str) -> "JsonItem": # Self, but that's >=3.11 + return cls.from_dict(json.loads(obj)) + + @classmethod + def from_dict(cls, obj: dict[str, Union[str, int, dict, list]]) -> "JsonItem": # Self, but that's >=3.11 + kwargs = {} + raw = None + for fld in fields(cls): + if fld.name == "_raw": + raw = obj + continue + + value = obj.get(fld.name, None) + kwargs[fld.name] = JsonItem._parse_type(fld.type, value) + + result = cls(**kwargs) + result._raw = raw + return result + + @staticmethod + def _parse_type(type_: Any, value: Union[str, int, dict, list]) -> Union[str, int, dict, list, bytes]: + result = None + + if type_ == Optional[type_]: + result = JsonItem._parse_type(get_args(type_)[0], value) if value is not None else None + elif get_origin(type_) is Union: + for atype in get_args(type_): + try: + result = JsonItem._parse_type(atype, value) + break + except Exception: + continue + elif get_origin(type_) is list: + vtype = get_args(type_)[0] + result = [JsonItem._parse_type(vtype, v) for v in value] + elif get_origin(type_) is dict: + ktype, vtype = get_args(type_) + result = {JsonItem._parse_type(ktype, k): JsonItem._parse_type(vtype, v) for k, v in value.items()} + elif type_ is bytes: + result = base64.b64decode(value) + elif issubclass(type_, JsonItem): + result = type_.from_dict(value) + else: + result = type_(value) + + return result + + +@dataclass +class Config(JsonItem): + json_size: int + keyslots_size: Optional[int] + flags: Optional[list[str]] + requirements: Optional[list[str]] + + +@dataclass +class KeyslotArea(JsonItem): + type: str + offset: int + size: int + # if type == "raw" + encryption: Optional[str] + key_size: Optional[int] + # type == "datashift-checksum" has all the fields of "checksum" and "datashift" + # if type == "checksum" + hash: Optional[str] + sector_size: Optional[int] + # if type in ("datashift", "datashift-journal") + shift_size: Optional[int] + + +@dataclass +class KeyslotKdf(JsonItem): + type: str + salt: bytes + # if type == "pbkdf2" + hash: Optional[str] + iterations: Optional[int] + # if type in ("argon2i", "argin2id") + time: Optional[int] + memory: Optional[int] + cpus: Optional[int] + + +@dataclass +class KeyslotAf(JsonItem): + type: str + # if type == "luks1" + stripes: Optional[int] + hash: Optional[str] + + +@dataclass +class Keyslot(JsonItem): + type: str + key_size: int + area: KeyslotArea + priority: Optional[int] + # if type == "luks2" + kdf: Optional[KeyslotKdf] + af: Optional[KeyslotAf] + # if type == "reencrypt" + mode: Optional[str] + direction: Optional[str] + + +@dataclass +class Digest(JsonItem): + type: str + keyslots: list[int] + segments: list[int] + salt: bytes + digest: bytes + # if type == "pbkdf2" + hash: Optional[str] + iterations: Optional[int] + + +@dataclass +class SegmentIntegrity(JsonItem): + type: str + journal_encryption: str + journal_integrity: str + + +@dataclass +class Segment(JsonItem): + type: str + offset: int + size: Union[int, str] + flags: Optional[list[str]] + # if type == "crypt" + iv_tweak: Optional[int] + encryption: Optional[str] + sector_size: Optional[int] + integrity: Optional[SegmentIntegrity] + + +@dataclass +class Token(JsonItem): + type: str + keyslots: list[int] + + +@dataclass +class Metadata(JsonItem): + config: Config + keyslots: dict[int, Keyslot] + digests: dict[int, Digest] + segments: dict[int, Segment] + tokens: dict[int, Token] + + @classmethod + def from_luks1_header(self, header: c_luks.luks_phdr) -> "Metadata": + """Map LUKS1 header information into a :class:`Metadata` dataclass.""" + config = Config(0, None, None, None) + keyslots = {} + digests = {} + segments = {} + tokens = {} + + cipher_spec = "-".join(map(lambda v: v.rstrip(b"\x00").decode(), [header.cipherName, header.cipherMode])) + hash_spec = header.hashSpec.rstrip(b"\x00").decode() + + for idx, block in enumerate(header.keyblock): + if block.active == c_luks.LUKS_KEY_DISABLED: + continue + + keyslots[idx] = Keyslot( + type="luks1", + key_size=header.keyBytes, + area=KeyslotArea( + type="raw", + offset=block.keyMaterialOffset * 512, + size=header.keyBytes * block.stripes, + encryption=cipher_spec, + key_size=header.keyBytes, + hash=None, + sector_size=None, + shift_size=None, + ), + priority=None, + kdf=KeyslotKdf( + type="pbkdf2", + salt=block.passwordSalt, + hash=hash_spec, + iterations=block.passwordIterations, + time=None, + memory=None, + cpus=None, + ), + af=KeyslotAf(type="luks1", stripes=block.stripes, hash=hash_spec), + mode=None, + direction=None, + ) + + digests[0] = Digest( + type="pbkdf2", + keyslots=list(keyslots.keys()), + segments=[0], + salt=header.mkDigestSalt, + digest=header.mkDigest, + hash=hash_spec, + iterations=header.mkDigestIterations, + ) + + segments[0] = Segment( + type="crypt", + offset=header.payloadOffset * 512, + size="dynamic", + flags=None, + iv_tweak=0, + encryption=cipher_spec, + sector_size=512, + integrity=None, + ) + + return Metadata(config, keyslots, digests, segments, tokens) diff --git a/dissect/fve/tools/__init__.py b/dissect/fve/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dissect/fve/tools/dd.py b/dissect/fve/tools/dd.py new file mode 100644 index 0000000..5bec4fe --- /dev/null +++ b/dissect/fve/tools/dd.py @@ -0,0 +1,235 @@ +from __future__ import annotations + +import argparse +import io +import sys +import traceback +from pathlib import Path +from typing import BinaryIO + +from dissect.target import container, volume + +from dissect.fve.bde import BDE, is_bde_volume +from dissect.fve.luks import LUKS, is_luks_volume +from dissect.fve.luks.luks import CryptStream + +try: + from rich.progress import ( + BarColumn, + DownloadColumn, + Progress, + TextColumn, + TimeRemainingColumn, + TransferSpeedColumn, + ) + + progress = Progress( + TextColumn("[bold blue]{task.fields[filename]}", justify="right"), + BarColumn(bar_width=None), + "[progress.percentage]{task.percentage:>3.1f}%", + "•", + DownloadColumn(), + "•", + TransferSpeedColumn(), + "•", + TimeRemainingColumn(), + transient=True, + ) + + log = progress.console.log +except ImportError: + + class Progress: + def __init__(self): + self.filename = None + self.total = None + + self.position = 0 + + def __enter__(self): + pass + + def __exit__(self, *args, **kwargs): + sys.stderr.write("\n") + sys.stderr.flush() + + def add_task(self, name, filename, total, **kwargs): + self.filename = filename + self.total = total + + def update(self, task_id, advance): + self.position += advance + + sys.stderr.write(f"\r{self.filename} {(self.position / self.total) * 100:0.2f}%") + sys.stderr.flush() + + import logging + + progress = Progress() + + logger = logging.getLogger(__name__) + stream_handler = logging.StreamHandler() + stream_handler.setFormatter(logging.Formatter("%(message)s")) + stream_handler.setLevel(logging.INFO) + logger.addHandler(stream_handler) + logger.setLevel(logging.INFO) + + log = logger.info + + +def stream( + fhin: BinaryIO, + fhout: BinaryIO, + offset: int, + length: int, + chunk_size: int = io.DEFAULT_BUFFER_SIZE, + task_id: int | None = None, +) -> None: + fhin.seek(offset) + while length != 0: + read_size = min(length, chunk_size) + fhout.write(fhin.read(read_size)) + + progress.update(task_id, advance=read_size) + + length -= read_size + + +def open_fve(vol: BinaryIO, args: argparse.Namespace) -> BinaryIO: + # Currently only BDE and LUKS + if is_bde_volume(vol): + return _open_bde(vol, args) + elif is_luks_volume(vol): + return _open_luks(vol, args) + else: + # Plain volume, return itself + return vol + + +def _open_bde(vol: BinaryIO, args: argparse.Namespace) -> BinaryIO | None: + bde = BDE(vol) + + if bde.has_clear_key(): + bde.unlock_with_clear_key() + else: + if args.passphrase and bde.has_passphrase(): + try: + bde.unlock_with_passphrase(args.passphrase) + log("Unlocked BDE volume with passphrase") + except Exception as e: + log(f"Failed to unlock BDE volume with passphrase: {e}") + + elif args.recovery and bde.has_recovery_password(): + try: + bde.unlock_with_recovery_password(args.recovery) + log("Unlocked BDE volume with recovery password") + except Exception as e: + log(f"Failed to unlock BDE volume with recovery password: {e}") + + elif args.unlock_file: + try: + with args.unlock_file.open("rb") as fh: + bde.unlock_with_bek(fh) + log("Unlocked BDE volume with BEK") + except Exception as e: + log(f"Failed to unlock BDE volume with BEK: {e}") + + if not bde.unlocked: + log("Failed to unlock BDE volume") + else: + return bde.open() + + +def _open_luks(vol: BinaryIO, args: argparse.Namespace) -> BinaryIO | None: + luks = LUKS(vol) + + if args.passphrase: + try: + luks.unlock_with_passphrase(args.passphrase, args.key_slot) + log("Unlocked LUKS volume with passphrase") + except Exception as e: + log(f"Failed to unlock LUKS volume with passphrase: {e}") + elif args.unlock_file: + try: + luks.unlock_with_key_file(args.unlock_file, args.keyfile_offset, args.keyfile_size, args.key_slot) + log("Unlocked LUKS volume with key file") + except Exception as e: + log(f"Failed to unlock LUKS volume with key file: {e}") + + if not luks.unlocked: + log("Failed to unlock LUKS volume") + else: + return luks.open() + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("input", type=Path, help="path to container with encrypted volume") + parser.add_argument("-p", "--passphrase", type=str, help="user passphrase") + parser.add_argument("-r", "--recovery", type=str, help="recovery passphrase") + parser.add_argument("-f", "--unlock-file", type=Path, help="unlock file") + parser.add_argument("--key-slot", type=int, help="LUKS keyslot") + parser.add_argument("--keyfile-offset", type=int, help="LUKS keyfile offset") + parser.add_argument("--keyfile-size", type=int, help="LUKS keyfile size") + parser.add_argument("-o", "--output", type=Path, required=True, help="path to output file") + parser.add_argument("-v", "--verbose", action="count", default=3, help="increase output verbosity") + args = parser.parse_args() + + in_path = args.input.resolve() + + if not in_path.exists(): + parser.exit(f"Input file doesn't exist: {in_path}") + + disk = container.open(in_path) + try: + vs = volume.open(disk) + disk_volumes = vs.volumes + except Exception: + log("Container has no volume system, treating as raw instead") + disk_volumes = [volume.Volume(disk, 1, 0, disk.size, None, None, disk=disk)] + + volumes = [] + for vol in disk_volumes: + fve_vol = None + + try: + fve_vol = open_fve(vol, args) + except Exception: + log(traceback.format_exc()) + log("Exception opening FVE volume") + + if fve_vol is None: + parser.exit(f"Failed to open FVE volume: {vol}") + else: + volumes.append((vol, fve_vol)) + + task_id = progress.add_task("decrypt", start=True, visible=True, filename=in_path.name, total=disk.size) + + offset = 0 + with progress: + with args.output.open("wb") as fh: + for vol, fve_vol in volumes: + if offset != vol.offset: + # We're not to the beginning of the volume yet, fill in + stream(disk, fh, offset, vol.offset - offset, task_id=task_id) + offset = vol.offset + + # Stream the decrypted volume + src_vol = fve_vol or vol + stream(src_vol, fh, 0, src_vol.size, task_id=task_id) + offset += src_vol.size + + if isinstance(fve_vol, CryptStream): + # LUKS volumes don't actually start at the beginning like Bitlocker + offset += fve_vol.offset + + # There's data after the volumes until the end of the disk + if offset != disk.size: + stream(disk, fh, offset, disk.size - offset, task_id=task_id) + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + pass diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2203c55 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,67 @@ +[build-system] +requires = ["setuptools>=65.5.0", "setuptools_scm[toml]>=6.4.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "dissect.fve" +description = "A Dissect module implementing a parsers for full volume encryption implementations, currently Linux Unified Key Setup (LUKS1 and LUKS2) and Microsoft's Bitlocker Disk Encryption" +readme = "README.md" +requires-python = "~=3.9" +license.text = "Affero General Public License v3" +authors = [ + {name = "Dissect Team", email = "dissect@fox-it.com"} +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Developers", + "Intended Audience :: Information Technology", + "License :: OSI Approved", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Topic :: Internet :: Log Analysis", + "Topic :: Scientific/Engineering :: Information Analysis", + "Topic :: Security", + "Topic :: Utilities", +] +dependencies = [ + "dissect.cstruct>=4,<5", + "dissect.util>=3,<4", + "pycryptodome", + "argon2-cffi", +] +dynamic = ["version"] + +[project.urls] +homepage = "https://dissect.tools" +documentation = "https://docs.dissect.tools/en/latest/projects/dissect.fve" +repository = "https://github.com/fox-it/dissect.fve" + +[project.optional-dependencies] +full = [ + "dissect.target", + "rich", +] +dev = [ + "dissect.cstruct>=4.0.dev,<5.0.dev", + "dissect.util>=3.0.dev,<4.0.dev", +] + +[project.scripts] +fve-dd = "dissect.fve.tools.dd:main" + +[tool.black] +line-length = 120 + +[tool.isort] +profile = "black" +known_first_party = ["dissect.fve"] +known_third_party = ["dissect"] + +[tool.setuptools] +license-files = ["LICENSE", "COPYRIGHT"] + +[tool.setuptools.packages.find] +include = ["dissect.*"] + +[tool.setuptools_scm] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/_data/bde/aes-xts_128.bin.gz b/tests/_data/bde/aes-xts_128.bin.gz new file mode 100644 index 0000000000000000000000000000000000000000..a02216d3af3cae394e109431d9ffd7fd92bdbe74 GIT binary patch literal 93617 zcmV(yKx1e-~;`S zhko*ZWj!P?!2bhSC`A(+J696|V+$uMCjvW10y}4O6Gs9Y6JrYlx<6Pb;s4wTOdK8U z9RE|~9Zj5^2n=jJ2&_yz2%PN*{?nZe9GzjI0RBI7|Nrdo{{( D?j;VvGK+kc>N~$U0gBIoBWu?5lD1PKXeBKQxCid!> S{jDV zNm-!6Z%@8a4Z8*!^iznvRBUh>|9 zdrRY7T*^hLZx@QoP^B0VHO3AEIW12m&x=1Gt7UPbO&bR!&|o7|A&+~oLNyU!sp}9b z9|`n~Au$H*bgu;5UjIs#JvxgN=m2BgK+4h_p#)#?wR3iEW1tkJK(_U{m;yAT3zSx9 zf_9~wfng+wCh|l*yH4h~Au;$~w>#Db7+2WTimrKv=4QPK+%1UzfeV6O*W)&|L+Ra) z9AhsNr0e%@mfVuR>!X#k2SF2ES7lZrn;I%Dw4HQehgQbWlnA0G%(X5OKo@Nlv2g`( zL* Lj8NBrR{jdiJZ;H0nprKWx2ab7hol%Kty~6jQ>>h;bOa=f0&h7_9ev&c zrdqx!`ux0H_Nk_*J_EJsw_R#>NNsmVqb4I~EBI_TV2&xSEQFWwg&o}h%pyKiK YmoQA!B9KmI =aqL1Rt%&jq5l!U&m zQg9Oi@tZzDl}|%b#Yi<;lIT|m0y-SpJ!Pif?TW8{HbYZ~vqt0@*TyOx8wui=j%A_R z$0iX-mPovTxT9u5G2jFgPgen>7xMVmFya74Sw8%VfX_47_9SwoFQSG>lcj H8 zTA^6%+8;&hdK_ytfkt+gc7niFMH+85gz&u0)UN7Cl7BJ$!ESqHepev) &MxxonaNNu0AlMb(KQU!59z9ggZ+yKp-x@5U9%E72r@!F& z4ao+(rfqgUbgpt9MwnoJZ!x=zAHu6P!S)I)X(F2A#j_iX=ow>$Z)ftp(V?6K(R}xT zjg<|9B_oppaw>_d49n=>k!08$t<@2_6!V8Y|CXBUk4Vd)C_AaTlttxxfZ?>k$t)67 zer&h>mX!mxzlv7n0PR3 vAW!Q_xSPmq;_;YZnj^Ah}RjrQ SGjzsiwsigJ*?jdN*I&de=Sa8FnMO$OI9IHS=vdIO{8b`xVS?ileIzjKMf z@gZi@d{r{ezv3A%7B(^kqiQxTSQJk*9vF2FqrJ+F{CzTs4y=uuB AX zt_nMdnnJ%7*rwpd(?@NfYQxWMBrH2=HmXf{&qjxNO-_k)N_wCvDXniBRXT!YLwQYH zK0_N4Sz3CxIBi=9Yr5g?PA953iJNrP=gQ$e5!;J x6k)S z7&(r3#6SA2pVDmQ5NWg5b2)jB)>jalM7N53+f|l(v9HR p6IT(;3gz z-V1wRxi(<6E83z895*_B1hDtowiA4)K~!p`{}u=YW0MQu$}v5r{T;-kKj2bk+ LdqX922ugA{WDWv{$|K!U@FF-81jNlYf{#sTg)Ts9H_{LJWNe{L0Gb??zd)q zm`fPCAuCrZ<`*`#dg!m52ZE&zOXhcTn1gSN3@GI%c(@A ;bVrh+DXU`=$eYf(Uf+1Bmo5R1C1V)ud13l1IPuo0e!zd#whRppJ*EDxCP zFFf>iU- pT70%72%Am_5$(<6dUvb zEu2XZv&F=_uj4@Dm>)FJ^HhZ+BJEi)iCLi+9`P+3Rw@6@+iL`Gpjui!q{I+35Zf~k zRO;*7eYgM&^ `9E*# z)LV!F%tKo9yM3-cI-fChy{RA&1l~1eN*eyl(9+V>6@lKQ5L~R=JAiR$vQ1>0ez e^%^$%IbV=6;7j1c1@88FBGGo z_4b}z7W_D}ENd_bmfs;&=C0pYHWIm2&=0EAu70!-R_eKTB9VM}-W$Uw5R$NpjW{J( zW%fEWOB7Zm0*b_>HRKmE`c722FR5P{Txp~nH;huIs8u?P5wK!Rja5PxfS^edGFU9U zS{3q&N_|QG@D8Cv^}yy#(z#B*kstp%A^G-!BRmB*3NWB$$Ar*zSG$rI)L=Kh%dr7! zq8-LF$EE`b*0aDxJy4n?mb%Z?!seTQv(QeaYzV(vg C9 zO@sk*1tQNfexA?g0|+-xj-MJp9}DY=zK;;~^tkT`vesX91>#_oc+c2*vP3m+_pj4@ zGbU%$GaiReT^{9i=QF&|^LBXY5IU1oll*pzIfY>P%noKxgh=$O9V##r*D@*2RGTl< zMP90(Bz3_HQPn|LrL~g-*Oa*%Qt+L^^_lUnx^BN$Ky($5^k-@EPn+L7D7EN&;$2DT zL}H2`h$eAnR6L`J91U{E@J v=sD^Wpwtx(ha5;e}(u~^~=zm>B`zjcuHirq# znM*OEo-TXYGP3l?wjq30yZN1$u5q*3qkVoDPITnE7FB0+%DElUnYvxla_~H*t>E13 z{0|Nig^mybkD2Jt4eKG_`^{f^Zatr(#81Q4*{zn~S_iOrJ-jC2fxl{MX$37Bb91`G zW5UJ8ZpjrAqLNY^uE)654avbZ;Rk+wmtg2 }7&dhvS^xljY_u^nUXEK6j%Elov z1uli0ny? Te%2B}wmt>-6peqUQ8m}*ZhSM{-^Ro$v3ZIyJM)WK0aL7cU=^cM~N@D4TM zPQOL?IcYcWcsK}+97>|K*HLb$HNo9S34O?)Vv7k5_RvZomfBds#4HN-{+FV7ZYF5{ z+5R es3g6ighuJ;?eSqxUv*!AJsU4m9&smOL{ zt#U;m%=; )YN1t0np}QVL87tl_*UFD|bNS;~>CP|yI^K%= z5B6yj9Y2z|CIZ6`o4j(cUpY=tG|mQkW*D%wHf5%)HT`J-v8@9iimY6}?=U;VY49(Q zgwMj5O1-yd+LyZS;?!FMPcdF>B-nZ{b!jSo9Xbzy#kP;2;_XkFfP06arWGV&-@Ne| z4CRWlrW>#rx4vvy%?QZ*({b}2o#( (6`wNMge#9CyZyK`ay@h0?Sjw08i@6B%_-xp9KZV6lDU^4O-$2@d9S9i+ifV z4sNjhS#lc8@WC39WZ&i#u71g+S|8S7L!_syfD22W!8n$r? V)uH!2E2 zHmcF#Cay=PXLVH)1QvS8d+^)rn~Vft*zxkhfU>gO*%cE2<}A+4u!|!8ain`Kmn3=q z9b|jX4h^y-y9#Qk@Og3tNq~GV=1Jb@d0PUvNr>p-szNm*-~y7*%or*iuk}zr`Ng!W z_u1jK?f0CiyJI`qE0JrF+e{hJK6OQb6Bi>m))1hOO|&u*EmWOXq=r(_xn**nbE3$H zjvtja2u!3F`x+P}^_Vfd#Kv|1*h0S^K0KyNcZMBLRQBJSEL3E2KQ>41tEUI}(|wN( zLG>;y_d#o6r+JRxfEprC>61)A8DDK9i1A(|O;uc^qaht2QDhQ$*8H%5b_vuXSf2r9 z)ax8u3W % 1%D{yT@ZM!*F zWWTYOL|0T|ftS=Zs>NA3yLbpgkVHG)>b=k~iH+80-h>ubv$>@OAEeHb8+X2%b|}Jt z_YZhdo~=CH0s8GVwF0v8vXZ#NIM4fv#FQ@IP>SKM_GSbzsB zL(8&A>*S&5zRM }C#?0v+VC^v zTKzd_0jrCLit~cgxh28iqEyy+#HP8_hiSz5F?2h8mlRAE%ZU }o^szGTD|`S`mrL) z(bZmGBMT{-A;h+oyn_y!k|Y#Yr|x2QEpLUy%iZ73gvO>cvGY+8ZMTkCXFMLxRmveW zBj(h5)u7m1$?z*=moc0{wG+V(%j6OH>r2RcH3UwFH^Xp6K0^;DAE_1DF5{bcBhWVD z%)uQs(WgRhk;xAl>v7Lt!9Pkem5gss0)aGy6kSnVSc<-Ki*tqL&b2oFdm={9gi621 zJpHO8K;Y;r$|^v~UXaHhqJAZAc`ZaNjEA)iy{nS_7;{rX&6&MHa8&wia+N+>H>X7H zkbZagXDpgNQU587cA_-J!&iH-y<*m)6HQn4XxWV}^es7)w6b5886P+n`lrn$%zL(~ z_i)J$3I6YTXH|;3EJ{VGXRebIbd(Jbb9w%FoFNKx=EqSKI UhlX$xYJvuV$nFJ{j}N-gV5CT$0+kX0L> z z_vZ%nx$&mCX_-SezFC&tyGunq *-e@V0lHFg|q?0%3q zI;VYNj6i_d%4+;&qyP iG~sXTfCCE<-t_9V$_28B;LuWuQ^W-FUb1-C4GgsZAR zNa5q5%bDKPR&N)GRDU%5#(ydaMEkPKu`~V#>pKM{ b;4&lb_$exgpkAa?J>43bO>Zk6&u;tBz?mO}09)*{1s^+@L$)zp@ zq$~qRcVwC-pok;cPKl{WvZ4c&h~0G!z$@7L=}&<1Sg&v3+5q$#Wx;@5P?yV(y$9~? ztCuVh2LA#KsK$kIE4t1{D<6_EVbL#{V?vPZv&|jfoq1h&c^yTBywHxULiU|-Mh#`k zD|5OwI?viBz#i9e`s&{BgaiEA2C8_cA5z}wR~ui15)W`{1zwtJvYb)_m#(I+BVC<- z`DJvLdRFSZCV# AzuXG{7nL z^?tH5%d5`oKv5vWxz{gIsMN@%sNVZ>LR$i@;4HHqtF~IkQr+LeFE@N6&UTx12AQnj z;PG{%CwuvhVo9gHiPVTdx|e1tPIj4VxX7ZYDMz?mChRqNSmQ8aPqwqspjGKYM{)+V zawaMi@n*E}TU;2dh@a2dVSQuwE?quW^s<}LH0yYuIon& Tr>)|%zHsz;1gUi_2a$Lc{n z-k1olLI7B0S01f>Lfs>aNc3zX3t3P;f{dXlwBY_ShPkDpCA%ze70cO_=*qE>t;6WW z#h(U}hVk#qKq;rDN+F$c!NH@p%Y>uq9ww|YH>~=W{K0?v+NJ$TU=4K1hBUN7+{LaU zF6JAHxC!mUrc_+FAYM`E3HSsM+$+Wi9}|$V`8EVMNsb=Nxt7}m8F)PcK&s`aVt9n2 z9Gc7>SIfxD;Li4g`u96NPAG>s9FN(tEzJubR-x;#2YXdddbm8w8LIPGx7i7xFG*~S ztbm@$*LENR>ILPcSK*&Tk^5%)U^k&^G7GA}KG;mVh96}f(o{X7zm_wwp{E>PnH1%q zC(%<*jV7`!3GGZYnRR0YvdRl_iBw_3gB%G(Pgw^9>hq(_U(vWA;h|!O0Vm{p(^S_b zoi4-}4=urOopeI#96cjL0g!STJP#xxzYQs^hg%IK*1B!!U5erP%BxzH%}|2&;Y#vz z9Ff@oO|y}?DztdCg=i1 m#29xEP{aH;U!p u(STkWI3lLhhPivWrGo?$^>LE7>$Cf z0ezKdFC!|+lObK@`BDw#XR9+|Ia^06lJG{iGE<>LLZ?M$Et%bu w0qTU+A%r@E zsqL3%2aNarQSn{Z4Q~5xh`!1_W(}HD8_*d*=Ep9 P@`;n*uEM5#a0wkww_UpLZqAZ^`h$ z>Jtv_;28OLbl=ALf+lNF03-)o;#hdzH^pZd0|-C^m9x&fF=xUEg+Mo1&{>)3#??7% ze%7pLM}%sXs$*SOZy|+Xsh2bjI+d%pR&7^%woUnTU8hGJdpKJ7Vn{44tXdvkzxW9) zsJ|wrz5K+s5Hd9RUpqsbp=p>knakE%jJYPjp9a+RnmVaf6x!@S;}4UpV9mh?#d& z&d=+V=fEyy{ Wz;dQuFogP@4a6c?iU#SKkhHvmIRQ!NFWmh(=!{8?%IjQHuS5I zyz> PPOu;KI2ECFSff?Kx%0<8eq#w z#K$b)Er*dX>8>`TOC8>|maT um6N%rZdgSI41maXKX z_q9w1ubR#ltYvNwHH@K9+w ehivf zfva%{M^xAPzdg{2cCSJ3KU961xgp&lmMae{cN&HWYOm6Yfidx+u{sSrxv3#h!)?73 z0tS-3ZV)H6=E$pRlovaX#_le1)RJ8kFc23^)bN`rJJvvc(Ou=oX$n(c0J9vc9e%OJ z>I0kc0a^Y2BTEJp_W3bNM^5bAGMf}7k2I+?Mk89AP4#DaaoU@^tOq0o`4T9X+^wCj z59W~;p+HE#vfMH8dKxF#hB#zC@3}Bgw!Fip4iN3gVXt{P&k3mroi>I+{dn{Scglu* z-caSA43vD^NkI^bu_;i9QYkv`X2adxwL@2VVr)JTgALm(KO9UP P=Q?Za~6 z5A=H+I2czCkp3~$C=TQ7VbS9P>80%InT|2=helz8A)eCWI{3SJs3T!6npG4p#5YWT zc4u=~X)!?Dm!-tSYH5#x3HO#ZgwXy8Cb;>mPwDKL$;IDt+6tFtF3Qv?>_ArA4#V#! z_t=cFm?l;!u@#v>a#qd8hN`(ui_!uvuLf_(EmW5Sfit8qe>EYjD^~iD{$kZ&YdF~H zWS=A ?!W4aB`X8szbveAFSDIb-74Dz1L9)cAxETE~*Hdn4*xw zBgwf)H0E$W9T9uXv4MQ08Omtv3owjg8=Z#9^=ceg(3yDQu%O$%5whLi9L&$sN=k{3 zf$0Z9GdS=)OnBIWMHfBY5L!dL=zOD7jB8TCQj> 3SB8r?=U*bRhTAO_mHyPweV1wjqx2i3aWMIr-aUpTF@l3!`W_834$E6 z-ELwOWITm^UJL#n^audeF1V!BOzP@-_mRcSjdt_)U1|)o+&tfj!WgZwfFhwaSVd|% zpJfO!*Bt;a0R?l~QVhX !tw&c60b{*%-rSSZ9J(VDbWe&+<7NkP3v+V z?P?;t4WCjL<-Om`jrixjIwi|^(oKqhmJA?A@Yfth4Jf>}q9&~~$?`w-R7wy=9mrqz zFhw-mk#pmBvDQ$(P1PJIuqtWa*jvxDK&cmQhGQnvO&9=5hWi7c3{;O4;8){We!FtG znhuEo+3ZFIZDBlcj_ZKFqZ$C?Zvv__#G$=*6zi|k7<}5M+NMeJsqYB=0-#!wrPk-5 z((m#(gP3b1j`;*9(0djo=z_tTIfYrX32xvdkhddmILFkR&%PYyy(EAu@^?6WJc5nS zEzoOLjj89V@DL}%il+ND8+Bcn?Y)y>>eS)2#O>iMf!N+$Dx{BrBD!96CWcF|$Gc>? zIxP6Jy`!-?4$9nz54s@@fxIDV>i;O9{@D~P6UKfY-o#!GVi3Ba2soW6RhC*{fc%3` z6u54 5l2N=<;9_@& !)|WU0qc8mIL!d8`NvdaV;l z498$~BlS*P*c+%nshAcwEwO{%^QNQvLkkVi-h=Tao4=()^R*?>b&mg+_f85u&Zs2k zhw5!A(C%a#`UP4qZ ?9=u;WZEX$G{5@gy&R6!>_!kh+Qe5j9J z{%MpaKK>)Lv?9Q^Qjm9#fx<^hzbjgmx 4zt(B3X58()) zc%! ==NUnC_HUdhsA;4UTNd?98&M(bK#4XD+S+%&Lg|Gb z5^*XsP-(Tjsr{F)&+ED`L2j*E_SaCe{I7hnN7(>Q#5kcS`Xb!=%qQRZ{DQ@}>wYaC z4>KO?eCl7ivLMw1PJmQ#e38e>yYVl*0+unmV|pT^8sHqMlJekc+=vBs&B^yh3|O2M zeC_kH%b-froLpmvjRZZKPC@JFS9ek&@f(JNwHHCT8OCX2Aiu3#3W?)cYRrQnl$bx4 z%*6)N5V@#E2U15$q38Ta4?_U0f*+iO4SwCtsNqGwuGt<;3B;dqeXPU?plbUS|NdLT z(V3w3_hU{Q^$=Qbd7$L HdG5> >?F)Rx9Z_?DM>81dJr0;! zRe8g7d=T;*zb04p7NJ&Ls`5$Hww6jW9q(m1%QgbzHA#i*`J*($nc=@Ae$%J%U` z1%((Sm6r%tAE_HVsV@4&GEft}y7Krc$Djc))V2;gMxu07nPa39e>4n(|0?%^nQql% zX{%`6?L5wK b|a4xrsT7T$(gQntA>G$Umk4kDE@76erAhSs9WQ z!YK9E3gmD~krpbfV!LDWzw(J)GT7YSJ4Enr(S0Asf~B#n23{^CyHUzf1$Ge2KxOp! zIcO C7KpqKJYMFCs6zt8#|#sh&os?^sq>?-D^ z8ij7t0Z7QZ%qgDZLYtLW?>sewDrz6j%T~!e>QK1)Dy%d6i#n5m)@45Z(gKA)NAE54 zf??^4dU1J~Pm5MF&XV+5JhKUmW*=PMWl#{ZfSpw7(+oP0)hIWek% hi&q+RipUOIqsgk z=>x5=696L=1th(0sjy(m_mKMC{CN690!qFdO`>Q3*&cmFN=WSs dkQH7Yabn z!)9%SvG-XsXsQad4cxhN |I#s_CR|(mzvHjhpquRB?u@D@@o z{6dfBVC>49era`Mu}qaeYe1B(1w$yv$qc^-Y0y?*d4jbZ?9FC+8!+rO6xplz8ym)f zQ_@AG@*Z{LFpf4^d_XpW9-W4?h-z;hX8~62L&`-u2jwL-scmbFMn)qtt6_X;3HzDD zq!Y>3xw1(3rXRV1=3j88IyX2E@T)*8AStC;b*k$0E#ib9D^G;$0i0!B1|-~tZe2sb z4^<}cN4eDVpn}BKp)jlyW{i+HF)0eI`tlT>Gd{7*=Nw>8Ap4nP?nqiwUlj!0@n|n$ z*5qULxe<(}g`2^V24?d2D>Oyz=#61QafjzkzrF|MZD89g-O|VMCNCNV-q0fmqvjBU zDpbbG5(8QPv4Ybku7W=p86hK;fv&XLeI&oKRJ075`5fTOD2 2bv*e}$p5#dp6>sNibmM&~ <78Sa4;z*3JZ7Q_9ZMn~R3A${9Y-XwJBG?A;hP^{4J~s3S?PPIG zOI!gKSIdZ|Pwd3oEXzs+dOvGv&E!5JzTyG6CQEEb(Yp{My-*%7f?GD{QMBDH8=h>y zt?nHy$r (b-3u#AA6&f?XN;(`hP2b6 zU5D0Yzsg%h@8I^XOG`HMs;h0-rLe1GQ7u*oWjWo6*shg)VYiH>3MVods(n0Ht$#p& zXAe+-GuOJlRTs@YH+Ft}EI~waRwT8nS5)3wg#3Z(EQ`NIhYdM}q;U_3JlZsTFWJ}> z{klLkunm^qh2`ttyh?{ydT^&mDcS(+TNuIKLt7dm&}2J%z@z+PTu_iGiW2@M(ZQ5* z$pQN^rQr3EXpc*US%KWMD-fFR@UYmi?%sAuPd$|8Ue&4&?BqZ#$~ka8Fsr08e9P{i zD{T3cZ-W|>?SRBErxWgg&OG=xEAclc!btwO)fTFXACgY3P@6V=|Atx5wjUbNW>Uf| zfX_i&>oF76@|sZ@2@02=Z(9D!x}0ooGB)6^$*5qs=HF8oQ`w{kIfpD-0zI!?sZ*F( zW>Q5WLEg5RdY>bz);N{T%&&3E#i3a~==&UJ)i7=g8J>w8x{c4HNvOWvk8F*0T~V#9 zHGK+qTTplgNVwp(i2xIE4!s3stNMpo^EdX*5RdzzmzML%m3cGU@W G81#-lu#k{n7{sJS u4&;z|czQkuc;0cm1V|t=Ul2|M1mz^z* zf4-|V3)NM4bBr0%qf8M$2MiQWhI_o5`S=1B`X=}FjNt8(`i0PxbUTF-WnHBII{)mG zp8m}vx$JkAu~{Z@!Dc_GA_UbY*_6fDwQ(>=P_^SUILf=SZZC`_s2lVuAwM@)WZWh| zvU@m68;H@pj6^!8E|YVFzXJ$g;E&Uyev9)V+kb$GahmkS2=vBE6 1M`ST9*b(b$fhEOF)+sc6e)F!${}yZr?%YG*ZuoveXdr0#n$ zVp!GTY=0Nj5ksBucvV-NoUFH6nLh{m1z&xzXQX>kc`qHQ6Hh=-6xuLmd}32b3pK{e zVA3n@)X4 zuF%4S1Q8uZsqkpB_|*w{$IAe+j3)MVCpcK!bVF#AvFYRKEc1<*Pmm}3aCjxHt<6JJ zm6p(VM?Dr8w}Z;c`~+xLnLnhDdQnm<5ao~5UhDt2VB*XS^_D)`dS Xyi z$8OE_7v!11> BX*j^BNU|pFNtJ zbhpWi`71`F(+%%9o^+iX=lUr}rW}-lXa-qH=t*F>JE4 >v ;W4H%d8k th^Bv$w9d^*JB) zN?oosUHyzck5G4OkyY7u-rO+mIaI1$tZJT%s`ttY^&n9u$TM^Iit*e+JR77OK<24I z55oQhqG=<91*X7n3Hk$z;e0JOfv%|=OCd52{K1MZ{WIqs#TelWxAzChuWL53H$7ig zWM#lOAFBat>@Ra7@_28rUi1 zj_~%D;!egT?w!)P}ixd>Oh^ ztAzMh|0bR&QR{B%BlEpU`OOayw?96es5tF=5gc@^8Fu>WM_qsYPga`XR$CC(vS1h4 z8vq}_hOX+6mUfCE|1xDD?y@@&$%bK|F ix0SwJaLyrG66~3cU+sLXpc2VgZ?83b#UwRxVUXA8muY zo7d6#h8^=0gdb~}x|=U7js7eM&qFCwKL)=)c^s-f`ka<#CgL#8dlTah9Ysf-fL^b; z!~+Ctf2)|qp}TEkdZ;p{Q5hsfB6m82!7pliHsVl`38fW5vaL&1HXnL+TN`_QMKt&v z7VosLAkHHSmEYeVKii0W;qKit*;=I%VbJOmW0>(8I+LBjbXQ_PY%$g5pU6`(ZH&Wo zbJpbAy+Vhoq65NTKIIXVKn+V;7jde~;SSVkZU}TJ!dTh#iNb(;Bg(GO_?iBg99za| zoFiv7vBj;#8BZ&kj* v&>nT3ay *>QY{? z4Wf&PD_x#`#t`MvO{QT4KMI%7NkLia`w%~K;z<7V;wHHJTo^zp_bU8CRNsgRRc SQj#dmD{_Hs*yo;STgpPLkeYugCSz6 z-`Y*=JEGXgUCS^zD1F(qre+sJn$EM?t?LP6E+$&9A!RI=HRFCH`=aEjKu9Y;; z$95t3+GxwBj2`C&z0y4zu!&|St5ccUtn)fyDaRsn=0v^IIs<)#`aMx>;Sq8C#frYU zLG!U))r#oUADCEaQSwQ0uVgNYgd=slmV)h}Y~P#t;}>9#rC~wrG;Q)OQN^0f_i@qi zQibt1&&*fR@D!)%st`28#P8jSg$lTtCjudFAFfOIE`y7@_WY2WWgePE-5^_J)|lH1 z#436aqsPg0ZfnuICm*Rb5Xv%*5=!Y4DKef$>f4@))v5gC3j(CK(>NKlIuC7;LkDRD z;<`8w2pC$|N?Z8|M5OB|bGGDQpi(bZ4F9cM7vQ@8R+*xp@uP&xx+^fRk$~#Y0soeS zPaZ!My#g8&Ao6Ws3lIOjN4c;D<_bA|+McvH$d+@b`Aa5X?*fOMXq+?FjhuRwKcC|i z7(|qAn>P@$+bOP|7d2u^iqtN|RjzIa;B$<~4>Hwi5wjA_f%vAxKzy5!)K2p4DUw2~ z{cnMMT1G0z_l}gVSSU#)Y&|T1*w@ zQIDIv0|Ys3#sQeZ(a{-jnY`V853Ho!N^z*Q?u$|9%N>_w&@US6h&M4kmipO%&VY}j zz$!er;ha%tB7jWyVt8N3W+iHlIB#Sc+d8;U0g2QhZPb`s5ybn(w~0~~*6~X)L9X;7 ziJsuYaI8|TS3|T!72PW
6zhcByGCpxGR!w)tRT ({r5(6tZ~f%!gKHDAkCKG00o#aHjgL+r#+C x Pvk7g4Gr@e`C9XKxX?i$Ql#` znJ?Wf*f=q3GLA@F@xAHKc-wAW&@e4-vRw|a-BH3ZWoL8J=(pzY*C(D_>Ma(YGP`V> zcv!{PsFSyYT6q%2d3}qvD}8xDQ5T#!4g}J`_}2GKSHdd5Ec(IYFQe*{+(9sR_(A4q zb58#|bP2;birq2`YD|!rsYgl9D};PMM5m1tHl-41^ZpX%&TSTkRWbTP+$+E;xB;d| zbWy<}qJ*cno_Zg0Za%2y*2x|D4z+kB{92kGsWbc?v*DpVu^(FxwxH5L*W$og!+50t z?|lyg)a|($XrTz2yf(&bzj~GZ8L4NxWR$>$ 96NZ)FQ>N(1Fg2PGQsV*XF}2eZbPm6}FX!Gh@hN0e&UuW8 z8j@P+aV0*$q_wnindNe$cq4|)`ia7r2Mk!$Pn4|iNC=_*GLX6ImBRJ%v~;_(B<`(( zDZpAK3pabr x zptCD rQCe+QC56!VjeXdo=ebSX(pEj(VMd86j-K{1 z7uk6(@)}hEx9(zLOu`eDI27IUF_D460Ta?Kf5KiSdX#UV_pc#_FH|1L@2)5ja8>Ev zpSt1mp3(DcJrYCJ*b>T){?%)fV~EQ)V(t*OvHhqE=ziEBR^mZ;8WD2y!33d)?zzk; z;aADnz#WuQ%;Hy(D3j(ee)XSEs(p3Zqn_$iHjq{t3?^i}gyWQjNp&`)7)tP2tBhSR z9D=S&EE&Dq-5}h5_wyEY7KvsY_*XMN93QpfI*WcU-7R-m+!g{a8rU){-x?167Y@Nl zgELj=RLc~$_M7lAr5R$CZ+h8)w(jYMRpzOGjuXO68WiN&HO~nmmJ)Inv#VDhA~;L8 zRUrf*cbPo0ZJ4dK01j-orKC>B$>E7`e>QXNeLGTUO6#DQR1eFBq*N){@ULyr0rFLB z_)VS4)W$v@M!26?dUwjdB}G$78KxefSDYk1RfHm%Hr9_lDviKK5;s@Yu)+z9sI=0P zpm{=NQ`LuE(A73>%#7qh2_X~B{E~GRd1a;|6%74}@{7PZXxYee5nMurwoaS8L+2ix zU_S@ A#Kos6iDHW#^nE~|15OPI) zt+KFQ1fn>5x;q8y6V Y2>c6 zsS#7sQmt}F=q}AWIzG`aMq>5*vI*uJuR3~_nEW55mF!5lz6oeU4>E!Y0NuBXT3wJA zzC67613RdW40~ QIfuAfjl|=L$F?xDgy<*t5G uy8Vf#ee!Bc;(`&+GHXk=fpAYO2!JEJ+tbT^2Zgs!_J!uw-P{_^!4{ z8i u3;92lK^P^uFej0IkMw)$$Mc z<|(^s`W0@y!NM~ZS!dIJZZF)&1*YlsDrzH%Aj bjNffFJOfb59MfJe5J4 zy75kASbO$5XOEM!Ev-UQ?K1r-*Wh2SYGi2*Eao5n?WF!w1m~38BfjR#iXK4>(j9*o zf^t`SZe~GI7C-R;y?LbA21^0I8dzzo13El5? uCJ(`O@s7`^tR@DcK_XG76YS7iykSYk9hd=e@-6*!aldG5616!98nnV4SC zO6eqh* %{*H5{Nd*y!jByc|h14aq#QZ{O@4i z0~tn-8IDih-#R4Hg4v5wvo3X1GR;BH%DNy6N^J~pxb{kz1O1$h>3(VLT#RdLM3#3Y ziY7^;6IP*BT9xa6efpf(j6^x<|1DX^%JJ{2n=0sIjoi8R-uB+-h*M9a9qf$~wm4_P zr nq7P@hd}xZC25)L;vMgR?~k`%Bub45FR0=iMccgSzRu_XKNG~7zfAyR7Q}!`j@T| zQx9ir3}HMKPvL^Q1_`>3s!rsyXs`gm_PBU}crZK>b=A~0dazt%Rxzr?g6>=hx_;zc zg27OLb#9IgoV;D>8FFRWj|-eUtT6ard-mIFfFo6F`OT_(qDm#}onx>g#7(EfCv0SK zp#KR1MsWbGWE}v+gdfvD{KX1}SPpuu9haSd+p38;6(DO&`i<7qSV=#38) ip3{O3=hd^0S*9ZF=_qUk7FLCCp`9NNQB1dN{DtwZP<)>?SW9hpW z<|3iNU9mH^ygK|QMBTXj<#M!wAI+rQSu4n(Q@*lh>Q4Fys`=EdLaIy8B8MW;%Y|vh zWEo W& zKQVk)q4%FXL*}-*VU;e9YPtF7aJ_1#xHlE$kbE})?;EQv_$xMT(?*5^7^lTbNR6-t zl9>Y6Ud@k(Z33&25+Wx^fvL&tXl4tB{F2bgun^w>T?bejnI&vH67U^ =<2;hgESf)Oc&(X`rjVguyAKT<{i zpsZ4y=hc(FrD`y$OWFJ|y;a7eC7lv!#%^+xd_(c;uf2O4)Ch7@Vy;|Vv!#&2q+5KT z%FaOC=*lHX9io*@H@ZqUHF(LbU2cwVrj(m-mhmt{riVw5?B|JIu-e8Xb*;v=@Lp$& zRl-L)OHtg_my_2Ne*Z19A89kO_yBD?Evx~v$X>Bd+ 0kMju7VaMMpk5Y#vw3YI ziUJa|NhXR zfb2?neXxSH;Tq%w|134ljAX-j0;QWIZa90{Zsjn;?KUjh+3czCRV)uaH1hOQ4yI&* zCbOS2(e1@h#mBSDppv7Yh(mylH} ysjF9&5FF?Kp z8+cCEskqC>R7vjOSMao=-`^%26Df0>j!uulfb}QlSCPMd&34s>yZnx4P_pHeDAhhg zWRFteBjwuC?(9X3KQwGZaexH`Ryn)bn&;)$%+hqWV(qk-ApUEDGL1ZyN${{_+7Rf0 zF=_|5>ht0Y4IuU5JIDZK{8u*9mw770*`6!}@5L66qsz?Nb4G)?x~VR7mWO1BW=2Zp z_$K2|_94v<-mo4+QpHc~u>1m%k!#$e3FDkSLl{&sYO7D1vJ_TH*|Hhk)O(FX0& WMV9Gs7%EvMI**k7Wy-ngt?l(2I6 ;UH-;D?qJEXUG(HQx|J&34LX=}L*&3T{*_RQ!?|Fk z1%Px})*lj_5VHU1hp}}!tKjDq=tPFtvvE%&Dv{3xU5K{QD)FG*vF{=lZmNP*jd`wl z=5QW9C68KSkHOnn4)lUY?EO?gp_)*pLP-M2zCKlm4D7+c*vdXqqQbi(^A@D3(s8*P z9pIu$jw8EDDI%W(%Vzx)Hy==uyByLR5gn1B6I|Wu7;GI6)m*gy>p7XvymiNJeQac? zPyc|nmiP&s)hBg8Eo036fRKDY%?C)MP5n6iej@Wl3lqWuX$dca_di+?)VWB-OcUJX zCdcHtlM6bE{am`+NDTZX8cud<=*yDQ9|=ppHwO7_=wCr7i~IAA-Q;WApd9hBF@z`{ z6*7HBDfKxYQb@OF$xbzjJ$9Veld*6T<| E9x$^NmAV~S`C5NPAdjhn#7y2J38FhMj?^Yri$-}gn5;KuSeln^R(ihjRI zn|(wab=}a5GQX1x^FZU$J}VFozHa#2fGiE!TDiXAANUQ}a?#7k!u?inQ1HD1Q3N?l zF=ZjQhxJN*pudNXRQElt)(RW$ydt>>r8$apU(7^Mt9JkLPoKN|fbOK=lE&F#20}7& zo`NV5wO_oKk2cJrgB)Q+iM-PnMOj>%%o;QNl#9zOgqABjHgg{U$7k*4JJntjVI(aM zKXfZ&0QB0g(A+K1i83!Df8=FX`%Vte8p LdspR6`Zd?@lX?MLf<^hHl%u^D zQI!5H0834Ru(Z}t8UG8NFXiF3VBS4}^~v15n)!H}EP$LS5`rtkf9qq-dSLUuE}y$5 zkDt; ?hstZ_k4Zb2|=lT<I6qh z=Z%d}r( nYM6cN!7SyS+p6AM(`qwm4$tS0*deTpzV6OkiXbnlcas2-Y}c4^u(qHN0?!yR zGGSt}%rRo}dm{FxX!Yj31FmouQ7fo$o2}O@BU5&2h>sYC7FLhZ=P8UN+U9l7&_$~y z+{8`obZWhqi6DOjhpz^B&@XetNF74WrL@a2xY`;@A;Rso&%GpaiNrU~4MJ?=FNR|! zgH6iM?Cfp)R)K76vC8Fr6Omt4ko$w>zn3KT7N6m&`H3f-oDg6_I|zeW2GW;ljZ@y@ zPL3uQ`=uGNee0kHPj@z1KW--~lM_3;r?b{RqNTSZCLtI?VV|t&vd||#Cs9&gzeGo+ zR_o6+u!1ey&0Ct|`;AYd6O<$qHd`sT>Re{iZ^>{m^wn18zU1<%+?BB)j*~X6z?i?B zl6|Neqe$VitBx3G?1PI19ud%{mA@S0O}-TNiEBW$D*=ME)o^K1O!w9V6;C^0@-+kF zH<`DD$3(EqIz#z3B54YX5>J@z^#B~2vHV=eO?lChRn}^DXWV5FIviu^QPNlic=QQ- zI>VKEsN((d$}wLTJdojz2?ut+w6(7zg{?7)9G`?dZolsHx~TnwV|LHq+HWyfsUjJS zp5}d!9B5G|wiQrc@m>R)83IW7?^uAQ!ds(oXNyx=?$co9XnsD6>e9;tYp2IQ*mq8L zQK~b3RZn>@3#;Oa7Ar~kCPVor{fRr6$gve18loD{-Kj?=?rYG@Qd3Mj-=IW4y39ZR zP=$TS>G(UKt*t6Qc!DgyI(A2>*8ie $HQgcY zPfmQcc<3t*?N6}_rVab41zM$c|76Qk5jXyvL;W$&zOld+N>!2t?i&HG)f&g)gIyii z&%S*@dC5;qP0(NY9GTB=Jh#iOJ(oJhVm~w{&;Wz!#eI!+y_cZHBbdx cfUr~J$0ZU?TWE+sQgd#2U$10N+Vu_J+_S(@$5Y!unUtTL( zUJG-}?1LRaPX|gr*J76@+dzm5$|&qx_j$g3hbYiHuGDBn(I^_uY_}vsQ+j5il?A*t z2|{V2tTIaYPm{&!PUI1P BFC+%(qYrVJcvfv)37KDcD> z3>L>rh|aR3;xycM0>49?!8d2xmPFat!Gu@%iZsBH) Y$oia*4ny*DJlZLj+MkbUM k#AVzcHpF<=xXo6a< zEyB4qtNS;XRcvP{bIivF)*||I-fIO$Vq{Zlh7-(@R`L&|jrdM;bAyaW$LgqfQ!Rv} z=Ju8 6H |x4PiOY<#0|5{&!z2I)R2x>CNHiZ z<%Pm(Pg4ub?|A_weIs8(HDujcL^fr0Rw76B7unu)Y`!;orqtDN@EhnJ)$G=T#tXf6 zV840+9Tn_t&z!k>N=2 fEzfMW$l7SE9nU- hM1IK3e22Kib&qqvhsJu4z WoO; zZZ~^nr&)VG(s7(#qbQ4>%-XJ i2NI)4m|=DdTgHdw%{J zVVZRe#W@Yj*GTObd28gm**~wI6JpSQ&UA0FWer@nxCS-mmHvBfVq#v@x3%SegWG~g z){#{YY4RvAn=NOEr7U>XK 8Y&` zrYB5uHbuPePa;;O58dJ9v-jWb$OQ~|Ys@t*H*C!@GOnKxr`4o=lwZ0M09VrQvv{RE zsWjpnEX&Sxs&D8)rD;gs<(&ch`~ao5r#$;bi$-vV0QF&goS!wL3+hlvr6ch1+BqS@ z)M90SMXxq*9&%6kqzmyt=VUMU&D0h!BCycY-ZzpV4BHyq2(Ho5*>U{w%Puu4DlCrd z#HEZ`UG$uy98mEeFyx9E0!nnQhz7Rwwi5^gaQxrItfwevMqin_^4C*A9Gzo7$=5IK z4xb6+ouHh<-qltFRD3e0Eb0<2sEaH|2Aq@wr|w!n%Yz `#7 zSnb< zF71aUH&+f_FY?QsuY&GaSzi)wC{Cf$ez0oGRA+D8-Ceo}S-l-e-%Okmre~ zT|*>8PxcDhl^((771K(mU;LXgEOhOuAEF`cLtcsC$W(Yri>~`brmzA}eqQcx5wqzg zE0yyj){K@n!$6d7!w}KKg#9x4M`oWkcSU3mebrBwmAT%-jd80dA1PrpcJoGU>J<&9 zx))@Rlx8Z8F?-3-c}sNk-XAM;(s;5hwF3hR`5NFVrxMMjINGxY(%^c3%=uh3g@S!E zNYCB|j`3&2`8aGjHPQO*V5$JWbfl3extLXb e9DX-$_Uwkt|3a2ma&!ay< z9q6a$lRDz(Ds@;xg)t|OoN7xQ^$cU(dYCL__b>oQ1*{biwtV#)jM`^qGtySVgn%h2 zw8h_k#P}tgf4B7!ky7s~oF0QX6)OkqjamGH(QiyYH(C_#08JeABV{RnIBmR>@9xk9 zv$G@SBU(|3gM_n+a(h6LItE!-75qS2P1{!1T0q6#LaYD%ML}w;bln+ EJpAWFqWZQM9p -}%H1fop3vL0=zKg?*P^86X0CIsJP- zm|u~e-)AC^T21pvdM(!P= e!-*JG{ zaRC}yljjt~UR$&0Ad 4vbPdrv1FnIp;FZW-JLy;R}v+INKYSWq>gw z8Wo*id-^&G&%b$SkU|&kQ^;uVQHYTNLs81B2l1tV!Gs6U!(ahg7E7uiQ^ZS0KC&Q$ zMM>+gf>ZCB!~H_5R@XZP5(?(^5vHQt$|h>xW)XkR<(!#+b6RT1a>I$}<4*+|L5Nv3 z5&)r6VxE&C3tEJ7*akygvFUN|(i7$E8r!eVvJM5+D#(*F^8&>`a2<7ciE`Awez9AF ziE~Ne`vql7GSvB`W3~Hl2}hL) *Zey0wGpzPZJ7RmlP_cE#tTTc^ M+vjNs}3E<|&Qj<7pOG`WSoy40=}P zH-0aD4U`<1GOu+B@8U{)1vi!A`6-lTGxPplosvmy4cxlbcC+BGcic_6M5CasJt%)F zHki3V4U@$WcPwW8Hz;>fL&0T C;UQ7U2RGtlll2x;@W; zoI3J6eX+E|w8NAc{43DO19n%~+i5xNYkK5uER2)s8;qY}Q(%n%ZF>Zu7Vg^HU}4Di z@iy}YJ}IzhU~cRUSI#{<+aHa0-lB?k?Bg&%ych#d5=CZ?@^qZGfT}PWa{Gxfn&b{4 zbQjC+%T
bV-zEDUOwC%kCV>lZa|wt7r~&He^8zN8W?d4{jQLe2N`p$pu0zx1y~k zn|#o@GGgppnUmUi=g_s$dtfG&rq?Li*CI}WCW1+b5{w}QUvef69 ^Qy92NBK`-m)QTQbWeTx|5Yph?2N zUbmM{@G=g%;WDRgb_3LRm>>zpZ&Zq#06x;! iVIIi>QF6 znWB5n`)CF?BwOAebIlzscDV=it_1*8@tnTDEC;UGCan_~X6OAO=P81igl}Zx8|{{) z(C?o9=CW8re%>vj<_y<2{Vw{AsM4(fDlrcXtBsj;NGEQL^2IG!iD_ZZz+Wsw$dV z)cP%$h78AMGK}s%8nUGeQ1VwVK R?I@Q&X;#h{O38I@!B30JrhTw*1=3pe z+)SAYy@?nkI`p<2X)8E!{o%(1{di{lBIY>>nGppJm?!dTCYH7t6}mR 0 zAO|MdPp2)&5I*y91R1=(Tue9Qjboinx;_5xW?HO`zneK#kW~R>cM_nnmhz2b`M$ zRH~h4WmXto0i(&&|CA3i L3jH=15?##Nfvy$Ia5c|w+(0Y?P-PXNu6E@^q&=*o|JmA zUX?gN5`2SOEh;r!UG(ZZ3G)5W6^cEf%c}ZT5cjYpP$?PJIm5<=C0^*uFimBaTMvy@ ztJ?J@VOXRAmwGH!P|_xeTl{;xiQl{G;{~azuTC|d;CaxGJe8M%ag4{*i|10}9M`bc zYAFcQdVcvsGes;`wQGferV+CWdxXBb21(4s{(217W-uc>aeQVGQ9ba_Y zkV7E9x rpsGCzDHM#d6-)eKLBWHpb0bS`gBVm6XD^ZHVqgx#8lynLuq;&u?IY zci{wloJMbOGk@|@BtR!a*E>qq_W@8*$U)ls<_!hj8$(Li^7qV~Pp|1wYFxd>ZH@gu zA)G ~~ zm~u47*BweB@2Lo1rbrEHrG&=!liubQzphESe6IY7u(Laqe(14uP4Q-JbQfTcg?nJ3 zfEv-uL_H~G=@ky4hYS+^5WhN#Iyhl6_MVn&O);wyiqN%*zr=&~`-VE35|CLXcsp>v zDivS*gVv90t*Q#f61Za)yW@2QWyrYab8hAbvzz(f-wXip=!C2c!D6pnYXsD$N|Pn2 zh}m?m?{`5NESsrd{K~A~w5el;vi3||iv4XfKK8zqAkYV52(i}OO#Cu}=~s*6`0a?C zk!oWx3ZZv9=l;_sr;?EKUzqrmojQ=$^%f4-h@Z<*JPRn$`O02h#<;}{S(dFH8u}vH zY9*zFp+*Klr%sd9x^K<=P)^zxVFd2@OS2a ^Fa3rHfJ2+!3*@esA8aKWVZ&vK}87G;K*F zN<@-cO5tai#dN1C09h?&ghPYA$#)bwOrOaH=~?~Hqh>y>rA~@&K_2tdgglYI#F;gM zBt_H3$ 3Z5tQ zSY}%6u;NP8btW^Nxu5jA*%MvzC}9#7VdL-y2h=YY1*g%c`8lkS4>hc=9JS{o&Bk}; zS7t2Z80?wy_p+s!oNuMWq&u#hE(vsC{fU=N9Op F%&{ZR zW4sG^o13OR$A;L3*4rM3i*eMpDXi;Wr2(*x!8P3o{Y=xJWhnPhQE`bM&d}~@YV~xA zis++Ve}{g^BuKp=Imo4QzGDX5c~-R~zURh3&o06U#B3TQ=+Ox^YCOa^MjKuM)QM6U z@j;XtgKX=OQnkiSL|{UCDc}2agYHL#Z?Ob4bP{?t`%M=lGi0s~t!Fh$N}~}u(F^F% zzNdq^SFF>}{`s(%`TQ``(3dx uCQ3O^_GoMuoq{jN31lXc_=$t0#iC9$;f=RH zco8blheXX$v68T#%YS}(%7h_F5pTY{j6VaNYTmMy2&VxAk^adSmpj=4eXnKe4Ib(6#$97{L zhkV|B2Gwr@z|?w82m@{7v~L #fsEjF2^Q7*u@5d^?+5XbW?V#%FEcl8gJOazuP9Xy=0Qb zGVH76TZs6jNK{RnKL)f s@I(fAr~V*S_p(j0KiLUE62> zZ+E^LZoZy +_F?l9Lhv-Sx1A)tc7w?SCTPc}rxpf=d5oEF r&W6TKyP|*d!JLY^tPxvhd3Q4XjP7i(zpE=w1EHxPl%V?JP>u48dLf+apbbEp zn@F*vne%;&sdoevHuU=nBUL{~kYOxeC30#{|9#MiMPW_O(=4L9qjpO$?k2>k%&E@) z(ft7(-)$(QseZ*R<>{PHb^Y!gTs3S@0zDA=H6;Jc?86_5+wE<|OwsU8oNny-r3qK) z>ZxNBqLRP$JgOPI%=Sn^H_AV``@~aVx_00Kl@ZmoF%|wSl-#eAO3~+7l@@)?I)F&! zNMyV2X?Ze~GxzrzR`g)^V>j8V8HUF7kOSHFE+1sG)qLyg#y%>kV+G8WQnyl2e=vI! zQx2cwz7(&>`cu1H1HvxxeYK+RTkq% z0oPBo8YFpotd$L-QTXFuF9;p~wbKV#YtJjrrAEKfLTYDMi_mPbjCa5gRDS1&1g|#p zbvzh^+bsqd$cd7iq{4kz1_(=56+pUA#O#h_N9v;;E_yqXfn)V+y#K502(X@6m@z~* zxAAWp%)(W@F{e~)ScVaC<0+!FgcNgrfbZ%~T==5B5v~|!Wh`jrkJC3QtRF78 efc;S(1})~s-Z ?Sx!)^_24DUkWbm#NyqYk- mVn`Ztsv8e<^@S?4;L~O|4Rg^ il!<>%rW}g$1Pdxt!sO-#W^O|==W2bf8`TYF5!aK43y7DGL +pF$#lCy45+0|ZoWk>0 z*TC&fOYuIK+S{TJM2q9~Wj5HIC^bK8;lI|-WqbwEe(Z8^PfhyQpJC$Y9R^DfV$vYA z(4(+)bL~N^yeCAz@|s50$ckU@-otg{hm#X%k~IvfBH8dA$oeqZ`p=#KX%;gMrx+dt z^Fd ?M&=we_JHW2<7`u%ozkd zU~nQxkFX>=I9v@tF3CxEJ@NaEGfIlVcaO?lV^v~%?MvwEpvZf3XxX6t5?x^EwJB{O zqdHwG5~Z=*=4!;KE*`g^3(2YQJnv@)>PBe_tZsPmCR!HuWLCDGxa-$=PxEJJ=~jA2 zo-9luzq`agInc5aPIPWlE_#q&r#-e<|K-wd3p^4M4zVTBhbll#NjaeA4QAx>jnHTs zrWz9qPU&f*-Zh=#Wa-oT^@xJ96W0~GDx@>lxrG|cy V#V|8Deo=dOReuCT{NF_UF2F;Ic*EaleaK786i=j z%SM7jMl!D5(Qv749?wI}d`b5ceG^Ab;h`!=g0M^KLnusf*l%WmH9q4lkRuV6pi0z_ zboXC>sMK#AHEsCfvB`g3L`lq2<$ ljgW zlxRqs^n*Q5F@<@5KANNi9xd*NR&Fl#)7S>g(PDWxr88!T-bSuM );50lE^wsiSAHWloHI!DZr$_v-vX#fYiG@+GI2$ #GnJcxx*OzW?+ZpT|DT1=)z~DV%1Chqk_iMaZ8xl$Chd9=_@$M4x?jp= zSwnM7N|N9e gFC`tzoTCGXh-BpyCV=(MOHqqjE1KAnRDH3EAYE!pFzYMTXa5h;cbgqxQd zV6Gyc%M?~?&_;1jQenBJx$Q}uY~#xb(um%@eZDlYP~m2i2!XoWY^ApGaQbp(sWHRe zWj=qv`;=ePLio~!FZAV@ _Qx$01Z~N_(c7uwGSqea&2xK77WfcQ z{!$Y7H*dHj%d;%RK7DoKG^ncTv7sK3Ad=? zMpb0x!^n!Ajp00o7r>7U47?%fH|qsBo*LzECAdh8Q`^ma&jGx)y}f+607yb{;y<0V zF?)iCCrnk@5r$<;!@WDI2+oA2yYjF*i1wUeT2)r*x69bO@|m|6v+#O&_w ?|yXn{41ii);QcM19 z^bep|uwV775j8&2GU=mK-va*=nXx{2FPV5L5i1%Lj;p$1iarX$gM~lXC6XE};5z0h zcA4$Jbu7Q9OuNfq=${Rp>(alCNumcxXEJK6ET_iHA5CMcD-0AZalpxAE~=KeqGscT zN=>lB8Ds9xifH~jnzpITw@+yLiGG++``oH{q;3;ZRgRo4d*+#DIY F8|ti(CWhj(S<*B=hL!@MROz;O_* *1o;ZTeyynJ(h$bMUQ=|KnTD4f{4< ~oc N4Bix;r5X#ba!W?`~7p=BYdNt{ay$zzy z*kQo3eI!72ZR}?=_r*%;En3**yIxf#BsTmZ`$QrnA*uL?(Hqn7F2ml8V*P|$EWDo6 zCqjt?s!qV38CY?m=XntA&>UYLrhnPH^03<0+m`G?gUL6BsdBMdu*@#9+Hg79)nGnX zzsKOikKFXn%=$Z(#}3A+A6HNONmWcg*P4oMK3D5DBZFwQo44|ti#Ou-%7@M;{|lI~ zBkUD7mXt2~*ZHZ1^KL|-X7l!(IW4dg2co+q(4U`=p1Rw+JYJ)XRS-U4BK2$5&JAaK z8!lu*G>iMlb!7u9H>FQlz(snGEKu@1W@yyAL3 KcjuS%0X4S|Gc9F*V2aSWdh@3&w;R(Y*EKKI|x19tY#0Nh#; zqjs~9eWz%yV3C7{{GJ}WNd*0Y&r>e$1{q>7CO11z2NtT?*DqVtIc7(76Y4JFcv~8| z%Ex)-s($rOpWUOit$i_p!ejT^6)$MbYJ{kwZ9*rM%9!v!Js#!eFOPc{9C$wB zQZB1hFf|x#;#Nq{u#2v?*yplCX&SY}KHo9wlNWWKT2Pr5jp(uUN?#1c%=O#W7*U3B z@zeAblpWYK6@ApvLRmI_f?Qaa;W4U{#b*!9VdpjW$9ak0l7tphEg!VgB`}tj@vHDo z3eJpF$CMgK9rBn}9?A7X?<;*v=AT6TPw$RfbL2u0-uoMpbT|Y{Wq3l)vyYa{S)|bU zzA1P$Cm!LXWbfa~I`;N3$h9Toh3~c=11h!Mt xb626HNuVFo_ZQusy6U>1u_7_AJ~iPiz=;x&aARcSxS?(+n1E#403$Bu-a zesAK?t%?(~mLvAgEussZLRHqxr`Bo0SI%`0NXansgnDjsd?AIcNs~D;zhli4SCZA_ z=>J54^~c~ZN^}r+%eIO#U-%aVSB;#Lv6dUasJg;ao(Dp~J0X*pP{mCWB~z#<;Bl z0&K%BzQsn$@yPGbI@57K L_} 7xpe7i!oTjVaS0L^(^*YzU2X}~JhcxV@z8l}+)gaD@-BD1~;XD>? zDC%}?yV?!CLHpg|(%hggr=QKBF*DPSZmZt?6SqOB9X2wm6w1?BehRdz*#wF6Q0dN( z+9WT&Iv<9$JHYU$@|h(GT@q@O &2r*OzXV)~A*`XwJ~qI|+1AQDFT^1EN>dH7m>djg)| bZm+9E-Bg$3nK&AUQ~JlT!9l(Q)&1gm z5x~jUT6O^lJLXjh6-&<*AEu~G122ifWkaT9+WO$X*G;G)7mo^+Zav%a) @y7BHsFEb64#7!4|eIig;I%7Y
Nz6g%~s zcd%BOPh)Ajt1e&EzaTO}gZMD%u~cc1qE%->s66o$U>2KGoF~SeF8_;JJ6M^<;ib&M zC98kW2aGXcJVPM=W@1=JD1|pe?vu=C??%U#NE_3pjV;>c1ODZF1J7s{+E?OIbBhk! zuiRt}B=A=@YEuEeuTnx2V7?^B>4^O=QterB>55iC{x5&(pa6U{G|lZK3_|HHRN`B+ zf#;K1sxX>%8mj6D*y&o^y+^s43Hye`Q@brGnZdVun*q^4Y67b|O)S83AiPX*=k|&K z>3&b?(nmq_TpfF3Lc--nI^MfJM}xy;4Rka4hCFZ08pQc?*gkvmjhmCuIG?_BLd;FI z5{wOV&Bns7GmHeTvw0lL$1kmx` y#x-+^ta2L*4$^9YG6)05|@3| ze3Ehd@Joq>Xqrc+Z%q?_;2lk}0`mEtrF|^C@VRp(jAT;>p5n-3EXM7IUSTo;uTK&g z^cE>;{flY=I;=OS7smP%+w7q<1>R%vgLiX$bV;>QSxveokCB8YaW#=GG5KrykVaQL znWFi>v}@3OoYtv#Hca;J((!yq>sR41JP?tCCe=ZLIj%2_7|(K(khqHfnO0MDmeJgU zQ{;m)sdFmS_gZC~Sn5Ny_eIK_u@k~ZO9;_8HtJkkd!+WxnSa469pyel@Cr_ivtQ+X zZauI{(7A+kl5F(fh*--tHfo?Os NLR6V=m m3$(Hw3U|`red*1dYuXJ4>7rJn)fMw|&?ig#I8P`vK+6fy#i7HH#i(X-h z-24qQ=bFCoO(i?y82g}O(j&Qm(mV~QQz$1^wjfi->Kvlx!adH*CMVaye`Iw@FSZEK z{Dp=Nu@^c1rW4IxHu*#4sK`Z62c?tQNNkSN;ZF^~n4L58)2f}(_^ d9XO5KsEwRX*QpUXh66n68JlUCli^>jC@qufC~Ty7?>pWu zPZxPUk3k-sYlqCVilGRP$;<0jnE67QWMaHxD-ibfBFu>L^(7mrqbZpZe-bqXn4@-z zEpwu3v&G7@`x*>VC^%f*TQSLnbMMfH-Q*ZAL 9xV~=}sRv?vEg5NUUGS=&)GY)Dcd>g`TTj_rVdm^! zKGoW;PPf$0KUlZyr(uYgtEMfD*qYK!?^ *wyV47=({ke-*+udtMU)j!F155ZeNCL^u$9=d!_ePUYk28 zUL%?5vH*riEFb@Tb_(vilCL>;bTj`!{Oy|V8`W LFvVHFq{;V!Z)Kax*vlo53$IwIFrEo2@!@qMH$xUWJ@{7^E7c7^4 z9i)a?4DK}6ntnCB0?^t0yl3?i?N9T>_#p5uKJ4hlm@Y8Q0WFxg$&$adHs|qlg?)}r zJ%3bJjcjU_U@Y$a-%4ZhyR1(N9dFC^{uE+!7i7tWaN_bXG2=_Dp9dG0XKv#RE#7tS zVK%G&Bu%B}a<%1PD#zy`1?T&*Do(qI58#!-Z-El$HN@Yp&-Afz$FaLm^uG|*w0@M5 z|BmP)*fUJx*Ey_ijRn2njbd{l0$iqRu^Me6M Z2nQrg(-90POh)nhpKfVdGtUYo5+^7HHiJ1L-%%;{@XYObw-~ANiXe#1DUTG zRW((^=9H(M5p=y%;c`^ Rc0>^+!V`(CPgsJSKb*b!m5;Ls(=me3X(=RR{M_l+eBUyO3{h+dj6vB4ZT$Jqt z%=y0{kTfeJ`_M3*fwrzGI?34>^y;zNi%s`y7ODtBE&61+X$1;AVvqQf@eppM1eC~x z)A8Y?pCcH!q_glnhRKQ~U)|C_YXoI13h-z@b0f4KXqH_1%IJ~+^eOr@yzHzM{V&`# zRy*pVzQ3 HAD+JAmd}RiAt80}uJ_-_wmeP) zL|6H~8L;c{robGUkPlWbIf8~d{jwFPu|CY^BnPLUKz{(Q|G&U3zL!10|J*wKUWcpz zUO)k$08juZ02BZU00n>oKmnitPyi?Z6aWhRZx^7t?DPMhUO?Qp6>-`B_G*Fk0R?~p zKmnitPyi?Z6aWeU1%LuT0iXa-04PvV2LbWx|1|^teAPzh0X={MKmnitPyi?Z6aWeU z1%LuT0iXa-04M+y_+Ky3kB1BSKN|zydf5X0*H;TH5GVi?015yFfC4}Ppa4(+C;$`y z3IGLw0zd(1U|;}n5&0zd(v08juZ02BZU00n>oKmnitP~iWR05CA%|I}~;ivY`}r#0tJ8qKmnitPyi?Z6aWeU1%LuT0ieMDc>(B7S2Smlc~ Ec%u1d%#l7-$zg~ z5r;Ps{Ua>Kk=1t(pN=;*W{z74?a&6^@OkmQTI!!uk(`p~@4cOgsxoZlS1)f&=Y2SR zjq~1>*9zZk{%(G!^H _Na3ka*hcRvy695#RUHJ;a^-< C*?M6O?shM%sJ|;q733CFC^Ps cQ%E56gxHZ8)3zYJO{@ z`eWlzIFMg6sM)ohGfEiau^TNpUIOsU4DqjOOu@?>lKkrul`EDnW#7i oNx$u3skEQF ztoqQLok>5E45;kNc@)U}^dT>@#U`Td2|>ISd8z7%ILJzo+07ndRf{dxhQD+v^~~;| zH#dXgI6Sg6{l8l-^{QJaO1k#q2#rD`4WRMwMW;Tzu{814Kg<(ziQ=eI^L1{mnk=n7 z9#_IIT}m-;_2jv{ZhmnW0{(z#p(37~PGym=(^w>h^M{%PtQgWgs-`Xrtoz|;uHh$3 zbs9op;-&V5 RF8fyC2#3r;4}$X6@#9CbiR!)N7St_$A&ze>#id1;%~ z aie^WJfc|JCN z)2Hla*oBb}Um&f`?@nMTF{9B2VWaIdkO*CLZ>$y7AfMCl2%d_+Xe2z?^v!P5Ng@ Kv`q%8x?n_DRqXmz|g;VMQcxUUJ # z#~)mkTW%k=vP1IlOt*-#M28OWJ#ga?%p{Ag8ZhIu@!=vlTP90(Ycb)iSOps+ll&rD zcbo%RiIyP`YNV{1;QSh$5Z~TkG?2#4`7U5SW@xG7uZT?0M2Aa`m?du5joLy0Z04ay z`V%?$D?Rh2;8~$l1Aui6WlW1){Vjbw)C&x!H>*PMg4f$BkJ?FLt6+oREpjVHk(pEO zxlE-4qDQQkDV-*!q2WSk52F<3G0 KNdJ^T}y}(_mg0i`N}VDD@Gfzhy1Rx?TF7ZuTH$IPF&`d)$qb)?YnP!N5e8U z@qc`FX`@D*xEwd+uY>d2#lo}qkg**A#E4ge WWx`F1742 zjg&(p4G_mL8TZlg_pl3)OAqTtf15%Z74G>lC%?L^`*IIO$n2)v@tT}sZ!B =>kMI(^v!4Oloqy= ztDWp%hJo|-#X*Nq8+<5ru Ur3H*Ex*bJnwBV~p-Yvs+&9yvg$tj`%r0*g%cfa>?C>dnJ_30{8r`Ydtq&ih zKh+F8-uY7aQVxgpFVfw( w@j6IC5<+`4lALo^O)PL7H_XxI7x*1 z-rAd-{Jaj??|-JN@N9@UmCXf!h)!uj7i$xFK}%fGGgy81)BTi`DR*A5<@b;0pO vcvuCrTqy)CW#+ zki))K|3SdWtr~APyel <0H}50$KlX$1SC@pw~?Se=1&T7){pk(GXuv4 z?)V*)rRW1{g^Hj4ge&M?t2J|au^*U%8?dFSOgCh@$3_4ZMQ#T6I~krW4i^G#O?nj+ zE|0<6-G1V{f{KyrMrZY*db;1hnR} zDR;2;;u1;?Ryx%E%{TeGJEAXmqVI1uX=&QgX6xK{hLicA%y8ySLNQ2q!{kP5=mdJ$ zaFtzP{*UL*k9FY-TM*?9%UWK;N*XX#!m+Qg@;PT`_JJpXqx))xWH@~E;37UFfsBzH z&NQ gOl$EjGI?yu)!LCdELVhktzm%0#`8fB3AE(K>k qqbykhH8%V zh@B7->e{O1BE$5b>_q8C1rB1C2N0S;q~QQc__D7nC M2O1ejA51fSovc*t;C?PoB{cvM+&@tDMCvw0 $X`A^%uZ0Pj*ZG_>(*;4oP$Lf@!$46?-q~5GMno{Bj7HI}a1wC!A^E8|1t2Y)S z;ydP1UEqoD7MFcrW7N`;Opf>E(Fb?_A_*&cVx{*tPF0FZwbhIN$~}OLNe02f2OG5# z#V#p;_lv^Yaj(c%civ9y)C&YMek)nE Uh>9|ADCIXmzVh)V;3oDRPWaBH)^j^Jlu{ts1-i0LwTL&6 D_#oMbJw8mn@oQpYY4p%OC8P`eb ziLb>tn%9_sIg*6yDr^{!rS!!(+|ty@i6!nO961_p2M6yax-- |0<@P4Y_CZ!0QVi8}SGuLQawCVhswaJRcY1lcR1% zAjV+X<549@tHQTAeAMBO?F#DPM8^Z3GdYZE(>~r>1VHVfSZGGV@SIw0u=B5lV`?17 zBU8#VR=1RBiPYQ?Zq;EW@Ql0+u6QC>QdoyjdGL0pCh>7jUbUTl9|F{126(htJJ7w_ z;t6jEUVZ~nJNDmwTzW|`xq8FO>(Bmj1^@!&qJvv*l_KwA_tgW6PMIs>a5i;X8QMkg zF;5!_@{d#IDoNfm{!a6^9}nZJ@${F2BDxuGtU^h^PHh2=vt-PDAX8gvSAn_IyOzVM zIWk)wPjPzY7qPB*jB6R )0Hh<{Hi+v(d(zY zGrZ*eKvNFje(YE_L90oH6v}xNEZ3b~9&pz{!;tRus|8}WNq5x$yzG@9U}*w=T~Vj5 zf2U2wb0FE|3hb_hPzST85R!O<88ps3)_H0zd|51Dr~KtvJzSpu)TbBgv7<3pibJaj zUu)>=Au7XrQ^pOo>&@%CQORU0LC@fg K>HB0BU z;v1is`B!#llO3VZDPY^u#!v!xhO;<+XY6{~wDxF@qE+lJQG)xWYJFeXm-!9e!L|^e zkDpW-(DtjYu1P=m^Ta@$A)$MXPxy?5-O^WmSFc4jas+c9YJURK3qL**hFB4&M8!_i z#IF%-fBFLH5M{`s9qQ6E#m%|w2NO-QBCeyAhwU#X-?aC4DcevV7sC{au5BQKoC@1* zlefyQJ_N4e2#hA6ukuQx1e+=(ygq1gL3M#p;uc1jh)P;nJ?kFqK09gh%w@EbF1nNP zU5oIHU+}iwS7!tnrWCT5omxIQy{|sLvl28AHF{mq`cG0NF%0#&3rMKrs!u ;#x%T^mnuNuPKjSEzoHLdO^8IB5h!}%VFyowg-yZG^CSisM&?Ib^J zmyf6E&XAXtbeElHPsQu$=cBdRl;ypPFDKwaVYgf-bf_F?JyUn@nZa6Ju?zL}djR*= z>NVc#;mB-y36GF*@Q;PBX~R`jzf0Wh=WP;_E>#>W`zC12o0&+ W!XlNfl61YWB z zy49z1Co@0rNnLjDT&FHA=(zToUA|iAX2j5aLjYdfNR;vc)tl79kA;y}uQ@0G1Wvg+ ztz{fULN2I^nl%2nnUGX0^yS`WF(suZJR~K^srZ&tlD~lLvP1cW%2T=}Wwsi9Uu)A0 z02hsceVs-k!(*ega7MzTE^;~y629JH62zm>cExq!_@`C{Nhed*k@{3rQOezfGTtXi z(fFjwFE&(_>khk7%iA%ro1 HEAWN3D7nCZRT^L&+P!qWx4mg kLJ_#A&> zjFNQoYSGPmsi#l?jSIe|j2Rir2u+ov bsWm476^Z`+TnEt~4Z{fr9Jt z>7cWdeSm1J;Y1+(_^xkuUFFi=9^7(nch9QT2-}hM zgqvt_fO8Ei0BfApDreL!AJALhrJomL%kvk}!K(CWY}sMK0VBzTIpJIdMd#=Qc2P}` z(WXDT$%}=hwfxTNwY`}BN^SX|(p`iS4}^RCy=h!bmeCjw9na4(Ci;UdSQgdXd5sX& zWDzscTP;Y=HKIK!rW@dIfR|b;(b2{WKYH}tsAE2XXqPBkF!IT&i-;=2HT@hs3UDQ~ z=ZN?WFQEXJ) zlLn(QV+f~P<~x=s1)=x*mo;e`Mp@}Z fkUl2LKJ?yT}ru3%{ zNsZ7lBwi#!z^+(lBS2~*5C8Ss`UhQWPBt`%9?>tXbyY1ECimv({Q2JgEWY+v`PsD? zTa3jjnVX9@iF9mDxf272TWW+4RJyVuyHqtbZhGeDyF$Vq2JN)Ill77|%|CGM{3FVV z%GxnVDg|mOA!&hY!_nP2&)ZZ;hDA6rE2R2$&!sE}O#_g4`Zne8y1*fhgsB+ss1uC7 zBy~Of$)b*W5<&eT!!zGq;R#RD(ZD>PsES^NrM+=?7Jj!AYqDQEOFT;p<^dnANPLyc zgIO*7uJ?XvCH=XnVC@^8GO9Uu|5+Cl1Y*PQd4%Qf!uq{t8de`C+>$s{0`N#%?` )d?%J>F%(yrt(V#R!!w-t zv7Hj{6lu#RSOM&Ik{mFIW%J<3bs|TgH%e8mC^()Z;!TUd|JjLouFKkgC<)5EKs-#E z52ZiNe`>wqBHB6uBzzop!6vrlv_1 zp%?>D4^LK*RT=ZlQ+c(s;RO4x55DKLf^TG*g#>=WFEPsmT<4b8Mji8H=2y)K)pstg zytrEbcm9|xB3{){;xV(%aQL~Gio{TA3~&{f#|^eR6nh!8Y2|; `pbVb )}0k?>8bovf{I<3$)@gDJV?YLoUPyF9_F?Rt4ct^0B3X2p-|a!{e8*ItI->kEAE zvL5|;iOSYf;$By{%b0$9et`Un{>G~}=zVpdhIe}BGPMRlx^G=JRjhyBsdyhu7}DNp zgl6J2y@qF oZ_%+ojc8~F2#{ 3Kk?Io z7IZdXqdTUUyv2@W`1nm_>2moGM?7L|ULJ>?POY|5a>}BlSfhmAs_D%Wi}8t&bTRj> zXUYS2<98xnwvNlHg4kt4yJ=&CTGa5Yg&(NW!C|O!p2ybQy|l!P3zGSY%Q>=oAEpO= zNH4OlEj^Uk53Hc)>bMg&Rj&}Sk-y7fPdx(sUs4wFx4bN1tux;g6 #q;#!2Z5{)wMCR_8hd3;Eqe$+-QHsuW`2*$Q=cGxW!RbZx8>0 zs3IR_wY$sppXKJTqg|-v#j)awy6P9G&7kx*NQ;etk0}ei$8BHy^zCu4!!TyU0VRj! zm(*#a;ua1r$9pJ&Cf1e1p0CfddIAvwAvXId&45&Blf>gc!ze!8H!|`?)bHon{Xhu$ zuJD?%XMy>|^sU`FrywV_EDXV(QC|K~jH9*Pd?IkxwKW1Kj{Mw3;aCnc@iArEf*8xl z9GAeMy9$$D$hq%5ScV~SPqIt(=X*a0oY)1Kh{`%Z1ORwKMn~VDy6`*(rRIy3LnM&Y zR#%B;))6vSyZYO%Jg!MXpo+XEzZcla@bhWt?4z7{dCv0`c@=Y 18C=h;6?ce!YOH~4-2=<0OH<$Zhf0 foU@$vYK$<=iY_%FAwoe_KbjZ$~1K zX(#0FSV&KKQgV-};9EYj(#pC54EZ8%NT_;o+5P7{%(Zs(Xz5o;XsvMIkkvy* %&1fl=+kT{3E%R%ceD$=^ zW+ OJSbE92KE*eH*quae< zjZJ*>p#AtfVTNmL{!Ktnq?|9jQ$B35{%KAk`zDXqX#4_eirw6MvqBVem1b+QX%RW= z*AaMpurZ2ztweSnyK#tJ6I&7J!rbs&FNhb-A5%BJYc+dS3_-gN1~Y&B*yF`1GUfUv z<$8O$XRbI5%(RSU@2Q|AfTiDaYI$P~Z~gj8E@tCMunC>6HWY{=yDK#kFg{sMQ5)(S zxWm}uLYEo8M|uM>3H%)SmJ;R&4bd7?JK^%)>YHL>5p#U5Mx-H$Lj?-}X=EHY9{UV2 zrwRDYMO+-)fY%+N9tB149EobLn2LE5gfx_hWw$6j(O@4b?@u5Mht$~A;_&oSLWZqJ zK+PT#mF9@P0C9g4gSK2v6reB(hopl-Nzb{R{w{(Kb41z2_d~W^_D>m#Pw yAS4{PBs3 Qw!p$iE^blN`~DVOwW^3CYB+C>&Jn6Xh>+5N-|MrTZ4V8;<=!4*bBjgQTl zDZ$Rffci0f#kCOhyX_(?LdEq*-p*Kiyir%XwfgjT4SG+4@ar|b5BtjL0Yc-Y&s!wv z(maneZ=yCU1fOUK2=C%U0+2yjbF#XH+O6ic=R4$=GI#S9^DkKeNn79X0+M>OYM!t{ zrNW>x{zm&GM+HB84*yEh89kgcM~k#EvN~=u6)UgQPi8TXEPVsUOdT+6>xbmxEFUWn z>k55pw&IjriI9BBKz&V!sHQ}R;(Xo djax7HtF3-!bt?nbWkPj|^`qd#qZg&(%UPSr8zHJcesM0UlXr Bd{ zvMvE2hNA!S8rdVy*0A*#GE6xcRS~rIa^}dLi#jr2x^bkYby$@d<~BT2qSuiqq~uJG z=d|@mv}wNuo^IgP`lw^I{x>pVR&?1a^mq#nDZJKx)Z+J9RTRL=DLcx(cBQ{*Bk++Y zA=5yb+TL0{k&_U4wM*i+=;F_t?NPWqo)SIyjPjfvv<*!CxZSQHY?0-O%RD;>JbS4+ zw;YVngm~)jx~+T?SRas*Xw;l&eP<=d>I&b{od!b1O)@PImY7S!{k-Dl;jB5E0tuPQ zKv(@ARJyb3^Naaizd*7ERG~2YS9v}!ll9KE?Y^-duc$s1S^PQFm+{4k{Yk%!@s@MK zOG6q*_)IV@du62sWnkQ9hZam2?z~9xS!U2FNiO=BU~*h|z|fm}4_qUL!Y?8_N1E3r za-XoLv@+>oy^P7gnoHT5kfO;mJ 6~Ytuz~?4 zT2*#>(`Yy E!VH%{`$nZ0#h_Z*ymc3zR*xGzsGfc zSdnN)HyK!83cC&RSN0vsO5ul;TRx)2W4**Lshd4mYoS6B>_SajHsflK^I2SkAB~Bv zkvCrARZ$s+s*BO#c5C)!)N*(W@J6eD)__9)v7t0_a)aA?U?>krADd?5#kWx#e$AFm zLx`G+LrveD47hDCx1(nOnxA9$Z0tgS%w@dXN?Tju>x@J`$LAwgJe?~MTC@Yhh@hl= zD4=R|VrwTJ2G&;c5_;PZoc_vLoUR(?KUZ?cA@bEmtKZVx%}OEC*4|>+Xme1$#u$Ln z7jv$FQ>yH^=xvv+dgz;kmg>-@)UoMe_v4PAIR~>>_f$d%Ly5!9Y-XOe-^|EZEe=w= zwbRazn=dn5@2k9-O(tg;NguY;12-MyLOJ*S{A*;DY4};wqF&TPWpnNGD28@ %*9sD?aEmco3-k$ zx?wdvldQ+!QzP;YPev}3vQ@9&AUV`P%!`N3?lAxX0%@}oVm@%AGw4f#Hupf$xqlV3 zP%*DbwM~(%d)$qGzn3w+n??jEtu5w?u;#*j*OFlYcri(()?g?GV%LVdiNmwcwGP9| z0n7Kt`%#~B%BSleas!|)bq zkC$z5+kUZ {d~%& zA!gMC^rK0vZw2Y(uZ9#oS<-Gjcg1h(W1U2(t0rP|w65C{kdzoo49KInYnTy#GrU%h zj(fPb1U!G{e2`L1!-iJq Af4dcjbkU8hy z2Y!aD@rNd`H!&F^V~Lyyh5a