From 2cf48244306ac0f90db3f2c71494d52d163c1e5b Mon Sep 17 00:00:00 2001 From: Allan Ritchie Date: Thu, 10 Sep 2026 17:28:05 -0400 Subject: [PATCH] Find Apple Developer ID Installer certificates (needed for publishing profiles) --- .../Handlers/Apple/GetCertificatesHandler.cs | 79 +++++++++++++++- src/MauiSherpa.Core/Interfaces.cs | 15 ++- .../Services/LocalCertificateService.cs | 31 ++++++- src/MauiSherpa/Pages/Certificates.razor | 30 ++++-- .../Apple/GetCertificatesHandlerTests.cs | 92 ++++++++++++++++++- 5 files changed, 235 insertions(+), 12 deletions(-) diff --git a/src/MauiSherpa.Core/Handlers/Apple/GetCertificatesHandler.cs b/src/MauiSherpa.Core/Handlers/Apple/GetCertificatesHandler.cs index 2e8ef961..25f76a43 100644 --- a/src/MauiSherpa.Core/Handlers/Apple/GetCertificatesHandler.cs +++ b/src/MauiSherpa.Core/Handlers/Apple/GetCertificatesHandler.cs @@ -10,11 +10,21 @@ namespace MauiSherpa.Core.Handlers.Apple; /// public partial class GetCertificatesHandler : IRequestHandler> { + internal const string DeveloperIdInstallerType = "DEVELOPER_ID_INSTALLER"; + private const string DeveloperIdInstallerPrefix = "Developer ID Installer:"; + private readonly IAppleConnectService _appleService; + private readonly ILocalCertificateService _localCertificates; + private readonly ILoggingService _logger; - public GetCertificatesHandler(IAppleConnectService appleService) + public GetCertificatesHandler( + IAppleConnectService appleService, + ILocalCertificateService localCertificates, + ILoggingService logger) { _appleService = appleService; + _localCertificates = localCertificates; + _logger = logger; } [Cache(AbsoluteExpirationSeconds = 300)] // 5 min cache @@ -24,6 +34,71 @@ public async Task> Handle( IMediatorContext context, CancellationToken ct) { - return await _appleService.GetCertificatesAsync(); + var certificates = await _appleService.GetCertificatesAsync(); + return await AddKeychainOnlyInstallerCertificatesAsync(certificates); + } + + /// + /// App Store Connect does not return Developer ID Installer certificates from + /// /v1/certificates, so the only place they exist is the local keychain. Without this + /// they are invisible everywhere in the app, including the installer certificate + /// picker on publish profiles. + /// + async Task> AddKeychainOnlyInstallerCertificatesAsync( + IReadOnlyList certificates) + { + if (!_localCertificates.IsSupported) + return certificates; + + try + { + var identities = await _localCertificates.GetSigningIdentitiesAsync(); + var knownSerials = certificates + .Select(certificate => NormalizeSerial(certificate.SerialNumber)) + .Where(serial => serial.Length > 0) + .ToHashSet(StringComparer.Ordinal); + + var installerCertificates = identities + .Where(IsDeveloperIdInstaller) + .Where(identity => !string.IsNullOrWhiteSpace(identity.SerialNumber)) + .GroupBy(identity => NormalizeSerial(identity.SerialNumber), StringComparer.Ordinal) + .Where(group => group.Key.Length > 0 && !knownSerials.Contains(group.Key)) + .Select(group => ToCertificate(group.First())) + .ToList(); + + if (installerCertificates.Count == 0) + return certificates; + + _logger.LogInformation( + $"Added {installerCertificates.Count} keychain-only Developer ID Installer certificate(s)"); + return certificates.Concat(installerCertificates).ToList(); + } + catch (Exception ex) + { + _logger.LogWarning($"Could not read Developer ID Installer certificates from the keychain: {ex.Message}"); + return certificates; + } } + + static bool IsDeveloperIdInstaller(LocalSigningIdentity identity) => + identity.CommonName.StartsWith(DeveloperIdInstallerPrefix, StringComparison.OrdinalIgnoreCase) || + identity.Identity.Contains(DeveloperIdInstallerPrefix, StringComparison.OrdinalIgnoreCase); + + static AppleCertificate ToCertificate(LocalSigningIdentity identity) => new( + Id: $"keychain:{identity.SerialNumber}", + Name: identity.CommonName, + CertificateType: DeveloperIdInstallerType, + Platform: "MAC_OS", + ExpirationDate: identity.ExpirationDate ?? DateTime.UtcNow.AddYears(1), + SerialNumber: identity.SerialNumber ?? "") + { + IsLocalOnly = true + }; + + static string NormalizeSerial(string? serialNumber) => + new string((serialNumber ?? "") + .Where(char.IsLetterOrDigit) + .Select(char.ToUpperInvariant) + .ToArray()) + .TrimStart('0'); } diff --git a/src/MauiSherpa.Core/Interfaces.cs b/src/MauiSherpa.Core/Interfaces.cs index c8326760..48e48b47 100644 --- a/src/MauiSherpa.Core/Interfaces.cs +++ b/src/MauiSherpa.Core/Interfaces.cs @@ -774,7 +774,15 @@ public record AppleCertificate( string Platform, DateTime ExpirationDate, string SerialNumber -); +) +{ + /// + /// True when this certificate came from the local keychain rather than App Store + /// Connect. Developer ID Installer certificates are not returned by the API, so they + /// only exist locally — App Store Connect actions such as revoke do not apply. + /// + public bool IsLocalOnly { get; init; } +} public record AppleProfile( string Id, @@ -2384,6 +2392,11 @@ Task RunAsync( /// Whether an operation is currently running /// bool IsRunning { get; } + + /// + /// Requests cancellation of the running operation + /// + void RequestCancellation(); /// /// Event fired when the modal is shown diff --git a/src/MauiSherpa.Core/Services/LocalCertificateService.cs b/src/MauiSherpa.Core/Services/LocalCertificateService.cs index 5f052eb0..13501d00 100644 --- a/src/MauiSherpa.Core/Services/LocalCertificateService.cs +++ b/src/MauiSherpa.Core/Services/LocalCertificateService.cs @@ -76,13 +76,35 @@ public async Task> GetSigningIdentitiesAsync // Parse output - each line looks like: // 1) HASH "Identity String" // or with CSSMERR_TP_CERT_EXPIRED for invalid certs - var lines = result.Output.Split('\n', StringSplitOptions.RemoveEmptyEntries); - + var lines = result.Output.Split('\n', StringSplitOptions.RemoveEmptyEntries).ToList(); + + // Installer certificates (Developer ID Installer, Mac Installer Distribution) + // sign packages rather than code, so the codesigning policy leaves them out. + // The basic policy lists them — take only the installer identities from it so + // unrelated basic identities (web server certs and the like) stay out. + var installerResult = await RunSecurityCommandAsync("find-identity", "-v", "-p", "basic"); + if (installerResult.ExitCode == 0) + { + lines.AddRange(installerResult.Output + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Where(IsInstallerIdentityLine)); + } + else + { + _logger.LogWarning( + $"Could not list installer identities: security find-identity -p basic exited with {installerResult.ExitCode}"); + } + + var seenHashes = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var line in lines) { var identity = ParseIdentityLine(line); if (identity != null) { + // The two policies overlap, so keep the first copy of each certificate. + if (!string.IsNullOrEmpty(identity.Hash) && !seenHashes.Add(identity.Hash)) + continue; + // Look up the serial number for this identity if (!string.IsNullOrEmpty(identity.Hash)) { @@ -623,6 +645,11 @@ private async Task SaveSerialCacheAsync() return (process.ExitCode, output, error); } + internal static bool IsInstallerIdentityLine(string line) => + line.Contains("Developer ID Installer:", StringComparison.OrdinalIgnoreCase) || + line.Contains("Mac Installer Distribution:", StringComparison.OrdinalIgnoreCase) || + line.Contains("3rd Party Mac Developer Installer:", StringComparison.OrdinalIgnoreCase); + // Regex to parse identity lines from security find-identity output [GeneratedRegex(@"^\s*\d+\)\s+(?[A-F0-9]+)\s+""(?[^""]+)""", RegexOptions.IgnoreCase)] private static partial Regex IdentityLineRegex(); diff --git a/src/MauiSherpa/Pages/Certificates.razor b/src/MauiSherpa/Pages/Certificates.razor index cdf2ddc8..0e560ef8 100644 --- a/src/MauiSherpa/Pages/Certificates.razor +++ b/src/MauiSherpa/Pages/Certificates.razor @@ -93,6 +93,7 @@ +