diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/EntraDeviceCertHelperParseTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/EntraDeviceCertHelperParseTests.cs new file mode 100644 index 00000000..932710da --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/EntraDeviceCertHelperParseTests.cs @@ -0,0 +1,146 @@ +using System; +using AutopilotMonitor.Agent.V2.Core.Security; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Security +{ + /// + /// Pins , the pure byte-level part of the + /// cert probe. Both encodings Microsoft actually ships are covered: the ASN.1 OCTET STRING wrapper + /// used by the MS-Organization-Access TenantId/DeviceId extensions (and Intune's + /// 1.2.840.113556.5.6 / 5.14), and the BARE 16-byte payload used by Intune's DeviceId + /// extension 1.2.840.113556.5.4 — the latter was silently returning null before. + /// The store-iteration shell around it needs a real machine cert store and is not unit-tested. + /// + public sealed class EntraDeviceCertHelperParseTests + { + // Synthetic — never a real tenant/device id. ToByteArray() gives the MS little-endian layout + // that both cert encodings carry, so every expectation below is derived, not hand-transcribed. + private static readonly Guid Sample = new Guid("11223344-5566-7788-99aa-bbccddeeff00"); + + private static byte[] Wrap(byte[] payload, params byte[] header) + { + var buf = new byte[header.Length + payload.Length]; + Buffer.BlockCopy(header, 0, buf, 0, header.Length); + Buffer.BlockCopy(payload, 0, buf, header.Length, payload.Length); + return buf; + } + + [Fact] + public void OctetString_ShortForm_Parses() + { + var raw = Wrap(Sample.ToByteArray(), 0x04, 0x10); + + Assert.Equal(Sample, EntraDeviceCertHelper.ParseGuidFromExtension(raw)); + } + + [Fact] + public void OctetString_LongForm_Parses() + { + var raw = Wrap(Sample.ToByteArray(), 0x04, 0x81, 0x10); + + Assert.Equal(Sample, EntraDeviceCertHelper.ParseGuidFromExtension(raw)); + } + + /// + /// The regression this class exists for: Intune's 1.2.840.113556.5.4 extension value is + /// exactly 16 bytes with no 04 10 prefix, so the strict OCTET-STRING parser bailed on the + /// tag check and the DeviceId was unreadable. + /// + [Fact] + public void BareSixteenBytes_Parses() + { + var raw = Sample.ToByteArray(); + + Assert.Equal(Sample, EntraDeviceCertHelper.ParseGuidFromExtension(raw)); + } + + /// + /// A bare GUID may legitimately start with 0x04 (the OCTET STRING tag). That must not be + /// mistaken for a wrapper: a wrapped 16-byte GUID is always >= 18 bytes, so the two forms are + /// disjoint by length and the bare path has to win here. + /// + [Fact] + public void BareSixteenBytes_StartingWithOctetStringTag_StillParses() + { + var guid = new Guid("aabbcc04-0110-4020-8030-405060708090"); + var raw = guid.ToByteArray(); + + Assert.Equal(0x04, raw[0]); // guard: the byte layout really does start with the ASN.1 tag + Assert.Equal(16, raw.Length); + Assert.Equal(guid, EntraDeviceCertHelper.ParseGuidFromExtension(raw)); + } + + [Fact] + public void OctetString_IsLittleEndianMsLayout() + { + // 04 10 + the canonical MS binary layout of {11223344-...} => first group byte-reversed. + var raw = new byte[] + { + 0x04, 0x10, + 0x44, 0x33, 0x22, 0x11, + 0x66, 0x55, + 0x88, 0x77, + 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00 + }; + + Assert.Equal(Sample, EntraDeviceCertHelper.ParseGuidFromExtension(raw)); + } + + [Fact] + public void Null_ReturnsNull() + { + Assert.Null(EntraDeviceCertHelper.ParseGuidFromExtension(null)); + } + + [Fact] + public void Empty_ReturnsNull() + { + Assert.Null(EntraDeviceCertHelper.ParseGuidFromExtension(new byte[0])); + } + + [Theory] + [InlineData(15)] + [InlineData(17)] + public void BarePayload_OfWrongLength_ReturnsNull(int length) + { + var raw = new byte[length]; + for (var i = 0; i < length; i++) raw[i] = (byte)(0x40 + i); // no 0x04 tag, no valid wrapper + + Assert.Null(EntraDeviceCertHelper.ParseGuidFromExtension(raw)); + } + + [Fact] + public void NonOctetStringTag_ReturnsNull() + { + // e.g. Intune's 1.2.840.113556.5.15 => ASN.1 INTEGER 2. + Assert.Null(EntraDeviceCertHelper.ParseGuidFromExtension(new byte[] { 0x02, 0x01, 0x02 })); + } + + [Fact] + public void OctetString_WithNonGuidContentLength_ReturnsNull() + { + var raw = Wrap(new byte[8], 0x04, 0x08); + + Assert.Null(EntraDeviceCertHelper.ParseGuidFromExtension(raw)); + } + + [Fact] + public void OctetString_TruncatedContent_ReturnsNull() + { + // Declares 16 content bytes but only carries 10. + var raw = Wrap(new byte[10], 0x04, 0x10); + + Assert.Null(EntraDeviceCertHelper.ParseGuidFromExtension(raw)); + } + + [Fact] + public void OctetString_LongFormWithOversizedLengthHeader_ReturnsNull() + { + // 0x85 => 5 length bytes, above the 4 the parser accepts. + var raw = Wrap(Sample.ToByteArray(), 0x04, 0x85, 0x00, 0x00, 0x00, 0x00, 0x10); + + Assert.Null(EntraDeviceCertHelper.ParseGuidFromExtension(raw)); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EntraDeviceCertHelper.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EntraDeviceCertHelper.cs index f4e118b4..f3b05429 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EntraDeviceCertHelper.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EntraDeviceCertHelper.cs @@ -23,6 +23,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Security /// 1.2.840.113556.1.5.284.2 → DeviceId (same encoding) /// /// + /// The sibling Microsoft Intune MDM Device CA client cert (the one the agent authenticates + /// with, see ) carries a DIFFERENT OID arc that identifies the same + /// device from Intune's side. Observed on a live Intune-enrolled Windows 11 device: + /// + /// + /// OIDMeaning / encoding + /// 1.2.840.113556.5.4Intune DeviceId — BARE 16-byte GUID, no ASN.1 wrapper. Equals the cert's Subject CN. + /// 1.2.840.113556.5.6Intune AccountId (the Intune tenant account, NOT the Entra TenantId) — OCTET STRING GUID. + /// 1.2.840.113556.5.14Entra TenantId — OCTET STRING GUID. This is the one to compare against an Entra tenant id. + /// 1.2.840.113556.5.11Further GUID, OCTET STRING; semantics not documented by Microsoft and not verified here. + /// 1.2.840.113556.5.15/.16/.17/.18/.19/.23ASN.1 INTEGER enums (platform / enrollment flavour); semantics undocumented. + /// + /// + /// The bare-GUID encoding of 1.2.840.113556.5.4 is why + /// accepts an unwrapped 16-byte payload as well — + /// a strict OCTET-STRING parser silently returns null for that extension. + /// + /// /// All probes are best-effort: never throw, return null on miss, log a Warning /// with the specific failure reason (no matching cert / extension missing / ASN.1 /// parse failed) so the agent log explains why the fallback didn't help. @@ -88,7 +106,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security continue; } - var guid = ParseGuidFromAsn1OctetString(ext.RawData); + var guid = ParseGuidFromExtension(ext.RawData); if (guid != null) { logger?.Info( @@ -98,7 +116,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security } logger?.Warning( - $"EntraDeviceCertHelper: failed to parse {label} ASN.1 OCTET STRING in cert thumbprint={cert.Thumbprint} " + + $"EntraDeviceCertHelper: failed to parse {label} GUID extension in cert thumbprint={cert.Thumbprint} " + $"(raw {ext.RawData.Length} bytes); trying next candidate."); } @@ -116,13 +134,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Security /// /// Parses a 16-byte MS-format binary GUID (little-endian for the first three groups, - /// per ) wrapped in an ASN.1 OCTET STRING. Handles both - /// short-form (04 10 ...) and long-form (04 81 10 ...) length encoding. + /// per ) out of a certificate extension's raw value. + /// Two encodings are accepted, because Microsoft uses both: + /// + /// Wrapped in an ASN.1 OCTET STRING — short-form (04 10 ...) or + /// long-form (04 81 10 ...) length. Used by the MS-Organization-Access TenantId/DeviceId + /// extensions and by the Intune …5.6 / …5.14 extensions. + /// A bare, unwrapped 16-byte payload. Used by the Intune DeviceId extension + /// 1.2.840.113556.5.4 — verified against a live Intune MDM device cert, whose raw value is + /// exactly 16 bytes with no 04 10 prefix. + /// + /// The two forms cannot collide: a valid OCTET-STRING wrapper around 16 bytes is at least 18 + /// bytes long, so a 16-byte payload is never a truncated wrapper even when it happens to start + /// with 0x04. /// Returns null on any structural mismatch — never throws. /// - public static Guid? ParseGuidFromAsn1OctetString(byte[] raw) + public static Guid? ParseGuidFromExtension(byte[] raw) { - if (raw == null || raw.Length < 2) return null; + if (raw == null) return null; + + // Bare 16-byte GUID (Intune 1.2.840.113556.5.4) — no ASN.1 wrapper to strip. + if (raw.Length == 16) return ToGuid(raw, 0); + + if (raw.Length < 2) return null; // ASN.1 OCTET STRING tag. if (raw[0] != 0x04) return null; @@ -151,8 +185,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Security if (contentLen != 16 || contentStart + 16 > raw.Length) return null; + return ToGuid(raw, contentStart); + } + + private static Guid? ToGuid(byte[] raw, int offset) + { var guidBytes = new byte[16]; - Buffer.BlockCopy(raw, contentStart, guidBytes, 0, 16); + Buffer.BlockCopy(raw, offset, guidBytes, 0, 16); try { return new Guid(guidBytes); } catch { return null; }