diff --git a/Src/DSInternals.Common.Test/DSInternals.Common.Test.csproj b/Src/DSInternals.Common.Test/DSInternals.Common.Test.csproj index ffc6f5f..1a252dd 100644 --- a/Src/DSInternals.Common.Test/DSInternals.Common.Test.csproj +++ b/Src/DSInternals.Common.Test/DSInternals.Common.Test.csproj @@ -47,6 +47,9 @@ ..\packages\MSTest.TestFramework.2.0.0\lib\net45\Microsoft.VisualStudio.TestPlatform.TestFramework.Extensions.dll True + + ..\packages\Newtonsoft.Json.12.0.3\lib\net45\Newtonsoft.Json.dll + ..\packages\PeterO.Numbers.1.5.1\lib\net40\Numbers.dll false @@ -73,6 +76,7 @@ + diff --git a/Src/DSInternals.Common.Test/SearchableDeviceKeyTester.cs b/Src/DSInternals.Common.Test/SearchableDeviceKeyTester.cs new file mode 100644 index 0000000..4ff9a05 --- /dev/null +++ b/Src/DSInternals.Common.Test/SearchableDeviceKeyTester.cs @@ -0,0 +1,96 @@ +using System; +using DSInternals.Common.Data; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace DSInternals.Common.Test +{ + /// + /// Contains tests for parsing the Azure AD SearchableDeviceKey attribute. + /// + [TestClass] + public class SearchableDeviceKeyTester + { + [TestMethod] + public void SearchableDeviceKey_Parse_FIDO() + { + string jsonData = @"{ + 'usage':'FIDO', + 'keyIdentifier':'GshyINLMaOOwqt1LNUi0gQ==', + 'keyMaterial':'eyJ2ZXJzaW9uIjoxLCJhdXRoRGF0YSI6Ik5XeWUxS0NUSWJscFh4NnZrWUlEOGJWZmFKMm1IN3lXR0V3VmZkcG9ESUhGQUFBQUdjdHBTQjZQOTBBNWsrd0tKeW1oVktnQUVCckljaURTekdqanNLcmRTelZJdElHbEFRSURKaUFCSVZnZ29uTkNlY2EwZE5LTzlaWXBGdjlvMCtlZ0VGQ1ZTeXJ0UmN1NndrMStoT2dpV0NDNWE3MFI0ZlNFZ0ZpNWxnQTRBVVBmN1Y0Q2p5VWcvb1VWTFEzem5kZnc1YUZyYUcxaFl5MXpaV055WlhUMSIsIng1YyI6WyJNSUlDdlRDQ0FhV2dBd0lCQWdJRUdLeEd3REFOQmdrcWhraUc5dzBCQVFzRkFEQXVNU3d3S2dZRFZRUURFeU5aZFdKcFkyOGdWVEpHSUZKdmIzUWdRMEVnVTJWeWFXRnNJRFExTnpJd01EWXpNVEFnRncweE5EQTRNREV3TURBd01EQmFHQTh5TURVd01Ea3dOREF3TURBd01Gb3diakVMTUFrR0ExVUVCaE1DVTBVeEVqQVFCZ05WQkFvTUNWbDFZbWxqYnlCQlFqRWlNQ0FHQTFVRUN3d1pRWFYwYUdWdWRHbGpZWFJ2Y2lCQmRIUmxjM1JoZEdsdmJqRW5NQ1VHQTFVRUF3d2VXWFZpYVdOdklGVXlSaUJGUlNCVFpYSnBZV3dnTkRFek9UUXpORGc0TUZrd0V3WUhLb1pJemowQ0FRWUlLb1pJemowREFRY0RRZ0FFZWVvN0xIeEpjQkJpSXd6U1ArdGc1U2t4Y2RTRDhRQytoWjFyRDRPWEF3RzFSczNVYnMvSzQrUHpENEhwN1dLOUpvMU1IcjAzczd5K2txakNydXRPT3FOc01Hb3dJZ1lKS3dZQkJBR0N4QW9DQkJVeExqTXVOaTR4TGpRdU1TNDBNVFE0TWk0eExqY3dFd1lMS3dZQkJBR0M1UndDQVFFRUJBTUNCU0F3SVFZTEt3WUJCQUdDNVJ3QkFRUUVFZ1FReTJsSUhvLzNRRG1UN0FvbkthRlVxREFNQmdOVkhSTUJBZjhFQWpBQU1BMEdDU3FHU0liM0RRRUJDd1VBQTRJQkFRQ1huUU9YMkdENEx1RmRNUng1YnJyN0l2cW40SVRadXJUR0c3dFg4K2Ewd1lwSU43aGNQRTdiNUlORDlOYWwyYkhPMm9yaC90U1JLU0Z6Qlk1ZTRjdmRhOXJBZFZmR29PalRhQ1c2Rlo1L3RhMk0ydmdFaG96NURvOGZpdW9Yd0JhMVhDcDYxSmZJbFB0eDExUFhtNXBJUzJ3M2JYSTdtWTB1SFVNR3Z4QXp0YTc0ektYTHNsYUxhU1FpYlNLaldLdDloK1NzWHk0SkdxY1ZlZk9sYVFsSmZYTDFUZ2E2d2NPMFFUdTZYcStVdzdaUE5QbnJwQnJMYXVLRGQyMDJSbE40U1A3b2hMM2Q5Ykc2VjVoVXovM091c05FQlpVbjVXM1ZtUGoxWm5GYXZrTUIzUmtSTU9hNThNWkFPUkpUNGltQVB6cnZKMHZ0djk0L3k3MUM2dFo1Il0sImRpc3BsYXlOYW1lIjoiWXViaUtleSA1In0=', + 'creationTime':'2019-12-12T09:42:21.2641041Z', + 'deviceId':'00000000-0000-0000-0000-000000000000', + 'customKeyInformation':'AQEAAAAAAAAAAAAAAAAA', + 'fidoAaGuid':'cb69481e-8ff7-4039-93ec-0a2729a154a8', + 'fidoAuthenticatorVersion':null, + 'fidoAttestationCertificates':['e7d092ba192fdbbb2f36552832d616126971a269'] + }"; + + // Parse the FIDO key and check all fields + var keyCredential = KeyCredential.ParseJson(jsonData); + Assert.AreEqual(KeyUsage.FIDO, keyCredential.Usage); + Assert.AreEqual("YubiKey 5", keyCredential.FidoKeyMaterial.DisplayName); + Assert.AreEqual("GshyINLMaOOwqt1LNUi0gQ==", keyCredential.Identifier); + Assert.AreEqual(2019, keyCredential.CreationTime.Year); + Assert.IsTrue(keyCredential.CustomKeyInfo.Flags.HasFlag(KeyFlags.Attestation)); + Assert.AreEqual("e7d092ba192fdbbb2f36552832d616126971a269", keyCredential.FidoKeyMaterial.AttestationCertificates[0].Thumbprint.ToLowerInvariant()); + Assert.AreEqual("cb69481e-8ff7-4039-93ec-0a2729a154a8", keyCredential.FidoKeyMaterial.AuthenticatorData.AttestedCredentialData.AaGuid.ToString()); + + // Serialize the object again and compare with the original + Assert.AreEqual(JToken.Parse(jsonData).ToString(Formatting.None), keyCredential.ToJson()); + } + + [TestMethod] + public void SearchableDeviceKey_Parse_NGC() + { + string jsonData = @"{ + 'usage':'NGC', + 'keyIdentifier':'6eHBLoX0uOrd/hOsFVyQK8Rk2iqLTubd5nV80SMh+z4=', + 'keyMaterial':'UlNBMQAIAAADAAAAAAEAAAAAAAAAAAAAAQAByI8SMTWloiGnJjhv1A3o/n7FquciD7pKgFPcwbuzJK3NhmyCXuKMqrxW2zod4l9juNj9EPV8Y/YaZYvkNX5fTmSvmHihrAcdT6Kugej9GObFW1LH10yQnNYGP9X+te//tsNe90a8ORaDsOEq3gwRo1xyq14MxozgWbyRYA+Y+yfjdS+4cyP/054/pflLDHjSGKyvAaka0nJ5FzaTQ/YSupqVWgRX97BGk8ClpDm/zc0kRS0w3e4uEFcyrlDb8tccZ6lxVl7nJiIUuMEq/kyLn2lL7ISf5U3ter2/MEnU7T4wxMbIG7gUfAr4wh4DT3dI4IhQuhYdHe4s6a9lr4gppQ==', + 'creationTime':'2015-11-17T08:17:13.7724773Z', + 'deviceId':'cbad3c94-b480-4fa6-9187-ff1ed42c4479', + 'customKeyInformation':'AQA=', + 'fidoAaGuid':null, + 'fidoAuthenticatorVersion':null, + 'fidoAttestationCertificates':[] + }"; + + // Parse the NGC key and check all fields + var parsedKey = KeyCredential.ParseJson(jsonData); + Assert.AreEqual(KeyUsage.NGC, parsedKey.Usage); + Assert.AreEqual("6eHBLoX0uOrd/hOsFVyQK8Rk2iqLTubd5nV80SMh+z4=", parsedKey.Identifier); + Assert.AreEqual(2015, parsedKey.CreationTime.Year); + Assert.AreEqual(KeyFlags.None, parsedKey.CustomKeyInfo.Flags); + Assert.AreEqual(1, parsedKey.CustomKeyInfo.Version); + Assert.AreEqual("cbad3c94-b480-4fa6-9187-ff1ed42c4479", parsedKey.DeviceId.Value.ToString().ToLowerInvariant()); + + // Serialize the object again and compare with the original + Assert.AreEqual(JToken.Parse(jsonData).ToString(Formatting.None), parsedKey.ToJson()); + + // Re-generate the identifier and check that it matches the value in AAD. + var generatedKey = new KeyCredential( + parsedKey.RawKeyMaterial, + Guid.Parse("cbad3c94-b480-4fa6-9187-ff1ed42c4479"), + "CN=Test", + DateTime.Parse("2015-11-17T08:17:13.7724773Z") + ); + Assert.AreEqual(parsedKey.Identifier, generatedKey.Identifier); + + // Serialize the generated object and compare with the original + Assert.AreEqual(JToken.Parse(jsonData).ToString(Formatting.None), generatedKey.ToJson()); + } + + [TestMethod] + public void SearchableDeviceKey_Parse_Null() + { + Assert.IsNull(KeyCredential.ParseJson(null)); + } + + [TestMethod] + public void SearchableDeviceKey_Parse_Empty() + { + Assert.IsNull(KeyCredential.ParseJson(String.Empty)); + } + } +} diff --git a/Src/DSInternals.Common.Test/packages.config b/Src/DSInternals.Common.Test/packages.config index be618e7..5a3eb9e 100644 --- a/Src/DSInternals.Common.Test/packages.config +++ b/Src/DSInternals.Common.Test/packages.config @@ -2,6 +2,7 @@ + diff --git a/Src/DSInternals.Common/DSInternals.Common.csproj b/Src/DSInternals.Common/DSInternals.Common.csproj index 344ab4e..d26e24d 100644 --- a/Src/DSInternals.Common/DSInternals.Common.csproj +++ b/Src/DSInternals.Common/DSInternals.Common.csproj @@ -46,6 +46,7 @@ + @@ -64,6 +65,7 @@ + @@ -167,6 +169,8 @@ Designer - + + + \ No newline at end of file diff --git a/Src/DSInternals.Common/Data/Hello/CustomKeyInformationConverter.cs b/Src/DSInternals.Common/Data/Hello/CustomKeyInformationConverter.cs new file mode 100644 index 0000000..6c4ded1 --- /dev/null +++ b/Src/DSInternals.Common/Data/Hello/CustomKeyInformationConverter.cs @@ -0,0 +1,49 @@ +using System; +using System.Globalization; +using Newtonsoft.Json; + +namespace DSInternals.Common.Data +{ + /// + /// Converts the CustomKeyInformation class to and from a base 64 string value. + /// + public class CustomKeyInformationConverter : JsonConverter + { + public override CustomKeyInformation ReadJson(JsonReader reader, Type objectType, CustomKeyInformation existingValue, bool hasExistingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.Null) + { + return null; + } + + if(reader.TokenType == JsonToken.String) + { + try + { + byte[] blob = Convert.FromBase64String((string)reader.Value); + return new CustomKeyInformation(blob); + } + catch(Exception e) + { + throw new JsonSerializationException("Cannot convert invalid value to CustomKeyInformation.", e); + } + } + else + { + throw new JsonSerializationException("Unexpected token parsing CustomKeyInformation."); + } + } + + public override void WriteJson(JsonWriter writer, CustomKeyInformation value, JsonSerializer serializer) + { + if(value != null) + { + writer.WriteValue(value.ToByteArray()); + } + else + { + writer.WriteNull(); + } + } + } +} diff --git a/Src/DSInternals.Common/Data/Hello/KeyCredential.cs b/Src/DSInternals.Common/Data/Hello/KeyCredential.cs index 596e9ce..aac2b6d 100644 --- a/Src/DSInternals.Common/Data/Hello/KeyCredential.cs +++ b/Src/DSInternals.Common/Data/Hello/KeyCredential.cs @@ -2,16 +2,23 @@ { using System; using System.IO; + using System.Linq; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using DSInternals.Common.Data.Fido; using Newtonsoft.Json; + using Newtonsoft.Json.Converters; /// - /// This class represents a single credential stored as a series of values, corresponding to the KEYCREDENTIALLINK_BLOB structure. + /// This class represents a single AD/AAD key credential. /// - /// This structure is stored as the binary portion of the msDS-KeyCredentialLink DN-Binary attribute. + /// + /// In Active Directory, this structure is stored as the binary portion of the msDS-KeyCredentialLink DN-Binary attribute + /// in the KEYCREDENTIALLINK_BLOB format. + /// The Azure Active Directory Graph API represents this structure in JSON format. + /// /// https://msdn.microsoft.com/en-us/library/mt220505.aspx + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] public class KeyCredential { /// @@ -36,6 +43,10 @@ /// /// A SHA256 hash of the Value field of the RawKeyMaterial entry. /// + /// + /// Version 1 keys had a guid in this field instead if a hash. + /// + [JsonProperty("keyIdentifier", Order = 2)] public string Identifier { get; @@ -51,6 +62,8 @@ } } + [JsonProperty("usage", Order = 1)] + [JsonConverter(typeof(StringEnumConverter))] public KeyUsage Usage { get; @@ -72,6 +85,7 @@ /// /// Key material of the credential. /// + [JsonProperty("keyMaterial", Order = 3)] public byte[] RawKeyMaterial { get; @@ -159,12 +173,15 @@ } } + [JsonProperty("customKeyInformation", Order = 6)] + [JsonConverter(typeof(CustomKeyInformationConverter))] public CustomKeyInformation CustomKeyInfo { get; private set; } + [JsonProperty("deviceId", Order = 5)] public Guid? DeviceId { get; @@ -174,6 +191,7 @@ /// /// The approximate time this key was created. /// + [JsonProperty("creationTime", Order = 4)] public DateTime CreationTime { get; @@ -198,6 +216,58 @@ private set; } + /// + /// Gets the FIDO AAGUID. For JSON deserialization only. + /// + [JsonProperty("fidoAaGuid", Order = 7)] + private Guid? FidoAaGuid + { + get + { + var fido = this.FidoKeyMaterial; + if (fido != null && fido.AuthenticatorData != null && fido.AuthenticatorData.AttestedCredentialData != null) + { + return fido.AuthenticatorData.AttestedCredentialData.AaGuid; + } + else + { + return null; + } + } + } + + /// + /// Gets the FIDO authenticator version. For JSON deserialization only. + /// + [JsonProperty("fidoAuthenticatorVersion", Order = 8)] + private string FidoAuthenticatorVersion + { + get + { + return null; + } + } + + /// + /// Gets a list of thumbprints of FIDO Attestation Certificates. For JSON deserialization only. + /// + [JsonProperty("fidoAttestationCertificates", Order = 9)] + private string[] FidoAttestationCertificates + { + get + { + var fido = this.FidoKeyMaterial; + if(fido != null && fido.AttestationCertificates != null) + { + return fido.AttestationCertificates.Cast().Select(cer => cer.Thumbprint.ToLowerInvariant()).ToArray(); + } + else + { + return new string[0]; + } + } + } + public KeyCredential(X509Certificate2 certificate, Guid? deviceId, string holderDN, DateTime? currentTime = null, bool isComputerKey = false) { Validator.AssertNotNull(certificate, nameof(certificate)); @@ -222,9 +292,7 @@ // Initialize the Key Credential based on requirements stated in MS-KPP Processing Details: this.Version = KeyCredentialVersion.Version2; this.Identifier = ComputeKeyIdentifier(publicKey, this.Version); - this.CreationTime = currentTime.HasValue ? currentTime.Value : DateTime.Now; - - + this.CreationTime = currentTime.HasValue ? currentTime.Value.ToUniversalTime() : DateTime.UtcNow; this.RawKeyMaterial = publicKey; this.Usage = KeyUsage.NGC; this.Source = KeySource.AD; @@ -324,6 +392,16 @@ } } + /// + /// This constructor is only used for JSON deserialization. + /// + [JsonConstructor] + private KeyCredential() + { + this.Source = KeySource.AzureAD; + this.Version = KeyCredentialVersion.Version2; + } + public override string ToString() { return String.Format( @@ -427,16 +505,34 @@ return new DNWithBinary(this.HolderDN, this.ToByteArray()).ToString(); } - public static KeyCredential Parse(string dnWithBinary) + public string ToJson() + { + return JsonConvert.SerializeObject(this); + } + + public static KeyCredential ParseDNBinary(string dnWithBinary) { Validator.AssertNotNullOrEmpty(dnWithBinary, nameof(dnWithBinary)); var parsed = DNWithBinary.Parse(dnWithBinary); return new KeyCredential(parsed.Binary, parsed.DistinguishedName); } + public static KeyCredential ParseJson(string jsonData) + { + if(String.IsNullOrEmpty(jsonData)) + { + return null; + } + else + { + return JsonConvert.DeserializeObject(jsonData); + } + } + private static DateTime ConvertFromBinaryTime(byte[] binaryTime, KeySource source, KeyCredentialVersion version) { long timeStamp = BitConverter.ToInt64(binaryTime, 0); + // AD and AAD use a different time encoding. switch (version) { diff --git a/Src/DSInternals.Common/Data/Hello/KeyCredentialEntryType.cs b/Src/DSInternals.Common/Data/Hello/KeyCredentialEntryType.cs index 5b73c52..95b6a18 100644 --- a/Src/DSInternals.Common/Data/Hello/KeyCredentialEntryType.cs +++ b/Src/DSInternals.Common/Data/Hello/KeyCredentialEntryType.cs @@ -38,7 +38,7 @@ DeviceId = 0x06, /// - /// Custom key identifier. + /// Custom key information. /// CustomKeyInformation = 0x07, @@ -52,4 +52,4 @@ /// KeyCreationTime = 0x09 } -} \ No newline at end of file +} diff --git a/Src/DSInternals.Common/Data/Hello/KeyMaterialFido.cs b/Src/DSInternals.Common/Data/Hello/KeyMaterialFido.cs index 83a81b2..8c6bd0d 100644 --- a/Src/DSInternals.Common/Data/Hello/KeyMaterialFido.cs +++ b/Src/DSInternals.Common/Data/Hello/KeyMaterialFido.cs @@ -4,6 +4,7 @@ using Newtonsoft.Json; namespace DSInternals.Common.Data { + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] public class KeyMaterialFido { /// diff --git a/Src/DSInternals.Common/Data/Hello/KeyUsage.cs b/Src/DSInternals.Common/Data/Hello/KeyUsage.cs index 06250d7..28b07dd 100644 --- a/Src/DSInternals.Common/Data/Hello/KeyUsage.cs +++ b/Src/DSInternals.Common/Data/Hello/KeyUsage.cs @@ -37,6 +37,11 @@ /// /// File Encryption Key (KEY_USAGE_FEK) /// - FEK = 0x08 + FEK = 0x08, + + /// + /// DPAPI Key + /// + DPAPI // TODO: The DPAPI enum needs to be mapped to a proper integer value. } } diff --git a/Src/DSInternals.PowerShell/Commands/Misc/GetADKeyCredential.cs b/Src/DSInternals.PowerShell/Commands/Misc/GetADKeyCredential.cs index d1df663..8dba933 100644 --- a/Src/DSInternals.PowerShell/Commands/Misc/GetADKeyCredential.cs +++ b/Src/DSInternals.PowerShell/Commands/Misc/GetADKeyCredential.cs @@ -125,7 +125,7 @@ case ParamSetFromDNBinary: foreach (string singleValue in this.DNWithBinaryData) { - keyCredential = KeyCredential.Parse(singleValue); + keyCredential = KeyCredential.ParseDNBinary(singleValue); this.WriteObject(keyCredential); } break;