using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text.Json; using DSInternals.Common.Data.Fido; namespace DSInternals.Common.Data; /// /// This class represents a single AD/AAD key credential. /// /// /// In Active Directory, this structure is stored as the binary portion of the msDS-KeyCredentialLink DN-Binary attribute /// in the KEYCREDENTIALLINK_BLOB format. /// The decommissioned internal Azure Active Directory Graph API used to represent this structure in JSON format. /// /// https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/f3f01e95-6d0c-4fe6-8b43-d585167658fa public class KeyCredential { /// /// Minimum length of the structure. /// private const int MinLength = sizeof(uint); // Version /// /// V0 structure alignment in bytes. /// private const ushort PackSize = 4; /// /// Cached value of the parsed FIDO key material /// private KeyMaterialFido? _cachedFidoKeyMaterial; /// /// Cached hash of the entries. /// private readonly byte[]? _cachedHash; /// /// Defines the version of the structure. /// public KeyCredentialVersion Version { get; private set; } /// /// A SHA256 hash of the Value field of the RawKeyMaterial entry. /// /// /// Version 1 keys had a guid in this field instead if a hash. /// public string? Identifier { get; private set; } public bool IsWeak { get { var key = this.RSAPublicKey; return key.HasValue && key.Value.IsWeakKey(); } } public KeyUsage? Usage { get; private set; } public string? LegacyUsage { get; private set; } public KeySource Source { get; private set; } /// /// Key material of the credential. /// public byte[]? RawKeyMaterial { get; private set; } public KeyMaterialFido? FidoKeyMaterial { get { if (this.Usage == KeyUsage.FIDO && this._cachedFidoKeyMaterial == null && this.RawKeyMaterial != null) { // The raw value has not yet been parsed Utf8JsonReader reader = new(this.RawKeyMaterial); this._cachedFidoKeyMaterial = JsonSerializer.Deserialize(ref reader, KeyCredentialSerializationContext.Default.KeyMaterialFido); } // Returned the parsed object from cache or NULL if no FIDO key is present. return this._cachedFidoKeyMaterial; } } public ECParameters? ECPublicKey { get { var fidoKey = this.FidoKeyMaterial; if (fidoKey != null && fidoKey.AuthenticatorData.AttestedCredentialData.CredentialPublicKey.Type == COSE.KeyType.EC2) { var eccPublicKey = fidoKey.AuthenticatorData.AttestedCredentialData.CredentialPublicKey.PublicKey as ECDsa; return eccPublicKey.ExportParameters(includePrivateParameters: false); } else { return null; } } } public RSAParameters? RSAPublicKey { get { if (this.RawKeyMaterial == null) { return null; } // FIDO keys typically use ECC instead of RSA, but we try to extract the RSA key anyway: var fidoKey = this.FidoKeyMaterial; if (fidoKey != null && fidoKey.AuthenticatorData.AttestedCredentialData.CredentialPublicKey.Type == COSE.KeyType.RSA) { var rsaPublicKey = fidoKey.AuthenticatorData.AttestedCredentialData.CredentialPublicKey.PublicKey as RSA; return rsaPublicKey.ExportParameters(includePrivateParameters: false); } if (this.Usage == KeyUsage.NGC || this.Usage == KeyUsage.STK) { // The RSA public key can be stored in at least 3 different formats. if (this.RawKeyMaterial.IsBCryptRSAPublicKeyBlob()) { // This public key is in DER format. This is typically true for device/computer keys. return this.RawKeyMaterial.ImportRSAPublicKeyBCrypt(); } else if (this.RawKeyMaterial.IsTPM20PublicKeyBlob()) { // This public key is encoded as PCP_KEY_BLOB_WIN8. This is typically true for device keys protected by TPM. // The PCP_KEY_BLOB_WIN8 structure is not yet supported by DSInternals. return null; } else if (this.RawKeyMaterial.IsDERPublicKeyBlob()) { // This public key is encoded as BCRYPT_RSAKEY_BLOB. This is typically true for user keys. return this.RawKeyMaterial.ImportRSAPublicKeyDER(); } } // Other key usages probably do not contain any public keys. return null; } } public string? RSAModulus { get { var publicKey = this.RSAPublicKey; return publicKey.HasValue ? Convert.ToBase64String(publicKey.Value.Modulus) : null; } } public CustomKeyInformation? CustomKeyInfo { get; private set; } public Guid? DeviceId { get; private set; } /// /// The approximate time this key was created. /// public DateTime? CreationTime { get; private set; } /// /// The approximate time this key was last used. /// public DateTime? LastLogonTime { get; private set; } /// /// Distinguished name of the AD object (UPN in case of AAD objects) that holds this key credential. /// public string? Owner { get; // We need to update this property after JSON deserialization, so it is internal instead of private. internal set; } public KeyCredential(X509Certificate2 certificate, Guid? deviceId, string owner, DateTime? currentTime = null, bool isComputerKey = false) { ArgumentNullException.ThrowIfNull(certificate); // Computer NGC keys are DER-encoded, while user NGC keys are encoded as BCRYPT_RSAKEY_BLOB. byte[] publicKey = isComputerKey ? certificate.ExportRSAPublicKeyDER() : certificate.ExportRSAPublicKeyBCrypt(); this.Initialize(publicKey, deviceId, owner, currentTime, isComputerKey); } public KeyCredential(byte[] publicKey, Guid? deviceId, string owner, DateTime? currentTime = null, bool isComputerKey = false) { ArgumentNullException.ThrowIfNull(publicKey); this.Initialize(publicKey, deviceId, owner, currentTime, isComputerKey); } private void Initialize(byte[] publicKey, Guid? deviceId, string owner, DateTime? currentTime, bool isComputerKey) { // Process owner DN/UPN ArgumentException.ThrowIfNullOrEmpty(owner); this.Owner = owner; // 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.ToUniversalTime() : DateTime.UtcNow; this.RawKeyMaterial = publicKey; this.Usage = KeyUsage.NGC; this.Source = KeySource.AD; if (isComputerKey) { // Computer NGC keys have to meet some requirements to pass the validated write. // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/f70afbcc-780e-4d91-850c-cfadce5bb15c // The KeyApproximateLastLogonTimeStamp entry must not present. // Although the spec also says that the CustomKeyInformation entry must not present, // in practice it seems that Windows adds it with KeyFlags.MFANotUsed. this.CustomKeyInfo = new CustomKeyInformation(KeyFlags.MFANotUsed); if (deviceId.HasValue) { throw new ArgumentException("Device ID cannot be specified for computer NGC keys.", nameof(deviceId)); } } else { this.LastLogonTime = this.CreationTime; this.CustomKeyInfo = new CustomKeyInformation(KeyFlags.None); // Device ID is only used for user NGC keys, but not for computer NGC keys. this.DeviceId = deviceId; } } public KeyCredential(byte[] blob, string owner) { // Input validation ArgumentNullException.ThrowIfNull(blob); Validator.AssertMinLength(blob, MinLength, nameof(blob)); ArgumentException.ThrowIfNullOrEmpty(owner); // Init this.Owner = owner; // Parse binary input using (var stream = new MemoryStream(blob, false)) { using (var reader = new BinaryReader(stream)) { this.Version = (KeyCredentialVersion)reader.ReadUInt32(); // Read all entries corresponding to the KEYCREDENTIALLINK_ENTRY structure: do { // A 16-bit unsigned integer that specifies the length of the Value field. ushort length = reader.ReadUInt16(); // An 8-bit unsigned integer that specifies the type of data that is stored in the Value field. KeyCredentialEntryType entryType = (KeyCredentialEntryType)reader.ReadByte(); // A series of bytes whose size and meaning are defined by the Identifier field. byte[] value = reader.ReadBytes(length); if (this.Version == KeyCredentialVersion.Version0) { // Data used to be aligned to 4B in this legacy format. int paddingLength = (PackSize - length % PackSize) % PackSize; reader.ReadBytes(paddingLength); } // Now parse the value of the current entry based on its type: switch (entryType) { case KeyCredentialEntryType.KeyID: this.Identifier = ConvertFromBinaryIdentifier(value, this.Version); break; case KeyCredentialEntryType.KeyHash: // TODO: Validate the integrity of the data against the hash. Hacktools might produce malformed CNG keys. this._cachedHash = value; break; case KeyCredentialEntryType.KeyMaterial: this.RawKeyMaterial = value; break; case KeyCredentialEntryType.KeyUsage: if (length == sizeof(byte)) { // This is apparently a V2 structure this.Usage = (KeyUsage)value[0]; } else { // This is a legacy structure that contains a string-encoded key usage instead of enum. this.LegacyUsage = System.Text.Encoding.UTF8.GetString(value); } break; case KeyCredentialEntryType.KeySource: this.Source = (KeySource)value[0]; break; case KeyCredentialEntryType.DeviceId: this.DeviceId = new Guid(value); break; case KeyCredentialEntryType.CustomKeyInformation: this.CustomKeyInfo = new CustomKeyInformation(value); break; case KeyCredentialEntryType.KeyApproximateLastLogonTimeStamp: this.LastLogonTime = ConvertFromBinaryTime(value, this.Source, this.Version); break; case KeyCredentialEntryType.KeyCreationTime: this.CreationTime = ConvertFromBinaryTime(value, this.Source, this.Version); break; default: // Unknown entry type. We will just ignore it. break; } } while (reader.BaseStream.Position != reader.BaseStream.Length); } } } public override string ToString() { return $"Id: {this.Identifier}, Source: {this.Source}, Version: {this.Version}, Usage: {this.Usage}, CreationTime: {this.CreationTime}"; } public byte[] ToByteArray() { // Note that we do not support the legacy V1 format. // Serialize properties 3-9 first, as property 2 must contain their hash: byte[] binaryProperties; using (var propertyStream = new MemoryStream()) { using (var propertyWriter = new BinaryWriter(propertyStream)) { // Key Material propertyWriter.Write((ushort)this.RawKeyMaterial.Length); propertyWriter.Write((byte)KeyCredentialEntryType.KeyMaterial); propertyWriter.Write(this.RawKeyMaterial); // Key Usage propertyWriter.Write((ushort)sizeof(KeyUsage)); propertyWriter.Write((byte)KeyCredentialEntryType.KeyUsage); propertyWriter.Write((byte)this.Usage); // Key Source propertyWriter.Write((ushort)sizeof(KeySource)); propertyWriter.Write((byte)KeyCredentialEntryType.KeySource); propertyWriter.Write((byte)this.Source); // Device ID if (this.DeviceId.HasValue) { byte[] binaryGuid = this.DeviceId.Value.ToByteArray(); propertyWriter.Write((ushort)binaryGuid.Length); propertyWriter.Write((byte)KeyCredentialEntryType.DeviceId); propertyWriter.Write(binaryGuid); } // Custom Key Information if (this.CustomKeyInfo != null) { byte[] binaryKeyInfo = this.CustomKeyInfo.ToByteArray(); propertyWriter.Write((ushort)binaryKeyInfo.Length); propertyWriter.Write((byte)KeyCredentialEntryType.CustomKeyInformation); propertyWriter.Write(binaryKeyInfo); } // Last Logon Time if (this.LastLogonTime.HasValue) { byte[] binaryLastLogonTime = ConvertToBinaryTime(this.LastLogonTime.Value, this.Source, this.Version); propertyWriter.Write((ushort)binaryLastLogonTime.Length); propertyWriter.Write((byte)KeyCredentialEntryType.KeyApproximateLastLogonTimeStamp); propertyWriter.Write(binaryLastLogonTime); } // Creation Time if (this.CreationTime.HasValue) { byte[] binaryCreationTime = ConvertToBinaryTime(this.CreationTime.Value, this.Source, this.Version); propertyWriter.Write((ushort)binaryCreationTime.Length); propertyWriter.Write((byte)KeyCredentialEntryType.KeyCreationTime); propertyWriter.Write(binaryCreationTime); } } binaryProperties = propertyStream.ToArray(); } using (var blobStream = new MemoryStream()) { using (var blobWriter = new BinaryWriter(blobStream)) { // Version blobWriter.Write((uint)this.Version); // Key Identifier byte[] binaryKeyId = ConvertToBinaryIdentifier(this.Identifier, this.Version); blobWriter.Write((ushort)binaryKeyId.Length); blobWriter.Write((byte)KeyCredentialEntryType.KeyID); blobWriter.Write(binaryKeyId); // Key Hash // Use the cached value if present. Compute the hash otherwise. byte[] keyHash = this._cachedHash ?? ComputeHash(binaryProperties); blobWriter.Write((ushort)keyHash.Length); blobWriter.Write((byte)KeyCredentialEntryType.KeyHash); blobWriter.Write(keyHash); // Append the remaining entries blobWriter.Write(binaryProperties); } return blobStream.ToArray(); } } public string ToDNWithBinary() { // This method should only be used when the owner is in the form of a Distinguished Name. return new DNWithBinary(this.Owner, this.ToByteArray()).ToString(); } public static KeyCredential ParseDNBinary(string dnWithBinary) { ArgumentException.ThrowIfNullOrEmpty(dnWithBinary); var parsed = DNWithBinary.Parse(dnWithBinary); return new KeyCredential(parsed.Binary, parsed.DistinguishedName); } 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) { case KeyCredentialVersion.Version0: return new DateTime(timeStamp); case KeyCredentialVersion.Version1: return DateTime.FromBinary(timeStamp); case KeyCredentialVersion.Version2: default: return source == KeySource.AD ? DateTime.FromFileTime(timeStamp) : DateTime.FromBinary(timeStamp); } } private static byte[] ConvertToBinaryTime(DateTime time, KeySource source, KeyCredentialVersion version) { long timeStamp; switch (version) { case KeyCredentialVersion.Version0: timeStamp = time.Ticks; break; case KeyCredentialVersion.Version1: timeStamp = time.ToBinary(); break; case KeyCredentialVersion.Version2: default: timeStamp = source == KeySource.AD ? time.ToFileTime() : time.ToBinary(); break; } return BitConverter.GetBytes(timeStamp); } private static byte[] ComputeHash(byte[] data) { return SHA256.HashData(data); } private static string ComputeKeyIdentifier(byte[] keyMaterial, KeyCredentialVersion version) { byte[] binaryId = ComputeHash(keyMaterial); return ConvertFromBinaryIdentifier(binaryId, version); } private static string ConvertFromBinaryIdentifier(byte[] binaryId, KeyCredentialVersion version) { switch (version) { case KeyCredentialVersion.Version0: case KeyCredentialVersion.Version1: return binaryId.ToHex(true); case KeyCredentialVersion.Version2: default: return Convert.ToBase64String(binaryId); } } private static byte[] ConvertToBinaryIdentifier(string keyIdentifier, KeyCredentialVersion version) { switch (version) { case KeyCredentialVersion.Version0: case KeyCredentialVersion.Version1: return keyIdentifier.HexToBinary(); case KeyCredentialVersion.Version2: default: return Convert.FromBase64String(keyIdentifier); } } }