Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 77 additions & 2 deletions src/MauiSherpa.Core/Handlers/Apple/GetCertificatesHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,21 @@ namespace MauiSherpa.Core.Handlers.Apple;
/// </summary>
public partial class GetCertificatesHandler : IRequestHandler<GetCertificatesRequest, IReadOnlyList<AppleCertificate>>
{
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
Expand All @@ -24,6 +34,71 @@ public async Task<IReadOnlyList<AppleCertificate>> Handle(
IMediatorContext context,
CancellationToken ct)
{
return await _appleService.GetCertificatesAsync();
var certificates = await _appleService.GetCertificatesAsync();
return await AddKeychainOnlyInstallerCertificatesAsync(certificates);
}

/// <summary>
/// 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.
/// </summary>
async Task<IReadOnlyList<AppleCertificate>> AddKeychainOnlyInstallerCertificatesAsync(
IReadOnlyList<AppleCertificate> 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');
}
15 changes: 14 additions & 1 deletion src/MauiSherpa.Core/Interfaces.cs
Original file line number Diff line number Diff line change
Expand Up @@ -774,7 +774,15 @@ public record AppleCertificate(
string Platform,
DateTime ExpirationDate,
string SerialNumber
);
)
{
/// <summary>
/// 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.
/// </summary>
public bool IsLocalOnly { get; init; }
}

public record AppleProfile(
string Id,
Expand Down Expand Up @@ -2384,6 +2392,11 @@ Task<OperationResult> RunAsync(
/// Whether an operation is currently running
/// </summary>
bool IsRunning { get; }

/// <summary>
/// Requests cancellation of the running operation
/// </summary>
void RequestCancellation();

/// <summary>
/// Event fired when the modal is shown
Expand Down
31 changes: 29 additions & 2 deletions src/MauiSherpa.Core/Services/LocalCertificateService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,35 @@ public async Task<IReadOnlyList<LocalSigningIdentity>> 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<string>(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))
{
Expand Down Expand Up @@ -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+(?<hash>[A-F0-9]+)\s+""(?<identity>[^""]+)""", RegexOptions.IgnoreCase)]
private static partial Regex IdentityLineRegex();
Expand Down
30 changes: 24 additions & 6 deletions src/MauiSherpa/Pages/Certificates.razor
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
<option value="DEVELOPMENT">Development</option>
<option value="DISTRIBUTION">Distribution</option>
<option value="DEVELOPER_ID">Developer ID</option>
<option value="DEVELOPER_ID_INSTALLER">Developer ID Installer</option>
</select>
<select @bind="filterPlatform" class="filter-select">
<option value="">All Platforms</option>
Expand Down Expand Up @@ -193,6 +194,12 @@
{
<span class="badge badge-platform">@formattedPlatform</span>
}
@if (cert.IsLocalOnly)
{
<span class="badge badge-local-only" title="Not published by App Store Connect — read from your local keychain">
<i class="fas fa-key"></i> Keychain only
</span>
}
@if (isExpired)
{
<span class="badge badge-expired">Expired</span>
Expand Down Expand Up @@ -238,10 +245,13 @@
<i class="fas fa-trash-alt"></i>
</button>
}
<button class="btn btn-danger btn-icon" @onclick="@(() => RevokeCertificate(cert))"
disabled="@isLoading" title="Revoke">
<i class="fas fa-ban"></i>
</button>
@if (!cert.IsLocalOnly)
{
<button class="btn btn-danger btn-icon" @onclick="@(() => RevokeCertificate(cert))"
disabled="@isLoading" title="Revoke">
<i class="fas fa-ban"></i>
</button>
}
</div>
}
</div>
Expand Down Expand Up @@ -514,6 +524,7 @@

.badge { padding: 2px 0.625rem; border-radius: 0.75rem; font-size: 0.75rem; font-weight: 500; }
.badge-type { background: #faf5ff; color: #6b46c1; }
.badge-local-only { background: var(--sync-local-bg); color: var(--sync-local-text); }
.badge-platform { background: #bee3f8; color: #2c5282; }
.badge-valid { background: var(--status-success-bg); color: var(--status-success-text); }
.badge-expiring { background: var(--status-warning-bg); color: var(--status-warning-text); }
Expand Down Expand Up @@ -685,7 +696,14 @@
switch (filterId)
{
case "type":
filterType = selectedIndex switch { 1 => "DEVELOPMENT", 2 => "DISTRIBUTION", 3 => "DEVELOPER_ID", _ => "" };
filterType = selectedIndex switch
{
1 => "DEVELOPMENT",
2 => "DISTRIBUTION",
3 => "DEVELOPER_ID",
4 => "DEVELOPER_ID_INSTALLER",
_ => ""
};
break;
case "platform":
filterPlatform = selectedIndex switch { 1 => "IOS", 2 => "MAC", _ => "" };
Expand Down Expand Up @@ -935,7 +953,7 @@

ToolbarService.SetSearch("Search certificates...");
ToolbarService.SetFilters(
new ToolbarFilter("type", "Type", ["All Types", "Development", "Distribution", "Developer ID"]),
new ToolbarFilter("type", "Type", ["All Types", "Development", "Distribution", "Developer ID", "Developer ID Installer"]),
new ToolbarFilter("platform", "Platform", ["All Platforms", "iOS", "macOS"]),
new ToolbarFilter("status", "Status", ["All Statuses", "Valid", "Expiring Soon", "Expired"]));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,104 @@ namespace MauiSherpa.Core.Tests.Handlers.Apple;
public class GetCertificatesHandlerTests
{
private readonly Mock<IAppleConnectService> _mockAppleService;
private readonly Mock<ILocalCertificateService> _mockLocalCertificates;
private readonly Mock<ILoggingService> _mockLogger;
private readonly Mock<IMediatorContext> _mockContext;
private readonly GetCertificatesHandler _handler;

public GetCertificatesHandlerTests()
{
_mockAppleService = new Mock<IAppleConnectService>();
_mockLocalCertificates = new Mock<ILocalCertificateService>();
_mockLogger = new Mock<ILoggingService>();
_mockContext = new Mock<IMediatorContext>();
_handler = new GetCertificatesHandler(_mockAppleService.Object);
_mockLocalCertificates.SetupGet(s => s.IsSupported).Returns(false);
_handler = new GetCertificatesHandler(
_mockAppleService.Object,
_mockLocalCertificates.Object,
_mockLogger.Object);
}

static LocalSigningIdentity Identity(string commonName, string serialNumber) => new(
Identity: commonName,
CommonName: commonName,
TeamId: "T9KLD6CCM9",
SerialNumber: serialNumber,
ExpirationDate: DateTime.UtcNow.AddYears(2),
IsValid: true);

[Fact]
public async Task Handle_AddsDeveloperIdInstallerCertificatesFromTheKeychain()
{
// App Store Connect does not return Developer ID Installer certificates.
_mockAppleService.Setup(s => s.GetCertificatesAsync())
.ReturnsAsync(new List<AppleCertificate>
{
new("cert1", "Developer ID Application", "DEVELOPER_ID_APPLICATION_G2", "MAC_OS", DateTime.UtcNow.AddYears(1), "ABC123")
});
_mockLocalCertificates.SetupGet(s => s.IsSupported).Returns(true);
_mockLocalCertificates.Setup(s => s.GetSigningIdentitiesAsync())
.ReturnsAsync(new List<LocalSigningIdentity>
{
Identity("Developer ID Installer: Allan Ritchie (T9KLD6CCM9)", "FEDCBA"),
Identity("Developer ID Application: Allan Ritchie (T9KLD6CCM9)", "ABC123"),
Identity("localhost", "999999")
});

var result = await _handler.Handle(
new GetCertificatesRequest("identity1"),
_mockContext.Object,
CancellationToken.None);

result.Should().HaveCount(2);
var installer = result.Single(certificate => certificate.SerialNumber == "FEDCBA");
installer.CertificateType.Should().Be("DEVELOPER_ID_INSTALLER");
installer.IsLocalOnly.Should().BeTrue();
result.Should().ContainSingle(certificate => certificate.SerialNumber == "ABC123");
}

[Fact]
public async Task Handle_DoesNotDuplicateAnInstallerCertificateTheApiAlreadyReturned()
{
_mockAppleService.Setup(s => s.GetCertificatesAsync())
.ReturnsAsync(new List<AppleCertificate>
{
new("cert1", "Installer", "DEVELOPER_ID_INSTALLER", "MAC_OS", DateTime.UtcNow.AddYears(1), "00FEDCBA")
});
_mockLocalCertificates.SetupGet(s => s.IsSupported).Returns(true);
_mockLocalCertificates.Setup(s => s.GetSigningIdentitiesAsync())
.ReturnsAsync(new List<LocalSigningIdentity>
{
Identity("Developer ID Installer: Allan Ritchie (T9KLD6CCM9)", "fedcba")
});

var result = await _handler.Handle(
new GetCertificatesRequest("identity1"),
_mockContext.Object,
CancellationToken.None);

result.Should().ContainSingle();
result.Single().IsLocalOnly.Should().BeFalse();
}

[Fact]
public async Task Handle_StillReturnsApiCertificates_WhenTheKeychainCannotBeRead()
{
_mockAppleService.Setup(s => s.GetCertificatesAsync())
.ReturnsAsync(new List<AppleCertificate>
{
new("cert1", "Development", "IOS_DEVELOPMENT", "IOS", DateTime.UtcNow.AddYears(1), "ABC123")
});
_mockLocalCertificates.SetupGet(s => s.IsSupported).Returns(true);
_mockLocalCertificates.Setup(s => s.GetSigningIdentitiesAsync())
.ThrowsAsync(new InvalidOperationException("keychain locked"));

var result = await _handler.Handle(
new GetCertificatesRequest("identity1"),
_mockContext.Object,
CancellationToken.None);

result.Should().ContainSingle();
}

[Fact]
Expand Down
Loading