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; }