diff --git a/Src/DSInternals.Common.Test/DSInternals.Common.Test.csproj b/Src/DSInternals.Common.Test/DSInternals.Common.Test.csproj index bfb7177..ad04055 100644 --- a/Src/DSInternals.Common.Test/DSInternals.Common.Test.csproj +++ b/Src/DSInternals.Common.Test/DSInternals.Common.Test.csproj @@ -26,6 +26,7 @@ DEBUG;TRACE prompt 4 + x64 true @@ -33,6 +34,7 @@ prompt 4 false + x64 @@ -76,6 +78,7 @@ + diff --git a/Src/DSInternals.Common.Test/KdsRootKeyTester.cs b/Src/DSInternals.Common.Test/KdsRootKeyTester.cs index 2105fe4..1994219 100644 --- a/Src/DSInternals.Common.Test/KdsRootKeyTester.cs +++ b/Src/DSInternals.Common.Test/KdsRootKeyTester.cs @@ -52,5 +52,21 @@ namespace DSInternals.Common.Test KdsRootKey.ParseSecretAgreementParameters(new byte[0] { }); throw new AssertInconclusiveException(); } + + [TestMethod] + public void ComputeL0Key_Vector1() + { + byte[] l0Key = KdsRootKey.ComputeL0Key( + Guid.Parse("7dc95c96-fa85-183a-dff5-f70696bf0b11"), + "814ad2f3928ff96d3650487967392feab3924f3d0dff8629d46a723640101cff8ca2cbd6aba40805cf03b380803b27837d80663eb4d18fd4cec414ebb2271fe2".HexToBinary(), + "SP800_108_CTR_HMAC", + "00000000010000000e000000000000005300480041003500310032000000".HexToBinary(), + 361 + ); + + Assert.AreEqual( + "76d7341bbf6f85f439a14d3f68c6de31a83d2c55b1371c9c122f5b6f0eccff282973da43349da2b21a0a89b050b49e9ace951323f27638ccbfce8b6a0ead782b", + l0Key.ToHex()); + } } } diff --git a/Src/DSInternals.Common.Test/ProtectionKeyIdentifierTester.cs b/Src/DSInternals.Common.Test/ProtectionKeyIdentifierTester.cs new file mode 100644 index 0000000..f5410df --- /dev/null +++ b/Src/DSInternals.Common.Test/ProtectionKeyIdentifierTester.cs @@ -0,0 +1,55 @@ +using DSInternals.Common.Data; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; + +namespace DSInternals.Common.Test +{ + [TestClass] + public class ProtectionKeyIdentifierTester + { + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public void ProtectionKeyIdentifier_Null() + { + var obj = new ProtectionKeyIdentifier(null); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentOutOfRangeException))] + public void ProtectionKeyIdentifier_Empty() + { + var obj = new ProtectionKeyIdentifier(new byte[0]); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentOutOfRangeException))] + public void ProtectionKeyIdentifier_Truncated1() + { + // Only contains version + byte[] blob = "01000000".HexToBinary(); + var parsed = new ProtectionKeyIdentifier(blob); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentOutOfRangeException))] + public void ProtectionKeyIdentifier_Truncated2() + { + // The last byte has been trimmed + byte[] blob = "010000004b44534b02000000690100001a00000018000000965cc97d85fa3a18dff5f70696bf0b1100000000180000001800000063006f006e0074006f0073006f002e0063006f006d00000063006f006e0074006f0073006f002e0063006f006d0000".HexToBinary(); + var parsed = new ProtectionKeyIdentifier(blob); + } + + [TestMethod] + public void ProtectionKeyIdentifier_Parse() + { + byte[] blob = "010000004b44534b02000000690100001a00000018000000965cc97d85fa3a18dff5f70696bf0b1100000000180000001800000063006f006e0074006f0073006f002e0063006f006d00000063006f006e0074006f0073006f002e0063006f006d000000".HexToBinary(); + var parsed = new ProtectionKeyIdentifier(blob); + Assert.AreEqual("contoso.com", parsed.DomainName); + Assert.AreEqual("contoso.com", parsed.ForestName); + Assert.AreEqual("7dc95c96-fa85-183a-dff5-f70696bf0b11", parsed.RootKeyId.ToString()); + Assert.AreEqual(361, parsed.L0KeyId); + Assert.AreEqual(26, parsed.L1KeyId); + Assert.AreEqual(24, parsed.L2KeyId); + } + } +} diff --git a/Src/DSInternals.Common/DSInternals.Common.csproj b/Src/DSInternals.Common/DSInternals.Common.csproj index 946f84a..3b4cd33 100644 --- a/Src/DSInternals.Common/DSInternals.Common.csproj +++ b/Src/DSInternals.Common/DSInternals.Common.csproj @@ -67,6 +67,7 @@ + @@ -89,6 +90,7 @@ + @@ -130,6 +132,7 @@ + @@ -145,6 +148,7 @@ + @@ -183,4 +187,4 @@ - + \ No newline at end of file diff --git a/Src/DSInternals.Common/Data/DPAPI/KdsRootKey.cs b/Src/DSInternals.Common/Data/DPAPI/KdsRootKey.cs index 04f6bbd..ebe09eb 100644 --- a/Src/DSInternals.Common/Data/DPAPI/KdsRootKey.cs +++ b/Src/DSInternals.Common/Data/DPAPI/KdsRootKey.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Text; +using DSInternals.Common.Interop; namespace DSInternals.Common.Data { @@ -10,11 +11,16 @@ namespace DSInternals.Common.Data /// public class KdsRootKey { + private const int L0KeyIteration = 1; + private const int L1KeyIteration = 32; + private const int L2KeyIteration = 32; + private int? version; private DateTime ?creationTime; private DateTime ?effectiveTime; private byte[] privateKey; private string kdfAlgorithmName; + private byte[] rawKdfParameters; private string secretAgreementAlgorithmName; private byte[] secretAgreementAlgorithmParam; private int? privateKeyLength; @@ -53,9 +59,7 @@ namespace DSInternals.Common.Data dsObject.ReadAttribute(CommonDirectoryAttributes.KdsKdfAlgorithm, out this.kdfAlgorithmName); // KDF algorithm parameters (only 1 in current implementation) - byte[] rawKdfParams; - dsObject.ReadAttribute(CommonDirectoryAttributes.KdsKdfParameters, out rawKdfParams); - this.KdfParameters = ParseKdfParameters(rawKdfParams); + dsObject.ReadAttribute(CommonDirectoryAttributes.KdsKdfParameters, out this.rawKdfParameters); // Secret agreement algorithm dsObject.ReadAttribute(CommonDirectoryAttributes.KdsSecretAgreementAlgorithm, out this.secretAgreementAlgorithmName); @@ -148,8 +152,10 @@ namespace DSInternals.Common.Data /// public Dictionary KdfParameters { - get; - private set; + get + { + return ParseKdfParameters(this.rawKdfParameters); + } } /// @@ -196,6 +202,53 @@ namespace DSInternals.Common.Data } } + public byte[] ComputeL0Key(int l0KeyId) + { + return ComputeL0Key( + this.KeyId, + this.KeyValue, + this.KdfAlgorithm, + this.rawKdfParameters, + l0KeyId + ); + } + + public static byte[] ComputeL0Key( + Guid kdsRootKeyId, + byte[] kdsRootKey, + string kdfAlgorithm, + byte[] kdfParameters, + int l0KeyId) + { + var result = NativeMethods.GenerateKDFContext( + kdsRootKeyId, + l0KeyId, + -1, + -1, + GroupKeyLevel.L0, + out byte[] kdfContext, + out int counterOffset + ); + + Validator.AssertSuccess(result); + + result = NativeMethods.GenerateDerivedKey( + kdfAlgorithm, + kdfParameters, + kdsRootKey, + kdfContext, + null, + null, + L0KeyIteration, + out byte[] l0Key, + out string invalidAtribute + ); + + Validator.AssertSuccess(result); + + return l0Key; + } + public static Dictionary ParseKdfParameters(byte[] blob) { if(blob == null || blob.Length == 0) @@ -259,4 +312,4 @@ namespace DSInternals.Common.Data } } } -} \ No newline at end of file +} diff --git a/Src/DSInternals.Common/Data/DPAPI/ProtectionKeyIdentifier.cs b/Src/DSInternals.Common/Data/DPAPI/ProtectionKeyIdentifier.cs new file mode 100644 index 0000000..b6dd858 --- /dev/null +++ b/Src/DSInternals.Common/Data/DPAPI/ProtectionKeyIdentifier.cs @@ -0,0 +1,121 @@ +namespace DSInternals.Common.Data +{ + using System; + using System.IO; + using System.Runtime.InteropServices; + using System.Text; + + /// + /// The Protection Key Identifier data structure is used to store metadata about keys used to cryptographically wrap DPAPI-NG encryption keys and to derive managed passwords. + /// + /// https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/98a575da-ca48-4afd-ba79-f77a8bed4e4e + public class ProtectionKeyIdentifier + { + private const string KdsKeyMagic = "KDSK"; + private const int ExpectedVersion = 1; + private const int StructureHeaderLength = 9 * sizeof(int) + 16; + + public int L0KeyId + { + get; + private set; + } + + public int L1KeyId + { + get; + private set; + } + + public int L2KeyId + { + get; + private set; + } + + public Guid RootKeyId + { + get; + private set; + } + + public string DomainName + { + get; + private set; + } + + public string ForestName + { + get; + private set; + } + + public ProtectionKeyIdentifier(byte[] blob) + { + Validator.AssertMinLength(blob, StructureHeaderLength, nameof(blob)); + + using (Stream stream = new MemoryStream(blob)) + { + using (BinaryReader reader = new BinaryReader(stream)) + { + // Version must be 0x00000001 + int version = reader.ReadInt32(); + Validator.AssertEquals(ExpectedVersion, version, nameof(version)); + + // Magic must be 0x4B53444B + byte[] binaryMagic = reader.ReadBytes(sizeof(int)); + string magic = ASCIIEncoding.ASCII.GetString(binaryMagic); + Validator.AssertEquals(KdsKeyMagic, magic, nameof(magic)); + + // Flags must be 0x00000000 + int flags = reader.ReadInt32(); + + // An L0 index + this.L0KeyId = reader.ReadInt32(); + + // An L1 index + this.L1KeyId = reader.ReadInt32(); + + // An L2 index + this.L2KeyId = reader.ReadInt32(); + + // A root key identifier + byte[] binaryRootKeyId = reader.ReadBytes(Marshal.SizeOf(typeof(Guid))); + this.RootKeyId = new Guid(binaryRootKeyId); + + // Variable data lengths + int additionalInfoLength = reader.ReadInt32(); + int domainNameLength = reader.ReadInt32(); + int forestNameLength = reader.ReadInt32(); + + // Validate variable data length + int expectedLength = StructureHeaderLength + additionalInfoLength + domainNameLength + forestNameLength; + Validator.AssertMinLength(blob, expectedLength, nameof(blob)); + + if (additionalInfoLength > 0) + { + // Additional info used in key derivation + byte[] additionalInfo = reader.ReadBytes(additionalInfoLength); + } + + if(domainNameLength > 0) + { + // DNS-style name of the Active Directory domain in which this identifier was created. + byte[] binaryDomainName = reader.ReadBytes(domainNameLength); + // Trim \0 + this.DomainName = Encoding.Unicode.GetString(binaryDomainName, 0, binaryDomainName.Length - sizeof(char)); + } + + if(forestNameLength > 0) + { + // DNS-style name of the Active Directory forest in which this identifier was created. + byte[] binaryForestName = reader.ReadBytes(forestNameLength); + // Trim \0 + this.ForestName = Encoding.Unicode.GetString(binaryForestName, 0, binaryForestName.Length - sizeof(char)); + } + } + } + } + } +} diff --git a/Src/DSInternals.Common/Data/Principals/GroupManagedServiceAccount.cs b/Src/DSInternals.Common/Data/Principals/GroupManagedServiceAccount.cs new file mode 100644 index 0000000..afdff05 --- /dev/null +++ b/Src/DSInternals.Common/Data/Principals/GroupManagedServiceAccount.cs @@ -0,0 +1,54 @@ +using DSInternals.Common.Cryptography; + +namespace DSInternals.Common.Data +{ + /// + /// Group Managed Service Account. + /// + /// https://learn.microsoft.com/en-us/windows/win32/adschema/c-msds-groupmanagedserviceaccount + public class GroupManagedServiceAccount : DSAccount + { + private const int DefaultPasswordValidityInterval = 30; + + public ProtectionKeyIdentifier ManagedPasswordId + { + get; + private set; + } + + public ProtectionKeyIdentifier ManagedPasswordPreviousId + { + get; + private set; + } + + public int ManagedPasswordInterval + { + get; + private set; + } + + public GroupManagedServiceAccount(DirectoryObject dsObject, string netBIOSDomainName, DirectorySecretDecryptor pek) : base(dsObject, netBIOSDomainName, pek) + { + // TODO: Check that this object is a gMSA + + // Read and parse msDS-ManagedPasswordId + dsObject.ReadAttribute(CommonDirectoryAttributes.ManagedPasswordId, out byte[] rawManagedPasswordId); + if(rawManagedPasswordId != null ) + { + this.ManagedPasswordId = new ProtectionKeyIdentifier(rawManagedPasswordId); + } + + // Read and parse msDS-ManagedPasswordPreviousId + dsObject.ReadAttribute(CommonDirectoryAttributes.ManagedPasswordPreviousId, out byte[] rawManagedPasswordPreviousId); + if(rawManagedPasswordPreviousId != null) + { + this.ManagedPasswordPreviousId = new ProtectionKeyIdentifier(rawManagedPasswordPreviousId); + } + + // Read msDS-ManagedPasswordInterval + dsObject.ReadAttribute(CommonDirectoryAttributes.ManagedPasswordInterval, out int? managedPasswordInterval); + this.ManagedPasswordInterval = managedPasswordInterval ?? DefaultPasswordValidityInterval; + } + } +} diff --git a/Src/DSInternals.Common/Data/Schema/CommonDirectoryAttributes.cs b/Src/DSInternals.Common/Data/Schema/CommonDirectoryAttributes.cs index b7fe201..e64e61e 100644 --- a/Src/DSInternals.Common/Data/Schema/CommonDirectoryAttributes.cs +++ b/Src/DSInternals.Common/Data/Schema/CommonDirectoryAttributes.cs @@ -63,6 +63,9 @@ public const int LMHashHistoryId = 589984; public const int LMHashId = 589879; public const string LockoutTime = "lockoutTime"; + public const string ManagedPasswordId = "msDS-ManagedPasswordId"; + public const string ManagedPasswordPreviousId = "msDS-ManagedPasswordPreviousId"; + public const string ManagedPasswordInterval = "msDS-ManagedPasswordInterval"; public const string MasterNamingContexts = "msDS-hasMasterNCs"; public const string Member = "member"; public const string Name = "name"; diff --git a/Src/DSInternals.Common/Interop/Enums/GroupKeyLevel.cs b/Src/DSInternals.Common/Interop/Enums/GroupKeyLevel.cs new file mode 100644 index 0000000..3872f3f --- /dev/null +++ b/Src/DSInternals.Common/Interop/Enums/GroupKeyLevel.cs @@ -0,0 +1,19 @@ +namespace DSInternals.Common.Interop +{ + /// https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-gkdi/4cac87a3-521e-4918-a272-240f8fabed39 + internal enum GroupKeyLevel : uint + { + /// + /// L0 key + /// + L0 = 0, + /// + /// L1 key + /// + L1 = 1, + /// + /// L2 key + /// + L2 = 2 + } +} diff --git a/Src/DSInternals.Common/Interop/NativeMethods.cs b/Src/DSInternals.Common/Interop/NativeMethods.cs index 1a98a07..36f9f71 100644 --- a/Src/DSInternals.Common/Interop/NativeMethods.cs +++ b/Src/DSInternals.Common/Interop/NativeMethods.cs @@ -15,12 +15,14 @@ namespace DSInternals.Common.Interop internal const int LMHashNumBytes = NTHashNumBits / 8; internal const int LMPasswordMaxChars = 14; internal const int NTPasswordMaxChars = 128; + internal const int KdsRootKeySize = 64; private const int MaxRegistryKeyClassSize = 256; private const string Advapi = "advapi32.dll"; private const string CryptDll = "cryptdll.Dll"; private const string Ntdll = "ntdll.dll"; private const string Mpr = "mpr.dll"; + private const string KdsCli = "KdsCli.dll"; private const string LMOwfInternalName = "SystemFunction006"; private const string NTOwfInternalName = "SystemFunction007"; private const string LMOwfEncryptInternalName = "SystemFunction024"; @@ -48,7 +50,7 @@ namespace DSInternals.Common.Interop /// https://github.com/wine-mirror/wine/blob/master/dlls/advapi32/crypt_md4.c [DllImport(Advapi, SetLastError = true, EntryPoint = NTOwfInternalName, CharSet = CharSet.Unicode)] private static extern NtStatus RtlCalculateNtOwfPassword([In] ref SecureUnicodeString password, [MarshalAs(UnmanagedType.LPArray, SizeConst = NTHashNumBytes), In, Out] byte[] hash); - + /// /// Takes the passed NtPassword and performs a one-way-function on it. /// Uses the RSA MD4 function @@ -161,7 +163,7 @@ namespace DSInternals.Common.Interop // Wrap to get rid of the unnecessary pointer to int return RtlEncryptLmOwfPwdWithIndex(lmOwfPassword, ref index, encryptedLmOwfPassword); } - + /// /// Faster arbitrary length data encryption function (using RC4) /// @@ -281,7 +283,7 @@ namespace DSInternals.Common.Interop { uint securityDescriptorSize; bool result = ConvertStringSecurityDescriptorToSecurityDescriptor(stringSecurityDescriptor, stringSDRevision, out securityDescriptor, out securityDescriptorSize); - if(result) + if (result) { return Win32ErrorCode.Success; } @@ -290,5 +292,130 @@ namespace DSInternals.Common.Interop return (Win32ErrorCode)Marshal.GetLastWin32Error(); } } + + /// Root key identifier of the requested key. It can be set to NULL. + /// L0 index of the requested group key. It MUST be a signed 32-bit integer greater than or equal to -1. + /// L1 index of the requested group key. It MUST be a signed 32-bit integer between -1 and 31 (inclusive). + /// L2 index of the requested group key. It MUST be a 32-bit integer between -1 and 31 (inclusive). + /// Group key level. + /// If the function succeeds, the return value is NO_ERROR. + internal static Win32ErrorCode GenerateKDFContext( + Guid rootKeyId, + int l0KeyId, + int l1KeyId, + int l2KeyId, + GroupKeyLevel level, + out byte[] context, + out int counterOffset) + { + var result = GenerateKDFContext( + rootKeyId, + l0KeyId, + l1KeyId, + l2KeyId, + level, + out SafeSidKeyProviderHandle contextHandle, + out int contextLength, + out counterOffset + ); + + try + { + context = contextHandle.ToArray(contextLength); + } + finally + { + contextHandle.Close(); + } + + return result; + } + + [DllImport(KdsCli, SetLastError = true)] + private static extern Win32ErrorCode GenerateKDFContext( + Guid rootKeyId, + int l0KeyId, + int l1KeyId, + int l2KeyId, + GroupKeyLevel level, + out SafeSidKeyProviderHandle context, + out int contextLength, + out int counterOffset); + + internal static Win32ErrorCode GenerateDerivedKey( + string kdfAlgorithmName, + byte[] kdfParameters, + byte[] secret, + byte[] context, + int? counterOffset, + byte[] label, + int iteration, + out byte[] derivedKey, + out string invalidAttribute) + { + + int kdfParametersLength = kdfParameters?.Length ?? 0; + int secretLength = secret?.Length ?? 0; + int contextLength = context?.Length ?? 0; + int labelLength = label?.Length ?? 0; + byte[] derivedKeyBuffer = new byte[KdsRootKeySize]; + StringBuilder invalidAttributeBuffer = new StringBuilder(byte.MaxValue); + + // Deal with the optional int parameter + int counterOffsetValue = counterOffset.GetValueOrDefault(); + var counterOffsetHandle = GCHandle.Alloc(counterOffsetValue); + + try + { + Win32ErrorCode result = GenerateDerivedKey( + kdfAlgorithmName, + kdfParameters, + kdfParametersLength, + secret, + secretLength, + context, + contextLength, + (counterOffset.HasValue ? (IntPtr) counterOffsetHandle : IntPtr.Zero), + label, + labelLength, + iteration, + derivedKeyBuffer, + KdsRootKeySize, + ref invalidAttributeBuffer + ); + + derivedKey = derivedKeyBuffer; + invalidAttribute = invalidAttributeBuffer.ToString(); + return result; + } + finally + { + counterOffsetHandle.Free(); + } + } + + [DllImport(KdsCli, CharSet = CharSet.Unicode, SetLastError = true)] + private static extern Win32ErrorCode GenerateDerivedKey( + string kdfAlgorithmName, + byte[] kdfParameters, + int kdfParametersLength, + byte[] secret, + int secretLength, + byte[] context, + int contextLength, + IntPtr counterOffset, + byte[] label, + int labelLength, + int iteration, + [MarshalAs(UnmanagedType.LPArray)] byte[] key, + int keyLength, + ref StringBuilder invalidAttribute); + + /// + /// Frees memory allocated for a credentials structure by the GenerateKDFContext and GenerateDerivedKey functions. + /// + /// Memory to be freed. + [DllImport(KdsCli)] + internal static extern void SIDKeyProvFree([In] IntPtr memory); } -} \ No newline at end of file +} diff --git a/Src/DSInternals.Common/Interop/SafeSidKeyProviderHandle.cs b/Src/DSInternals.Common/Interop/SafeSidKeyProviderHandle.cs new file mode 100644 index 0000000..8487888 --- /dev/null +++ b/Src/DSInternals.Common/Interop/SafeSidKeyProviderHandle.cs @@ -0,0 +1,45 @@ +using Microsoft.Win32.SafeHandles; +using System; +using System.Runtime.InteropServices; +using System.Security; + +namespace DSInternals.Common.Interop +{ + /// + /// Represents a wrapper class for SID Key Provider object handles. + /// + [SecurityCritical] + internal class SafeSidKeyProviderHandle : SafeHandleZeroOrMinusOneIsInvalid + { + private SafeSidKeyProviderHandle() : base(true) + { + } + + public SafeSidKeyProviderHandle(IntPtr preexistingHandle, bool ownsHandle) : base(ownsHandle) + { + this.SetHandle(preexistingHandle); + } + + public byte[] ToArray(int size) + { + if(this.IsInvalid) + { + return null; + } + + byte[] binaryData = new byte[size]; + Marshal.Copy(this.handle, binaryData, 0, size); + + return binaryData; + } + + [SecurityCritical] + protected override bool ReleaseHandle() + { + NativeMethods.SIDKeyProvFree(this.handle); + + // Presume that the memory release has been successful. + return true; + } + } +} diff --git a/Src/DSInternals.Common/Validator.cs b/Src/DSInternals.Common/Validator.cs index aec9f5f..ccb3640 100644 --- a/Src/DSInternals.Common/Validator.cs +++ b/Src/DSInternals.Common/Validator.cs @@ -81,6 +81,15 @@ namespace DSInternals.Common } } + public static void AssertEquals(int expectedValue, int actualValue, string paramName) + { + if (expectedValue != actualValue) + { + string message = String.Format(Resources.UnexpectedValueMessage, actualValue, expectedValue); + throw new ArgumentException(message, paramName); + } + } + public static void AssertEquals(char expectedValue, char actualValue, string paramName) { if (expectedValue.CompareTo(actualValue) != 0)