diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Security/EntraDeviceCertHelper.cs b/src/Agent/AutopilotMonitor.Agent.Core/Security/EntraDeviceCertHelper.cs
new file mode 100644
index 00000000..adc6cb61
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Security/EntraDeviceCertHelper.cs
@@ -0,0 +1,161 @@
+using System;
+using System.Linq;
+using System.Security.Cryptography.X509Certificates;
+using AutopilotMonitor.Agent.Core.Logging;
+
+namespace AutopilotMonitor.Agent.Core.Security
+{
+ ///
+ /// Reads the Entra TenantId / DeviceId out of the MS-Organization-Access device
+ /// certificate that Windows installs into LocalMachine\My when AAD-Join completes.
+ ///
+ /// Useful as a fallback for : in the Autopilot
+ /// pre-provisioning / hybrid window the cert can land BEFORE the
+ /// HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\TenantInfo and
+ /// JoinInfo registry writes finish, so the cert carries the TenantId while
+ /// every registry probe still misses.
+ ///
+ ///
+ /// Both GUIDs live as ASN.1 OCTET STRING extensions on the cert:
+ ///
+ ///
+ /// - 1.2.840.113556.1.5.284.5 → TenantId (16 bytes, MS little-endian binary GUID)
+ /// - 1.2.840.113556.1.5.284.2 → DeviceId (same encoding)
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ public static class EntraDeviceCertHelper
+ {
+ public const string MsOrganizationAccessIssuer = "MS-Organization-Access";
+ public const string TenantIdOid = "1.2.840.113556.1.5.284.5";
+ public const string DeviceIdOid = "1.2.840.113556.1.5.284.2";
+
+ ///
+ /// Reads the Entra TenantId from the MS-Organization-Access device cert.
+ /// Returns null if no currently-valid cert is present, the OID extension
+ /// is missing, or the ASN.1 parse failed.
+ ///
+ public static Guid? TryGetTenantIdFromCert(AgentLogger logger = null) =>
+ TryReadGuidExtension(TenantIdOid, "TenantId", logger);
+
+ ///
+ /// Reads the Entra DeviceId from the MS-Organization-Access device cert.
+ ///
+ public static Guid? TryGetDeviceIdFromCert(AgentLogger logger = null) =>
+ TryReadGuidExtension(DeviceIdOid, "DeviceId", logger);
+
+ private static Guid? TryReadGuidExtension(string oid, string label, AgentLogger logger)
+ {
+ if (string.IsNullOrEmpty(oid)) return null;
+
+ try
+ {
+ using (var store = new X509Store(StoreName.My, StoreLocation.LocalMachine))
+ {
+ store.Open(OpenFlags.ReadOnly);
+
+ var now = DateTime.UtcNow;
+ var candidates = store.Certificates
+ .OfType()
+ .Where(c => c.Issuer != null
+ && c.Issuer.IndexOf(MsOrganizationAccessIssuer, StringComparison.OrdinalIgnoreCase) >= 0)
+ .Where(c => c.NotBefore <= now && c.NotAfter >= now)
+ // Latest-expiring cert first — defends against an expired sibling from a prior enrollment lingering in the store.
+ .OrderByDescending(c => c.NotAfter)
+ .ToList();
+
+ if (candidates.Count == 0)
+ {
+ logger?.Warning(
+ $"EntraDeviceCertHelper: no currently-valid '{MsOrganizationAccessIssuer}' cert found in LocalMachine\\My — device likely not yet AAD-joined.");
+ return null;
+ }
+
+ foreach (var cert in candidates)
+ {
+ var ext = cert.Extensions
+ .OfType()
+ .FirstOrDefault(e => e.Oid != null && e.Oid.Value == oid);
+
+ if (ext == null || ext.RawData == null)
+ {
+ logger?.Debug(
+ $"EntraDeviceCertHelper: cert thumbprint={cert.Thumbprint} carries no {label} extension (OID={oid}); trying next candidate.");
+ continue;
+ }
+
+ var guid = ParseGuidFromAsn1OctetString(ext.RawData);
+ if (guid != null)
+ {
+ logger?.Info(
+ $"EntraDeviceCertHelper: read {label}={guid.Value} from {MsOrganizationAccessIssuer} cert " +
+ $"(thumbprint={cert.Thumbprint}, NotAfter={cert.NotAfter:yyyy-MM-ddTHH:mm:ssZ}).");
+ return guid;
+ }
+
+ logger?.Warning(
+ $"EntraDeviceCertHelper: failed to parse {label} ASN.1 OCTET STRING in cert thumbprint={cert.Thumbprint} " +
+ $"(raw {ext.RawData.Length} bytes); trying next candidate.");
+ }
+
+ logger?.Warning(
+ $"EntraDeviceCertHelper: {candidates.Count} matching cert(s) present but none yielded a parseable {label} extension.");
+ return null;
+ }
+ }
+ catch (Exception ex)
+ {
+ logger?.Warning($"EntraDeviceCertHelper: probe for {label} failed — {ex.GetType().Name}: {ex.Message}");
+ return null;
+ }
+ }
+
+ ///
+ /// 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.
+ /// Returns null on any structural mismatch — never throws.
+ ///
+ public static Guid? ParseGuidFromAsn1OctetString(byte[] raw)
+ {
+ if (raw == null || raw.Length < 2) return null;
+
+ // ASN.1 OCTET STRING tag.
+ if (raw[0] != 0x04) return null;
+
+ int contentStart;
+ int contentLen;
+
+ if (raw[1] < 0x80)
+ {
+ // short-form length
+ contentLen = raw[1];
+ contentStart = 2;
+ }
+ else
+ {
+ // long-form length: lower 7 bits = number of following length bytes.
+ int numLenBytes = raw[1] & 0x7F;
+ if (numLenBytes < 1 || numLenBytes > 4 || raw.Length < 2 + numLenBytes) return null;
+
+ contentLen = 0;
+ for (int i = 0; i < numLenBytes; i++)
+ contentLen = (contentLen << 8) | raw[2 + i];
+
+ contentStart = 2 + numLenBytes;
+ }
+
+ if (contentLen != 16 || contentStart + 16 > raw.Length) return null;
+
+ var guidBytes = new byte[16];
+ Buffer.BlockCopy(raw, contentStart, guidBytes, 0, 16);
+
+ try { return new Guid(guidBytes); }
+ catch { return null; }
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Security/TenantIdResolver.cs b/src/Agent/AutopilotMonitor.Agent.Core/Security/TenantIdResolver.cs
index e91fede1..0967cd5c 100644
--- a/src/Agent/AutopilotMonitor.Agent.Core/Security/TenantIdResolver.cs
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Security/TenantIdResolver.cs
@@ -42,6 +42,13 @@ namespace AutopilotMonitor.Agent.Core.Security
/// value TenantId. Same AAD-join phase as TenantInfo, used as a secondary
/// reading because some flows populate one but not the other.
///
+ /// -
+ /// MS-Organization-Access device cert in LocalMachine\My, OID
+ /// 1.2.840.113556.1.5.284.5. Last-resort synchronous probe — the cert can
+ /// land before the CloudDomainJoin registry writes complete, so this lets the agent
+ /// boot without falling through to the registry-watcher awaiter and burning the
+ /// 600s wait window. See .
+ ///
///
///
/// Returns null when no source carries a TenantId. Never throws. On miss the
@@ -61,6 +68,7 @@ namespace AutopilotMonitor.Agent.Core.Security
public const string SourceEnrollmentsRegistryFallback = "enrollments_registry_fallback";
public const string SourceCloudDomainJoinTenantInfo = "cloud_domain_join_tenant_info";
public const string SourceCloudDomainJoinJoinInfo = "cloud_domain_join_join_info";
+ public const string SourceMsOrganizationAccessCert = "ms_organization_access_cert";
///
/// Tries each probe in order and returns the first non-empty TenantId.
@@ -99,6 +107,19 @@ namespace AutopilotMonitor.Agent.Core.Security
return fromJoinInfo;
}
+ // Last synchronous probe: parse the MS-Organization-Access device cert.
+ // EntraDeviceCertHelper logs its own miss-reason warnings, so just record the attempt
+ // here and let a non-null result short-circuit the awaiter wait window.
+ diagnostics.MsOrgAccessCertProbed = true;
+ var fromCert = EntraDeviceCertHelper.TryGetTenantIdFromCert(logger);
+ if (fromCert != null)
+ {
+ var tenantIdString = fromCert.Value.ToString("D");
+ logger?.Info($"TenantIdResolver: resolved TenantId={tenantIdString} from {SourceMsOrganizationAccessCert} (no registry source carried it yet).");
+ return tenantIdString;
+ }
+ diagnostics.MsOrgAccessCertMissed = true;
+
LogResolutionMiss(logger, diagnostics);
return null;
}
@@ -367,6 +388,11 @@ namespace AutopilotMonitor.Agent.Core.Security
foreach (var j in diag.JoinInfoEntries)
logger.Warning($" - thumbprint={j.Thumbprint}, TenantId={(j.HasTenantId ? "" : "")}");
}
+
+ // MS-Organization-Access cert probe (EntraDeviceCertHelper logs its own miss reason — this
+ // is just a breadcrumb that the resolver did try the cert path before giving up).
+ if (diag.MsOrgAccessCertProbed && diag.MsOrgAccessCertMissed)
+ logger.Warning($"TenantIdResolver: {SourceMsOrganizationAccessCert} probe also missed (see EntraDeviceCertHelper warning above for the specific reason).");
}
///
@@ -438,6 +464,9 @@ namespace AutopilotMonitor.Agent.Core.Security
public bool JoinInfoRootMissing;
public string JoinInfoProbeError;
public readonly List JoinInfoEntries = new List();
+
+ public bool MsOrgAccessCertProbed;
+ public bool MsOrgAccessCertMissed;
}
private struct IntuneMdmSnapshot
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EntraDeviceCertHelper.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EntraDeviceCertHelper.cs
new file mode 100644
index 00000000..f4e118b4
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EntraDeviceCertHelper.cs
@@ -0,0 +1,161 @@
+using System;
+using System.Linq;
+using System.Security.Cryptography.X509Certificates;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+
+namespace AutopilotMonitor.Agent.V2.Core.Security
+{
+ ///
+ /// Reads the Entra TenantId / DeviceId out of the MS-Organization-Access device
+ /// certificate that Windows installs into LocalMachine\My when AAD-Join completes.
+ ///
+ /// Useful as a fallback for : in the Autopilot
+ /// pre-provisioning / hybrid window the cert can land BEFORE the
+ /// HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\TenantInfo and
+ /// JoinInfo registry writes finish, so the cert carries the TenantId while
+ /// every registry probe still misses.
+ ///
+ ///
+ /// Both GUIDs live as ASN.1 OCTET STRING extensions on the cert:
+ ///
+ ///
+ /// - 1.2.840.113556.1.5.284.5 → TenantId (16 bytes, MS little-endian binary GUID)
+ /// - 1.2.840.113556.1.5.284.2 → DeviceId (same encoding)
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ public static class EntraDeviceCertHelper
+ {
+ public const string MsOrganizationAccessIssuer = "MS-Organization-Access";
+ public const string TenantIdOid = "1.2.840.113556.1.5.284.5";
+ public const string DeviceIdOid = "1.2.840.113556.1.5.284.2";
+
+ ///
+ /// Reads the Entra TenantId from the MS-Organization-Access device cert.
+ /// Returns null if no currently-valid cert is present, the OID extension
+ /// is missing, or the ASN.1 parse failed.
+ ///
+ public static Guid? TryGetTenantIdFromCert(AgentLogger logger = null) =>
+ TryReadGuidExtension(TenantIdOid, "TenantId", logger);
+
+ ///
+ /// Reads the Entra DeviceId from the MS-Organization-Access device cert.
+ ///
+ public static Guid? TryGetDeviceIdFromCert(AgentLogger logger = null) =>
+ TryReadGuidExtension(DeviceIdOid, "DeviceId", logger);
+
+ private static Guid? TryReadGuidExtension(string oid, string label, AgentLogger logger)
+ {
+ if (string.IsNullOrEmpty(oid)) return null;
+
+ try
+ {
+ using (var store = new X509Store(StoreName.My, StoreLocation.LocalMachine))
+ {
+ store.Open(OpenFlags.ReadOnly);
+
+ var now = DateTime.UtcNow;
+ var candidates = store.Certificates
+ .OfType()
+ .Where(c => c.Issuer != null
+ && c.Issuer.IndexOf(MsOrganizationAccessIssuer, StringComparison.OrdinalIgnoreCase) >= 0)
+ .Where(c => c.NotBefore <= now && c.NotAfter >= now)
+ // Latest-expiring cert first — defends against an expired sibling from a prior enrollment lingering in the store.
+ .OrderByDescending(c => c.NotAfter)
+ .ToList();
+
+ if (candidates.Count == 0)
+ {
+ logger?.Warning(
+ $"EntraDeviceCertHelper: no currently-valid '{MsOrganizationAccessIssuer}' cert found in LocalMachine\\My — device likely not yet AAD-joined.");
+ return null;
+ }
+
+ foreach (var cert in candidates)
+ {
+ var ext = cert.Extensions
+ .OfType()
+ .FirstOrDefault(e => e.Oid != null && e.Oid.Value == oid);
+
+ if (ext == null || ext.RawData == null)
+ {
+ logger?.Debug(
+ $"EntraDeviceCertHelper: cert thumbprint={cert.Thumbprint} carries no {label} extension (OID={oid}); trying next candidate.");
+ continue;
+ }
+
+ var guid = ParseGuidFromAsn1OctetString(ext.RawData);
+ if (guid != null)
+ {
+ logger?.Info(
+ $"EntraDeviceCertHelper: read {label}={guid.Value} from {MsOrganizationAccessIssuer} cert " +
+ $"(thumbprint={cert.Thumbprint}, NotAfter={cert.NotAfter:yyyy-MM-ddTHH:mm:ssZ}).");
+ return guid;
+ }
+
+ logger?.Warning(
+ $"EntraDeviceCertHelper: failed to parse {label} ASN.1 OCTET STRING in cert thumbprint={cert.Thumbprint} " +
+ $"(raw {ext.RawData.Length} bytes); trying next candidate.");
+ }
+
+ logger?.Warning(
+ $"EntraDeviceCertHelper: {candidates.Count} matching cert(s) present but none yielded a parseable {label} extension.");
+ return null;
+ }
+ }
+ catch (Exception ex)
+ {
+ logger?.Warning($"EntraDeviceCertHelper: probe for {label} failed — {ex.GetType().Name}: {ex.Message}");
+ return null;
+ }
+ }
+
+ ///
+ /// 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.
+ /// Returns null on any structural mismatch — never throws.
+ ///
+ public static Guid? ParseGuidFromAsn1OctetString(byte[] raw)
+ {
+ if (raw == null || raw.Length < 2) return null;
+
+ // ASN.1 OCTET STRING tag.
+ if (raw[0] != 0x04) return null;
+
+ int contentStart;
+ int contentLen;
+
+ if (raw[1] < 0x80)
+ {
+ // short-form length
+ contentLen = raw[1];
+ contentStart = 2;
+ }
+ else
+ {
+ // long-form length: lower 7 bits = number of following length bytes.
+ int numLenBytes = raw[1] & 0x7F;
+ if (numLenBytes < 1 || numLenBytes > 4 || raw.Length < 2 + numLenBytes) return null;
+
+ contentLen = 0;
+ for (int i = 0; i < numLenBytes; i++)
+ contentLen = (contentLen << 8) | raw[2 + i];
+
+ contentStart = 2 + numLenBytes;
+ }
+
+ if (contentLen != 16 || contentStart + 16 > raw.Length) return null;
+
+ var guidBytes = new byte[16];
+ Buffer.BlockCopy(raw, contentStart, guidBytes, 0, 16);
+
+ try { return new Guid(guidBytes); }
+ catch { return null; }
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/TenantIdResolver.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/TenantIdResolver.cs
index 21dae6bc..c6d4e26a 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/TenantIdResolver.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/TenantIdResolver.cs
@@ -42,6 +42,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
/// value TenantId. Same AAD-join phase as TenantInfo, used as a secondary
/// reading because some flows populate one but not the other.
///
+ /// -
+ /// MS-Organization-Access device cert in LocalMachine\My, OID
+ /// 1.2.840.113556.1.5.284.5. Last-resort synchronous probe — the cert can
+ /// land before the CloudDomainJoin registry writes complete, so this lets the agent
+ /// boot without falling through to the registry-watcher awaiter and burning the
+ /// 600s wait window. See .
+ ///
///
///
/// Returns null when no source carries a TenantId. Never throws. On miss the
@@ -61,6 +68,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
public const string SourceEnrollmentsRegistryFallback = "enrollments_registry_fallback";
public const string SourceCloudDomainJoinTenantInfo = "cloud_domain_join_tenant_info";
public const string SourceCloudDomainJoinJoinInfo = "cloud_domain_join_join_info";
+ public const string SourceMsOrganizationAccessCert = "ms_organization_access_cert";
///
/// Tries each probe in order and returns the first non-empty TenantId.
@@ -99,6 +107,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
return fromJoinInfo;
}
+ // Last synchronous probe: parse the MS-Organization-Access device cert.
+ // EntraDeviceCertHelper logs its own miss-reason warnings, so just record the attempt
+ // here and let a non-null result short-circuit the awaiter wait window.
+ diagnostics.MsOrgAccessCertProbed = true;
+ var fromCert = EntraDeviceCertHelper.TryGetTenantIdFromCert(logger);
+ if (fromCert != null)
+ {
+ var tenantIdString = fromCert.Value.ToString("D");
+ logger?.Info($"TenantIdResolver: resolved TenantId={tenantIdString} from {SourceMsOrganizationAccessCert} (no registry source carried it yet).");
+ return tenantIdString;
+ }
+ diagnostics.MsOrgAccessCertMissed = true;
+
LogResolutionMiss(logger, diagnostics);
return null;
}
@@ -367,6 +388,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
foreach (var j in diag.JoinInfoEntries)
logger.Warning($" - thumbprint={j.Thumbprint}, TenantId={(j.HasTenantId ? "" : "")}");
}
+
+ // MS-Organization-Access cert probe (EntraDeviceCertHelper logs its own miss reason — this
+ // is just a breadcrumb that the resolver did try the cert path before giving up).
+ if (diag.MsOrgAccessCertProbed && diag.MsOrgAccessCertMissed)
+ logger.Warning($"TenantIdResolver: {SourceMsOrganizationAccessCert} probe also missed (see EntraDeviceCertHelper warning above for the specific reason).");
}
///
@@ -438,6 +464,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
public bool JoinInfoRootMissing;
public string JoinInfoProbeError;
public readonly List JoinInfoEntries = new List();
+
+ public bool MsOrgAccessCertProbed;
+ public bool MsOrgAccessCertMissed;
}
private struct IntuneMdmSnapshot