fix(agent): add MS-Organization-Access cert fallback to TenantIdResolver

Adds EntraDeviceCertHelper that reads the AAD-Join device certificate's
TenantId extension (OID 1.2.840.113556.1.5.284.5) from LocalMachine\My.
Wired as the 5th and final synchronous probe in TenantIdResolver, after
the four registry probes:

  1. enrollments_registry         (Type=6, Intune MDM)
  2. enrollments_registry_fallback (any Type, AADTenantID present)
  3. cloud_domain_join_tenant_info
  4. cloud_domain_join_join_info
  5. ms_organization_access_cert  <- new

Targets the Autopilot pre-provisioning / hybrid window where the cert
can land before the CloudDomainJoin registry writes complete. Lets the
agent boot without falling through to the registry-watcher awaiter and
burning the 600s wait window.

Helper hardening over the original draft:
- Filters to currently-valid certs (NotBefore <= now <= NotAfter) and
  picks latest NotAfter when multiple match, defending against an
  expired sibling from a prior re-enrollment.
- If the highest-priority cert lacks the OID extension, walks through
  the remaining candidates instead of bailing on the first miss.
- Logger emits Info on hit (thumbprint + NotAfter) and Warning on miss
  with the specific reason (no cert / extension missing / parse failed).

Mirrored across V1 (Agent.Core) and V2 (Agent.V2.Core).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-05-05 22:19:02 +02:00
parent b67d68c552
commit b4e98e32eb
4 changed files with 380 additions and 0 deletions
@@ -0,0 +1,161 @@
using System;
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using AutopilotMonitor.Agent.Core.Logging;
namespace AutopilotMonitor.Agent.Core.Security
{
/// <summary>
/// Reads the Entra TenantId / DeviceId out of the <c>MS-Organization-Access</c> device
/// certificate that Windows installs into <c>LocalMachine\My</c> when AAD-Join completes.
/// <para>
/// Useful as a fallback for <see cref="TenantIdResolver"/>: in the Autopilot
/// pre-provisioning / hybrid window the cert can land BEFORE the
/// <c>HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\TenantInfo</c> and
/// <c>JoinInfo</c> registry writes finish, so the cert carries the TenantId while
/// every registry probe still misses.
/// </para>
/// <para>
/// Both GUIDs live as ASN.1 OCTET STRING extensions on the cert:
/// </para>
/// <list type="bullet">
/// <item><description><c>1.2.840.113556.1.5.284.5</c> → TenantId (16 bytes, MS little-endian binary GUID)</description></item>
/// <item><description><c>1.2.840.113556.1.5.284.2</c> → DeviceId (same encoding)</description></item>
/// </list>
/// <para>
/// All probes are best-effort: never throw, return <c>null</c> 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.
/// </para>
/// </summary>
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";
/// <summary>
/// Reads the Entra TenantId from the MS-Organization-Access device cert.
/// Returns <c>null</c> if no currently-valid cert is present, the OID extension
/// is missing, or the ASN.1 parse failed.
/// </summary>
public static Guid? TryGetTenantIdFromCert(AgentLogger logger = null) =>
TryReadGuidExtension(TenantIdOid, "TenantId", logger);
/// <summary>
/// Reads the Entra DeviceId from the MS-Organization-Access device cert.
/// </summary>
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<X509Certificate2>()
.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<X509Extension>()
.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;
}
}
/// <summary>
/// Parses a 16-byte MS-format binary GUID (little-endian for the first three groups,
/// per <see cref="Guid(byte[])"/>) wrapped in an ASN.1 OCTET STRING. Handles both
/// short-form (<c>04 10 ...</c>) and long-form (<c>04 81 10 ...</c>) length encoding.
/// Returns <c>null</c> on any structural mismatch — never throws.
/// </summary>
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; }
}
}
}
@@ -42,6 +42,13 @@ namespace AutopilotMonitor.Agent.Core.Security
/// value <c>TenantId</c>. Same AAD-join phase as TenantInfo, used as a secondary
/// reading because some flows populate one but not the other.
/// </description></item>
/// <item><description>
/// <c>MS-Organization-Access</c> device cert in <c>LocalMachine\My</c>, OID
/// <c>1.2.840.113556.1.5.284.5</c>. 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 <see cref="EntraDeviceCertHelper"/>.
/// </description></item>
/// </list>
/// <para>
/// Returns <c>null</c> 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";
/// <summary>
/// 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 ? "<present>" : "<missing>")}");
}
// 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).");
}
/// <summary>
@@ -438,6 +464,9 @@ namespace AutopilotMonitor.Agent.Core.Security
public bool JoinInfoRootMissing;
public string JoinInfoProbeError;
public readonly List<JoinInfoSnapshot> JoinInfoEntries = new List<JoinInfoSnapshot>();
public bool MsOrgAccessCertProbed;
public bool MsOrgAccessCertMissed;
}
private struct IntuneMdmSnapshot
@@ -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
{
/// <summary>
/// Reads the Entra TenantId / DeviceId out of the <c>MS-Organization-Access</c> device
/// certificate that Windows installs into <c>LocalMachine\My</c> when AAD-Join completes.
/// <para>
/// Useful as a fallback for <see cref="TenantIdResolver"/>: in the Autopilot
/// pre-provisioning / hybrid window the cert can land BEFORE the
/// <c>HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\TenantInfo</c> and
/// <c>JoinInfo</c> registry writes finish, so the cert carries the TenantId while
/// every registry probe still misses.
/// </para>
/// <para>
/// Both GUIDs live as ASN.1 OCTET STRING extensions on the cert:
/// </para>
/// <list type="bullet">
/// <item><description><c>1.2.840.113556.1.5.284.5</c> → TenantId (16 bytes, MS little-endian binary GUID)</description></item>
/// <item><description><c>1.2.840.113556.1.5.284.2</c> → DeviceId (same encoding)</description></item>
/// </list>
/// <para>
/// All probes are best-effort: never throw, return <c>null</c> 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.
/// </para>
/// </summary>
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";
/// <summary>
/// Reads the Entra TenantId from the MS-Organization-Access device cert.
/// Returns <c>null</c> if no currently-valid cert is present, the OID extension
/// is missing, or the ASN.1 parse failed.
/// </summary>
public static Guid? TryGetTenantIdFromCert(AgentLogger logger = null) =>
TryReadGuidExtension(TenantIdOid, "TenantId", logger);
/// <summary>
/// Reads the Entra DeviceId from the MS-Organization-Access device cert.
/// </summary>
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<X509Certificate2>()
.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<X509Extension>()
.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;
}
}
/// <summary>
/// Parses a 16-byte MS-format binary GUID (little-endian for the first three groups,
/// per <see cref="Guid(byte[])"/>) wrapped in an ASN.1 OCTET STRING. Handles both
/// short-form (<c>04 10 ...</c>) and long-form (<c>04 81 10 ...</c>) length encoding.
/// Returns <c>null</c> on any structural mismatch — never throws.
/// </summary>
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; }
}
}
}
@@ -42,6 +42,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
/// value <c>TenantId</c>. Same AAD-join phase as TenantInfo, used as a secondary
/// reading because some flows populate one but not the other.
/// </description></item>
/// <item><description>
/// <c>MS-Organization-Access</c> device cert in <c>LocalMachine\My</c>, OID
/// <c>1.2.840.113556.1.5.284.5</c>. 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 <see cref="EntraDeviceCertHelper"/>.
/// </description></item>
/// </list>
/// <para>
/// Returns <c>null</c> 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";
/// <summary>
/// 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 ? "<present>" : "<missing>")}");
}
// 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).");
}
/// <summary>
@@ -438,6 +464,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
public bool JoinInfoRootMissing;
public string JoinInfoProbeError;
public readonly List<JoinInfoSnapshot> JoinInfoEntries = new List<JoinInfoSnapshot>();
public bool MsOrgAccessCertProbed;
public bool MsOrgAccessCertMissed;
}
private struct IntuneMdmSnapshot