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