diff --git a/dissect/database/ese/ntds/objects/__init__.py b/dissect/database/ese/ntds/objects/__init__.py index 566ae34..0acaf9c 100644 --- a/dissect/database/ese/ntds/objects/__init__.py +++ b/dissect/database/ese/ntds/objects/__init__.py @@ -68,6 +68,7 @@ from dissect.database.ese.ntds.objects.msds_resourcepropertylist import MSDSResourcePropertyList from dissect.database.ese.ntds.objects.msds_shadowprincipalcontainer import MSDSShadowPrincipalContainer from dissect.database.ese.ntds.objects.msds_valuetype import MSDSValueType +from dissect.database.ese.ntds.objects.msfve_recoveryinformation import MSFVERecoveryInformation from dissect.database.ese.ntds.objects.msimaging_psps import MSImagingPSPs from dissect.database.ese.ntds.objects.mskds_provserverconfiguration import MSKDSProvServerConfiguration from dissect.database.ese.ntds.objects.msmqenterprisesettings import MSMQEnterpriseSettings @@ -174,6 +175,7 @@ "MSDSResourcePropertyList", "MSDSShadowPrincipalContainer", "MSDSValueType", + "MSFVERecoveryInformation", "MSImagingPSPs", "MSKDSProvServerConfiguration", "MSMQEnterpriseSettings", diff --git a/dissect/database/ese/ntds/objects/computer.py b/dissect/database/ese/ntds/objects/computer.py index 28d500e..c267620 100644 --- a/dissect/database/ese/ntds/objects/computer.py +++ b/dissect/database/ese/ntds/objects/computer.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING +from dissect.database.ese.ntds.objects.msfve_recoveryinformation import MSFVERecoveryInformation from dissect.database.ese.ntds.objects.user import User if TYPE_CHECKING: @@ -22,6 +23,12 @@ class Computer(User): def __repr_body__(self) -> str: return f"name={self.name!r}" + def fve_recovery_information(self) -> Iterator[MSFVERecoveryInformation]: + """Return the BitLocker recovery information objects associated with this computer.""" + for child in self.children(): + if isinstance(child, MSFVERecoveryInformation): + yield child + def managed_by(self) -> Iterator[Object]: """Return the objects that manage this computer.""" self._assert_local() diff --git a/dissect/database/ese/ntds/objects/msfve_recoveryinformation.py b/dissect/database/ese/ntds/objects/msfve_recoveryinformation.py new file mode 100644 index 0000000..f53f8e0 --- /dev/null +++ b/dissect/database/ese/ntds/objects/msfve_recoveryinformation.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID + +from dissect.database.ese.ntds.objects.top import Top + +if TYPE_CHECKING: + from dissect.database.ese.ntds.objects import Computer + + +class MSFVERecoveryInformation(Top): + """Represents a msFVE-RecoveryInformation object in the Active Directory. + + References: + - https://learn.microsoft.com/en-us/windows/win32/adschema/c-msfve-recoveryinformation + """ + + __object_class__ = "msFVE-RecoveryInformation" + + @property + def volume_guid(self) -> UUID: + """Return the volume GUID associated with this recovery information.""" + return UUID(bytes_le=self.get("msFVE-VolumeGuid")) + + @property + def recovery_guid(self) -> UUID: + """Return the recovery GUID associated with this recovery information.""" + return UUID(bytes_le=self.get("msFVE-RecoveryGuid")) + + @property + def recovery_password(self) -> str | None: + """Return the recovery password associated with this recovery information.""" + return self.get("msFVE-RecoveryPassword") + + @property + def key_package(self) -> bytes | None: + """Return the key package associated with this recovery information, if any.""" + return self.get("msFVE-KeyPackage") + + def computer(self) -> Computer: + """Return the computer object associated with this recovery information.""" + if (parent := self.parent()) is None: + raise ValueError("msFVE-RecoveryInformation object has no parent computer") + return parent diff --git a/dissect/database/ese/ntds/objects/object.py b/dissect/database/ese/ntds/objects/object.py index 6803918..388a80e 100644 --- a/dissect/database/ese/ntds/objects/object.py +++ b/dissect/database/ese/ntds/objects/object.py @@ -57,6 +57,11 @@ def __repr_suffix__(self) -> str: def __getattr__(self, name: str) -> Any: return self.get(name) + def __eq__(self, other: object) -> bool: + if not isinstance(other, Object): + return NotImplemented + return self.record == other.record + @classmethod def from_record(cls, db: Database, record: Record) -> Object: """Create an Object instance from a database record. diff --git a/tests/_data/ese/ntds/fve/ntds.dit.gz b/tests/_data/ese/ntds/fve/ntds.dit.gz new file mode 100644 index 0000000..f3333bb --- /dev/null +++ b/tests/_data/ese/ntds/fve/ntds.dit.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:03c9dda585aee54c526917c96150d96879237f4934f23b26b7971ef79e0b2e30 +size 1927045 diff --git a/tests/ese/ntds/conftest.py b/tests/ese/ntds/conftest.py index 5b5f4df..1b3f051 100644 --- a/tests/ese/ntds/conftest.py +++ b/tests/ese/ntds/conftest.py @@ -34,6 +34,13 @@ def adam() -> Iterator[NTDS]: yield NTDS(fh) +@pytest.fixture(scope="module") +def fve() -> Iterator[NTDS]: + """NTDS file with BitLocker recovery information.""" + for fh in open_file_gz("_data/ese/ntds/fve/ntds.dit.gz"): + yield NTDS(fh) + + @pytest.fixture(scope="module") def large() -> Iterator[NTDS]: """Large NTDS file for performance testing. diff --git a/tests/ese/ntds/test_ntds.py b/tests/ese/ntds/test_ntds.py index 4dca2ce..da29394 100644 --- a/tests/ese/ntds/test_ntds.py +++ b/tests/ese/ntds/test_ntds.py @@ -320,3 +320,34 @@ def test_backup_keys(goad: NTDS) -> None: hashlib.sha256(keys[1].public_key).hexdigest() == "398fef9281677096b18785d0ad000251d41f76b82e28687718d6a9812ddaca8a" ) + + +def test_fve_recovery_information(fve: NTDS) -> None: + """Test retrieval of BitLocker recovery information.""" + computer = next(c for c in fve.computers() if c.name == "WIN11") + assert isinstance(computer, Computer) + + recovery_info = list(computer.fve_recovery_information()) + assert len(recovery_info) == 1 + assert recovery_info[0].volume_guid == UUID("616127c1-403a-4bdf-9213-42c285cf9ee7") + assert recovery_info[0].recovery_guid == UUID("1d3184e4-ff3f-44f8-9255-32d1728f6172") + assert recovery_info[0].recovery_password == "197307-494857-485111-648725-619432-662057-360844-079310" + assert recovery_info[0].key_package == bytes.fromhex( + "d40100000100000030000000d4010000c12761613a40df4b921342c285cf9ee7" + "0100000000000000a0bad003caa5dc015000030005000100a039ebd4c8a5dc01" + "090000003a621f05e8ca419a50af679b12eef8b0dba5cef0249ab9a509164767" + "2ff5d3ea058671c43643fbc6f503f5fb966f6e61acda9b5d44ad4730ca200fe9" + "3c01020008000100e484311d3ffff844925532d1728f61721040cdd6c8a5dc01" + "00000008ac0000000300010000100000b351f88395b15fc67a7bcbf9132ba131" + "4000120005000100a039ebd4c8a5dc0105000000373c16060ab99c0619679931" + "1b2f3cfccb4ca31f6ae9dbd41c5c67a605674db0b96512e4184c815bc4d38ad1" + "5000130005000100a039ebd4c8a5dc01060000002ee39c79df7a782cead1fb91" + "73b073b5d512e054c9700c69076cf7a374e066b13b56fec03e12fa623acaaab0" + "702141d694cb726163f5b4da002a5cc85000000005000100a039ebd4c8a5dc01" + "07000000df655b5d65e9a8d969c990083a846ca8c34708334d4710b5fb532887" + "a9689ff9e1191521f5abe368cf5476e82cd2ecad01ba90b6065cd7e87447a8ad" + "1c00000015000100f06475dec8a5dc01f06475dec8a5dc011000020018000f00" + "0f00010000400e04000000000020000000000000" + ) + + assert recovery_info[0].computer() == computer