diff --git a/src/MauiSherpa.Core/Interfaces.cs b/src/MauiSherpa.Core/Interfaces.cs index 4bfef9a4..b4ba2794 100644 --- a/src/MauiSherpa.Core/Interfaces.cs +++ b/src/MauiSherpa.Core/Interfaces.cs @@ -41,7 +41,7 @@ public interface IDialogService Task ShowLoadingAsync(string message); Task HideLoadingAsync(); Task ShowInputDialogAsync(string title, string message, string placeholder = ""); - Task ShowFileDialogAsync(string title, bool isSave = false, string[]? filters = null); + Task ShowFileDialogAsync(string title, bool isSave = false, string[]? filters = null, string? defaultFileName = null); Task PickFolderAsync(string title); Task CopyToClipboardAsync(string text); } @@ -322,6 +322,130 @@ public interface IAppleRootCertService IReadOnlyDictionary? GetCachedCerts(); } +// ============================================================================ +// Local Signing Identities - Keychain Certificate Management +// ============================================================================ + +/// +/// A signing identity from the local macOS keychain that includes the private key +/// +public record LocalSigningIdentity( + string Identity, // Full identity string (e.g., "Apple Development: Name (TEAM)") + string CommonName, // Certificate common name + string? TeamId, // Team ID extracted from identity + string? SerialNumber, // For matching with API certificates + DateTime? ExpirationDate, + bool IsValid, // Valid according to security tool + string? Hash = null // SHA-1 hash from keychain (for looking up details) +); + +/// +/// Service for managing local signing identities in the macOS keychain +/// +public interface ILocalCertificateService +{ + /// + /// Gets all valid code signing identities from the local keychain + /// + Task> GetSigningIdentitiesAsync(); + + /// + /// Checks if a certificate with the given serial number has a private key locally + /// + Task HasPrivateKeyAsync(string serialNumber); + + /// + /// Exports a signing identity as a P12/PFX file + /// + /// The full identity string + /// Password to protect the P12 file + /// P12 file contents + Task ExportP12Async(string identity, string password); + + /// + /// Exports a certificate (public key only) as a .cer file + /// + /// The certificate serial number + /// DER-encoded certificate data + Task ExportCertificateAsync(string serialNumber); + + /// + /// Deletes a certificate and its private key from the local keychain + /// + /// The identity string or serial number + Task DeleteCertificateAsync(string identity); + + /// + /// Invalidates the cached list of signing identities, forcing a refresh on next query + /// + void InvalidateCache(); + + /// + /// Gets whether this service is supported on the current platform + /// + bool IsSupported { get; } +} + +// ============================================================================ +// CI Secrets Wizard Models +// ============================================================================ + +/// +/// Platform selection for CI secrets wizard +/// +public enum ApplePlatformType +{ + iOS, + MacCatalyst, + macOS +} + +/// +/// Distribution type for CI secrets wizard +/// +public enum AppleDistributionType +{ + Development, + AdHoc, // iOS only + AppStore, + Direct // Mac Catalyst / macOS only (Developer ID) +} + +/// +/// State for the CI secrets wizard +/// +public record CISecretsWizardState +{ + public ApplePlatformType Platform { get; init; } + public AppleDistributionType Distribution { get; init; } + public bool NeedsInstallerCert { get; init; } + + // Selected resources + public AppleBundleId? SelectedBundleId { get; init; } + public AppleCertificate? SigningCertificate { get; init; } + public AppleCertificate? InstallerCertificate { get; init; } + public AppleProfile? ProvisioningProfile { get; init; } + + // Local signing identity (with private key) + public LocalSigningIdentity? LocalSigningIdentity { get; init; } + public LocalSigningIdentity? LocalInstallerIdentity { get; init; } + + // Notarization (for Direct Distribution) + public string? NotarizationAppleId { get; init; } + public string? NotarizationPassword { get; init; } + public string? NotarizationTeamId { get; init; } +} + +/// +/// A secret to be exported for CI configuration +/// +public record CISecretExport( + string Name, // Recommended secret name (e.g., "APPLE_CERTIFICATE_P12") + string Value, // The actual secret value (base64 encoded, etc.) + string Description, // Human-readable description + bool IsSensitive // Whether to mask in UI +); + // ============================================================================ // MAUI Doctor Service - SDK/Workload Health Checking // ============================================================================ @@ -1251,3 +1375,298 @@ public interface ISplashService /// bool IsBlazorReady { get; } } + +// ============================================================================ +// Cloud Secrets Storage - Abstractions for secure cloud-based secrets +// ============================================================================ + +/// +/// Type of cloud secrets provider +/// +public enum CloudSecretsProviderType +{ + None, + AzureKeyVault, + AwsSecretsManager, + GoogleSecretManager, + Infisical +} + +/// +/// Configuration for a cloud secrets provider instance +/// +public record CloudSecretsProviderConfig( + string Id, + string Name, + CloudSecretsProviderType ProviderType, + Dictionary Settings +) +{ + /// + /// Creates a new config with a generated ID + /// + public static CloudSecretsProviderConfig Create( + string name, + CloudSecretsProviderType providerType, + Dictionary settings) => + new(Guid.NewGuid().ToString("N"), name, providerType, settings); +} + +/// +/// Where a secret/certificate private key is stored +/// +public enum SecretLocation +{ + /// Not found anywhere - cannot be used for signing + None, + /// Only in local keychain - can sign but not synced + LocalOnly, + /// Only in cloud storage - can be installed locally + CloudOnly, + /// Synced - exists in both local keychain and cloud + Both +} + +/// +/// Information about a certificate's secret (private key) storage status +/// +public record CertificateSecretInfo( + string CertificateId, + string SerialNumber, + SecretLocation Location, + string? CloudProviderId, + string? CloudSecretId, + DateTime? LastSyncedUtc +); + +/// +/// Metadata stored alongside a certificate's private key in the cloud +/// +public record CertificateSecretMetadata( + string CertificateId, + string SerialNumber, + string CommonName, + string CertificateType, + DateTime ExpirationDate, + string CreatedByMachine, + DateTime CreatedAt +); + +/// +/// Information about provider configuration requirements +/// +public record CloudProviderSettingInfo( + string Key, + string DisplayName, + string Description, + bool IsRequired, + bool IsSecret, + string? DefaultValue = null, + string? Placeholder = null +); + +/// +/// Abstract interface for cloud secrets providers (Azure, AWS, Google, Infisical, etc.) +/// +public interface ICloudSecretsProvider +{ + /// + /// The type of this provider + /// + CloudSecretsProviderType ProviderType { get; } + + /// + /// Human-readable display name + /// + string DisplayName { get; } + + /// + /// Tests the connection to the cloud provider + /// + Task TestConnectionAsync(CancellationToken cancellationToken = default); + + /// + /// Stores a secret value + /// + /// The secret key/name + /// The secret value as bytes + /// Optional metadata to store with the secret + Task StoreSecretAsync(string key, byte[] value, Dictionary? metadata = null, CancellationToken cancellationToken = default); + + /// + /// Retrieves a secret value + /// + /// The secret key/name + /// The secret value, or null if not found + Task GetSecretAsync(string key, CancellationToken cancellationToken = default); + + /// + /// Deletes a secret + /// + /// The secret key/name + Task DeleteSecretAsync(string key, CancellationToken cancellationToken = default); + + /// + /// Checks if a secret exists + /// + /// The secret key/name + Task SecretExistsAsync(string key, CancellationToken cancellationToken = default); + + /// + /// Lists all secrets with an optional prefix filter + /// + /// Optional prefix to filter secrets + Task> ListSecretsAsync(string? prefix = null, CancellationToken cancellationToken = default); +} + +/// +/// Factory for creating cloud secrets provider instances +/// +public interface ICloudSecretsProviderFactory +{ + /// + /// Creates a provider instance from configuration + /// + ICloudSecretsProvider CreateProvider(CloudSecretsProviderConfig config); + + /// + /// Gets the list of supported provider types + /// + IReadOnlyList SupportedProviders { get; } + + /// + /// Gets the required and optional settings for a provider type + /// + IReadOnlyList GetProviderSettings(CloudSecretsProviderType providerType); + + /// + /// Gets the display name for a provider type + /// + string GetProviderDisplayName(CloudSecretsProviderType providerType); +} + +/// +/// Service for managing cloud secrets storage providers and operations +/// +public interface ICloudSecretsService +{ + // Provider management + + /// + /// Gets all configured cloud secrets providers + /// + Task> GetProvidersAsync(); + + /// + /// Saves (adds or updates) a provider configuration + /// + Task SaveProviderAsync(CloudSecretsProviderConfig provider); + + /// + /// Deletes a provider configuration + /// + Task DeleteProviderAsync(string providerId); + + /// + /// Tests a provider's connection + /// + Task TestProviderConnectionAsync(string providerId); + + // Active provider + + /// + /// Gets the currently active provider (if any) + /// + CloudSecretsProviderConfig? ActiveProvider { get; } + + /// + /// Sets the active provider by ID (null to clear) + /// + Task SetActiveProviderAsync(string? providerId); + + /// + /// Event fired when the active provider changes + /// + event Action? OnActiveProviderChanged; + + // Secret operations (uses active provider) + + /// + /// Stores a secret using the active provider + /// + Task StoreSecretAsync(string key, byte[] value, Dictionary? metadata = null, CancellationToken cancellationToken = default); + + /// + /// Retrieves a secret from the active provider + /// + Task GetSecretAsync(string key, CancellationToken cancellationToken = default); + + /// + /// Deletes a secret from the active provider + /// + Task DeleteSecretAsync(string key, CancellationToken cancellationToken = default); + + /// + /// Checks if a secret exists in the active provider + /// + Task SecretExistsAsync(string key, CancellationToken cancellationToken = default); + + /// + /// Lists secrets from the active provider + /// + Task> ListSecretsAsync(string? prefix = null, CancellationToken cancellationToken = default); +} + +/// +/// Service for syncing certificate private keys between local keychain and cloud storage +/// +public interface ICertificateSyncService +{ + /// + /// Gets the storage status for a list of certificates + /// + Task> GetCertificateStatusesAsync( + IEnumerable certificates, + CancellationToken cancellationToken = default); + + /// + /// Uploads a certificate's private key to cloud storage + /// + /// The certificate to upload + /// The P12/PFX data containing the private key + /// Password protecting the P12 + /// Optional metadata about the certificate + Task UploadToCloudAsync( + AppleCertificate certificate, + byte[] p12Data, + string password, + CertificateSecretMetadata? metadata = null, + CancellationToken cancellationToken = default); + + /// + /// Downloads a certificate's private key from cloud storage and installs locally + /// + /// The certificate ID to download + Task DownloadAndInstallAsync(string certificateId, CancellationToken cancellationToken = default); + + /// + /// Gets the cloud secret key for a certificate + /// + string GetCertificateSecretKey(string serialNumber); + + /// + /// Gets the password secret key for a certificate + /// + string GetCertificatePasswordKey(string serialNumber); + + /// + /// Gets the metadata secret key for a certificate + /// + string GetCertificateMetadataKey(string serialNumber); + + /// + /// Deletes a certificate's private key from cloud storage + /// + /// The serial number of the certificate to delete + Task DeleteFromCloudAsync(string serialNumber, CancellationToken cancellationToken = default); +} diff --git a/src/MauiSherpa.Core/MauiSherpa.Core.csproj b/src/MauiSherpa.Core/MauiSherpa.Core.csproj index 0d95c8fc..2d36a481 100644 --- a/src/MauiSherpa.Core/MauiSherpa.Core.csproj +++ b/src/MauiSherpa.Core/MauiSherpa.Core.csproj @@ -24,6 +24,11 @@ + + + + + diff --git a/src/MauiSherpa.Core/Services/AppleConnectService.cs b/src/MauiSherpa.Core/Services/AppleConnectService.cs index 12bfc059..f372d1ed 100644 --- a/src/MauiSherpa.Core/Services/AppleConnectService.cs +++ b/src/MauiSherpa.Core/Services/AppleConnectService.cs @@ -266,14 +266,14 @@ public async Task> GetCertificatesAsync() c.Id, c.Attributes?.DisplayName ?? c.Attributes?.Name ?? "", c.Attributes?.CertificateType.ToString() ?? "DEVELOPMENT", - c.Attributes?.Platform.ToString() ?? "IOS", + c.Attributes?.Platform.ToString() ?? "", DateTime.UtcNow.AddYears(1), // CertificateAttributes doesn't have ExpirationDate directly c.Attributes?.SerialNumber ?? "")) .ToList(); foreach (var cert in certs) { - _logger.LogInformation($" Cert: {cert.Id} - {cert.Name} - Type: {cert.CertificateType}"); + _logger.LogInformation($" Cert: {cert.Id} - {cert.Name} - Type: {cert.CertificateType} - Platform: '{cert.Platform}'"); } return certs; diff --git a/src/MauiSherpa.Core/Services/AwsSecretsManagerProvider.cs b/src/MauiSherpa.Core/Services/AwsSecretsManagerProvider.cs new file mode 100644 index 00000000..51c7d1ec --- /dev/null +++ b/src/MauiSherpa.Core/Services/AwsSecretsManagerProvider.cs @@ -0,0 +1,332 @@ +using System.Text; +using Amazon; +using Amazon.Runtime; +using Amazon.SecretsManager; +using Amazon.SecretsManager.Model; +using MauiSherpa.Core.Interfaces; + +namespace MauiSherpa.Core.Services; + +/// +/// Cloud secrets provider implementation for AWS Secrets Manager +/// Uses the official AWSSDK.SecretsManager SDK +/// +public class AwsSecretsManagerProvider : ICloudSecretsProvider +{ + private readonly CloudSecretsProviderConfig _config; + private readonly ILoggingService _logger; + private AmazonSecretsManagerClient? _client; + + public AwsSecretsManagerProvider(CloudSecretsProviderConfig config, ILoggingService logger) + { + _config = config; + _logger = logger; + } + + public CloudSecretsProviderType ProviderType => CloudSecretsProviderType.AwsSecretsManager; + public string DisplayName => "AWS Secrets Manager"; + + #region Configuration Helpers + + private string Region => _config.Settings.GetValueOrDefault("Region", "us-east-1"); + private string AccessKeyId => _config.Settings.GetValueOrDefault("AccessKeyId", ""); + private string SecretAccessKey => _config.Settings.GetValueOrDefault("SecretAccessKey", ""); + private string SecretPrefix => _config.Settings.GetValueOrDefault("SecretPrefix", ""); + + #endregion + + #region Client Initialization + + private AmazonSecretsManagerClient GetClient() + { + if (_client != null) + return _client; + + var credentials = new BasicAWSCredentials(AccessKeyId, SecretAccessKey); + var config = new AmazonSecretsManagerConfig + { + RegionEndpoint = RegionEndpoint.GetBySystemName(Region) + }; + + _client = new AmazonSecretsManagerClient(credentials, config); + return _client; + } + + #endregion + + #region ICloudSecretsProvider Implementation + + public async Task TestConnectionAsync(CancellationToken cancellationToken = default) + { + try + { + var client = GetClient(); + + // Try to list secrets to verify credentials + await client.ListSecretsAsync(new ListSecretsRequest { MaxResults = 1 }, cancellationToken); + + _logger.LogInformation($"AWS Secrets Manager connection test successful for region {Region}"); + return true; + } + catch (AmazonSecretsManagerException ex) + { + _logger.LogError($"AWS Secrets Manager connection test failed: {ex.StatusCode} - {ex.Message}", ex); + return false; + } + catch (Exception ex) + { + _logger.LogError($"AWS Secrets Manager connection test error: {ex.Message}", ex); + return false; + } + } + + public async Task StoreSecretAsync(string key, byte[] value, Dictionary? metadata = null, CancellationToken cancellationToken = default) + { + try + { + var client = GetClient(); + var secretName = GetSecretName(key); + + // First check if the secret exists + if (await SecretExistsInternalAsync(secretName, cancellationToken)) + { + // Update existing secret + var updateRequest = new PutSecretValueRequest + { + SecretId = secretName, + SecretBinary = new MemoryStream(value) + }; + + await client.PutSecretValueAsync(updateRequest, cancellationToken); + } + else + { + // Create new secret + var createRequest = new CreateSecretRequest + { + Name = secretName, + SecretBinary = new MemoryStream(value) + }; + + // Add tags if metadata provided + if (metadata != null && metadata.Count > 0) + { + createRequest.Tags = metadata.Select(kv => new Tag { Key = kv.Key, Value = kv.Value }).ToList(); + } + + await client.CreateSecretAsync(createRequest, cancellationToken); + } + + _logger.LogInformation($"Stored secret: {key}"); + return true; + } + catch (AmazonSecretsManagerException ex) + { + _logger.LogError($"AWS Secrets Manager store secret failed: {ex.StatusCode} - {ex.Message}", ex); + return false; + } + catch (Exception ex) + { + _logger.LogError($"AWS Secrets Manager store secret error: {ex.Message}", ex); + return false; + } + } + + public async Task GetSecretAsync(string key, CancellationToken cancellationToken = default) + { + try + { + var client = GetClient(); + var secretName = GetSecretName(key); + + var request = new GetSecretValueRequest { SecretId = secretName }; + var response = await client.GetSecretValueAsync(request, cancellationToken); + + if (response.SecretBinary != null) + { + using var ms = new MemoryStream(); + await response.SecretBinary.CopyToAsync(ms, cancellationToken); + return ms.ToArray(); + } + + // If stored as string, return as UTF8 bytes + if (response.SecretString != null) + { + return Encoding.UTF8.GetBytes(response.SecretString); + } + + return null; + } + catch (ResourceNotFoundException) + { + return null; + } + catch (AmazonSecretsManagerException ex) + { + _logger.LogError($"AWS Secrets Manager get secret failed: {ex.StatusCode} - {ex.Message}", ex); + return null; + } + catch (Exception ex) + { + _logger.LogError($"AWS Secrets Manager get secret error: {ex.Message}", ex); + return null; + } + } + + public async Task DeleteSecretAsync(string key, CancellationToken cancellationToken = default) + { + try + { + var client = GetClient(); + var secretName = GetSecretName(key); + + var request = new DeleteSecretRequest + { + SecretId = secretName, + ForceDeleteWithoutRecovery = true + }; + + await client.DeleteSecretAsync(request, cancellationToken); + + _logger.LogInformation($"Deleted secret: {key}"); + return true; + } + catch (ResourceNotFoundException) + { + _logger.LogInformation($"Secret already deleted or not found: {key}"); + return true; + } + catch (AmazonSecretsManagerException ex) + { + _logger.LogError($"AWS Secrets Manager delete secret failed: {ex.StatusCode} - {ex.Message}", ex); + return false; + } + catch (Exception ex) + { + _logger.LogError($"AWS Secrets Manager delete secret error: {ex.Message}", ex); + return false; + } + } + + public async Task SecretExistsAsync(string key, CancellationToken cancellationToken = default) + { + try + { + var secretName = GetSecretName(key); + return await SecretExistsInternalAsync(secretName, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError($"AWS Secrets Manager secret exists check error: {ex.Message}", ex); + return false; + } + } + + private async Task SecretExistsInternalAsync(string secretName, CancellationToken cancellationToken) + { + try + { + var client = GetClient(); + await client.DescribeSecretAsync(new DescribeSecretRequest { SecretId = secretName }, cancellationToken); + return true; + } + catch (ResourceNotFoundException) + { + return false; + } + } + + public async Task> ListSecretsAsync(string? prefix = null, CancellationToken cancellationToken = default) + { + try + { + var client = GetClient(); + var allSecrets = new List(); + var effectivePrefix = GetSecretName(prefix ?? ""); + string? nextToken = null; + + do + { + var request = new ListSecretsRequest + { + MaxResults = 100, + NextToken = nextToken + }; + + var response = await client.ListSecretsAsync(request, cancellationToken); + + foreach (var secret in response.SecretList) + { + if (string.IsNullOrEmpty(secret.Name)) + continue; + + // Filter by prefix if specified + if (!string.IsNullOrEmpty(effectivePrefix) && !secret.Name.StartsWith(effectivePrefix, StringComparison.OrdinalIgnoreCase)) + continue; + + // Remove our prefix to get the original key + var originalKey = RemoveSecretPrefix(secret.Name); + allSecrets.Add(originalKey); + } + + nextToken = response.NextToken; + } while (!string.IsNullOrEmpty(nextToken)); + + return allSecrets.AsReadOnly(); + } + catch (Exception ex) + { + _logger.LogError($"AWS Secrets Manager list secrets error: {ex.Message}", ex); + return Array.Empty(); + } + } + + #endregion + + #region Private Helpers + + private string GetSecretName(string key) + { + if (string.IsNullOrEmpty(SecretPrefix)) + return SanitizeSecretName(key); + + return SanitizeSecretName($"{SecretPrefix}/{key}"); + } + + private string RemoveSecretPrefix(string secretName) + { + if (string.IsNullOrEmpty(SecretPrefix)) + return secretName; + + var prefix = SanitizeSecretName($"{SecretPrefix}/"); + if (secretName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + return secretName[prefix.Length..]; + + return secretName; + } + + /// + /// Sanitize secret name for AWS Secrets Manager + /// + private static string SanitizeSecretName(string name) + { + var sanitized = new StringBuilder(); + foreach (var c in name) + { + if (char.IsLetterOrDigit(c) || c == '/' || c == '+' || c == '=' || c == '.' || c == '_' || c == '-') + sanitized.Append(c); + else + sanitized.Append('-'); + } + + var result = sanitized.ToString(); + + // Secret names must be 1-512 characters + if (result.Length > 512) + result = result[..512]; + + return result; + } + + #endregion +} diff --git a/src/MauiSherpa.Core/Services/AzureKeyVaultProvider.cs b/src/MauiSherpa.Core/Services/AzureKeyVaultProvider.cs new file mode 100644 index 00000000..f2f92d0f --- /dev/null +++ b/src/MauiSherpa.Core/Services/AzureKeyVaultProvider.cs @@ -0,0 +1,271 @@ +using System.Text; +using Azure; +using Azure.Identity; +using Azure.Security.KeyVault.Secrets; +using MauiSherpa.Core.Interfaces; + +namespace MauiSherpa.Core.Services; + +/// +/// Cloud secrets provider implementation for Azure Key Vault +/// Uses the official Azure.Security.KeyVault.Secrets SDK +/// +public class AzureKeyVaultProvider : ICloudSecretsProvider +{ + private readonly CloudSecretsProviderConfig _config; + private readonly ILoggingService _logger; + private SecretClient? _client; + + public AzureKeyVaultProvider(CloudSecretsProviderConfig config, ILoggingService logger) + { + _config = config; + _logger = logger; + } + + public CloudSecretsProviderType ProviderType => CloudSecretsProviderType.AzureKeyVault; + public string DisplayName => "Azure Key Vault"; + + #region Configuration Helpers + + private string VaultUrl => _config.Settings.GetValueOrDefault("VaultUrl", "").TrimEnd('/'); + private string TenantId => _config.Settings.GetValueOrDefault("TenantId", ""); + private string ClientId => _config.Settings.GetValueOrDefault("ClientId", ""); + private string ClientSecretValue => _config.Settings.GetValueOrDefault("ClientSecret", ""); + + #endregion + + #region Client Initialization + + private SecretClient GetClient() + { + if (_client != null) + return _client; + + var credential = new ClientSecretCredential(TenantId, ClientId, ClientSecretValue); + _client = new SecretClient(new Uri(VaultUrl), credential); + return _client; + } + + #endregion + + #region ICloudSecretsProvider Implementation + + public async Task TestConnectionAsync(CancellationToken cancellationToken = default) + { + try + { + var client = GetClient(); + + // Try to list secrets (limited) to verify access + await foreach (var _ in client.GetPropertiesOfSecretsAsync(cancellationToken).AsPages(pageSizeHint: 1)) + { + break; // Just need to verify we can access + } + + _logger.LogInformation($"Azure Key Vault connection test successful for {VaultUrl}"); + return true; + } + catch (AuthenticationFailedException ex) + { + _logger.LogError($"Azure Key Vault authentication failed: {ex.Message}", ex); + return false; + } + catch (RequestFailedException ex) + { + _logger.LogError($"Azure Key Vault connection test failed: {ex.Status} - {ex.Message}", ex); + return false; + } + catch (Exception ex) + { + _logger.LogError($"Azure Key Vault connection test error: {ex.Message}", ex); + return false; + } + } + + public async Task StoreSecretAsync(string key, byte[] value, Dictionary? metadata = null, CancellationToken cancellationToken = default) + { + try + { + var client = GetClient(); + var sanitizedKey = SanitizeKey(key); + + // Azure Key Vault stores strings, so we base64 encode the binary data + var base64Value = Convert.ToBase64String(value); + + var secret = new KeyVaultSecret(sanitizedKey, base64Value); + + // Add metadata as tags + if (metadata != null) + { + foreach (var kvp in metadata) + { + secret.Properties.Tags[kvp.Key] = kvp.Value; + } + } + + await client.SetSecretAsync(secret, cancellationToken); + + _logger.LogInformation($"Stored secret: {key}"); + return true; + } + catch (RequestFailedException ex) + { + _logger.LogError($"Azure Key Vault store secret failed: {ex.Status} - {ex.Message}", ex); + return false; + } + catch (Exception ex) + { + _logger.LogError($"Azure Key Vault store secret error: {ex.Message}", ex); + return false; + } + } + + public async Task GetSecretAsync(string key, CancellationToken cancellationToken = default) + { + try + { + var client = GetClient(); + var sanitizedKey = SanitizeKey(key); + + var response = await client.GetSecretAsync(sanitizedKey, cancellationToken: cancellationToken); + + if (response?.Value?.Value == null) + return null; + + // Decode from base64 + return Convert.FromBase64String(response.Value.Value); + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return null; + } + catch (FormatException ex) + { + _logger.LogError($"Azure Key Vault secret not base64 encoded: {key} - {ex.Message}"); + return null; + } + catch (Exception ex) + { + _logger.LogError($"Azure Key Vault get secret error: {ex.Message}", ex); + return null; + } + } + + public async Task DeleteSecretAsync(string key, CancellationToken cancellationToken = default) + { + try + { + var client = GetClient(); + var sanitizedKey = SanitizeKey(key); + + var operation = await client.StartDeleteSecretAsync(sanitizedKey, cancellationToken); + + // Wait for deletion to complete + await operation.WaitForCompletionAsync(cancellationToken); + + _logger.LogInformation($"Deleted secret: {key}"); + return true; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + _logger.LogInformation($"Secret already deleted or not found: {key}"); + return true; + } + catch (RequestFailedException ex) + { + _logger.LogError($"Azure Key Vault delete secret failed: {ex.Status} - {ex.Message}", ex); + return false; + } + catch (Exception ex) + { + _logger.LogError($"Azure Key Vault delete secret error: {ex.Message}", ex); + return false; + } + } + + public async Task SecretExistsAsync(string key, CancellationToken cancellationToken = default) + { + try + { + var client = GetClient(); + var sanitizedKey = SanitizeKey(key); + + await client.GetSecretAsync(sanitizedKey, cancellationToken: cancellationToken); + return true; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return false; + } + catch (Exception ex) + { + _logger.LogError($"Azure Key Vault secret exists check error: {ex.Message}", ex); + return false; + } + } + + public async Task> ListSecretsAsync(string? prefix = null, CancellationToken cancellationToken = default) + { + try + { + var client = GetClient(); + var allSecrets = new List(); + + await foreach (var secretProperties in client.GetPropertiesOfSecretsAsync(cancellationToken)) + { + var secretName = secretProperties.Name; + + // Convert back from sanitized format (hyphens back to underscores for prefix matching) + var originalKey = secretName.Replace("-", "_").ToUpperInvariant(); + + if (string.IsNullOrEmpty(prefix) || originalKey.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + allSecrets.Add(originalKey); + } + } + + return allSecrets.AsReadOnly(); + } + catch (Exception ex) + { + _logger.LogError($"Azure Key Vault list secrets error: {ex.Message}", ex); + return Array.Empty(); + } + } + + #endregion + + #region Private Helpers + + /// + /// Sanitize key for Azure Key Vault (must be alphanumeric with hyphens, 1-127 chars) + /// + private static string SanitizeKey(string key) + { + var sanitized = new StringBuilder(); + foreach (var c in key) + { + if (char.IsLetterOrDigit(c)) + sanitized.Append(c); + else + sanitized.Append('-'); + } + + // Azure Key Vault secret names must start with a letter + var result = sanitized.ToString(); + if (result.Length > 0 && !char.IsLetter(result[0])) + { + result = "S" + result; + } + + // Limit to 127 characters + if (result.Length > 127) + { + result = result[..127]; + } + + return result; + } + + #endregion +} diff --git a/src/MauiSherpa.Core/Services/CertificateSyncService.cs b/src/MauiSherpa.Core/Services/CertificateSyncService.cs new file mode 100644 index 00000000..1df12c05 --- /dev/null +++ b/src/MauiSherpa.Core/Services/CertificateSyncService.cs @@ -0,0 +1,399 @@ +using System.Text; +using System.Text.Json; +using MauiSherpa.Core.Interfaces; + +namespace MauiSherpa.Core.Services; + +/// +/// Service for syncing certificate private keys between local keychain and cloud storage +/// +public class CertificateSyncService : ICertificateSyncService +{ + private readonly ICloudSecretsService _cloudSecretsService; + private readonly ILocalCertificateService _localCertificateService; + private readonly ILoggingService _logger; + + private const string SecretPrefix = "CERT"; + + public CertificateSyncService( + ICloudSecretsService cloudSecretsService, + ILocalCertificateService localCertificateService, + ILoggingService logger) + { + _cloudSecretsService = cloudSecretsService; + _localCertificateService = localCertificateService; + _logger = logger; + } + + public string GetCertificateSecretKey(string serialNumber) + => $"{SecretPrefix}_{SanitizeSerialNumber(serialNumber)}_P12"; + + public string GetCertificatePasswordKey(string serialNumber) + => $"{SecretPrefix}_{SanitizeSerialNumber(serialNumber)}_PWD"; + + public string GetCertificateMetadataKey(string serialNumber) + => $"{SecretPrefix}_{SanitizeSerialNumber(serialNumber)}_META"; + + public async Task> GetCertificateStatusesAsync( + IEnumerable certificates, + CancellationToken cancellationToken = default) + { + var results = new List(); + + // Get all local signing identities + // Note: security find-identity only returns identities with private keys, + // so all returned identities have private keys by definition + var localIdentities = _localCertificateService.IsSupported + ? await _localCertificateService.GetSigningIdentitiesAsync() + : Array.Empty(); + + var localSerials = localIdentities + .Where(i => i.IsValid) // Only consider valid certificates + .Select(i => SanitizeSerialNumber(i.SerialNumber ?? "")) // Sanitize local serials too! + .Where(s => !string.IsNullOrEmpty(s)) + .ToHashSet(); + + // Check cloud storage if a provider is configured + var cloudSecrets = new HashSet(); + if (_cloudSecretsService.ActiveProvider != null) + { + try + { + var secrets = await _cloudSecretsService.ListSecretsAsync($"{SecretPrefix}_", cancellationToken); + foreach (var secret in secrets) + { + // Extract serial number from key like "CERT_XXXX_P12" + var parts = secret.Split('_'); + if (parts.Length >= 2) + { + cloudSecrets.Add(parts[1].ToUpperInvariant()); + } + } + } + catch (Exception ex) + { + _logger.LogWarning($"Could not check cloud storage: {ex.Message}"); + } + } + + foreach (var cert in certificates) + { + var serialUpper = cert.SerialNumber?.ToUpperInvariant() ?? ""; + var sanitizedSerial = SanitizeSerialNumber(serialUpper); + + var hasLocal = localSerials.Contains(sanitizedSerial); + var hasCloud = cloudSecrets.Contains(sanitizedSerial); + + var location = (hasLocal, hasCloud) switch + { + (true, true) => SecretLocation.Both, + (true, false) => SecretLocation.LocalOnly, + (false, true) => SecretLocation.CloudOnly, + _ => SecretLocation.None + }; + + results.Add(new CertificateSecretInfo( + cert.Id ?? "", + cert.SerialNumber ?? "", + location, + hasCloud ? _cloudSecretsService.ActiveProvider?.Id : null, + hasCloud ? GetCertificateSecretKey(cert.SerialNumber ?? "") : null, + null // Would need to track last sync time separately + )); + } + + return results.AsReadOnly(); + } + + public async Task UploadToCloudAsync( + AppleCertificate certificate, + byte[] p12Data, + string password, + CertificateSecretMetadata? metadata = null, + CancellationToken cancellationToken = default) + { + if (_cloudSecretsService.ActiveProvider == null) + { + _logger.LogWarning("No active cloud secrets provider configured"); + return false; + } + + var serialNumber = certificate.SerialNumber ?? ""; + + try + { + // Store the P12 data + var p12Key = GetCertificateSecretKey(serialNumber); + if (!await _cloudSecretsService.StoreSecretAsync(p12Key, p12Data, null, cancellationToken)) + { + _logger.LogError($"Failed to store P12 data for certificate {serialNumber}"); + return false; + } + + // Store the password + var pwdKey = GetCertificatePasswordKey(serialNumber); + var pwdBytes = Encoding.UTF8.GetBytes(password); + if (!await _cloudSecretsService.StoreSecretAsync(pwdKey, pwdBytes, null, cancellationToken)) + { + _logger.LogError($"Failed to store password for certificate {serialNumber}"); + // Clean up the P12 we just stored + await _cloudSecretsService.DeleteSecretAsync(p12Key, cancellationToken); + return false; + } + + // Store metadata if provided + if (metadata != null) + { + var metaKey = GetCertificateMetadataKey(serialNumber); + var metaJson = JsonSerializer.Serialize(metadata); + var metaBytes = Encoding.UTF8.GetBytes(metaJson); + await _cloudSecretsService.StoreSecretAsync(metaKey, metaBytes, null, cancellationToken); + } + + _logger.LogInformation($"Uploaded certificate {serialNumber} to cloud storage"); + return true; + } + catch (Exception ex) + { + _logger.LogError($"Failed to upload certificate to cloud: {ex.Message}", ex); + return false; + } + } + + public async Task DownloadAndInstallAsync(string certificateId, CancellationToken cancellationToken = default) + { + if (_cloudSecretsService.ActiveProvider == null) + { + _logger.LogWarning("No active cloud secrets provider configured"); + return false; + } + + // We need to find the serial number from certificate ID + // For now, we'll need this to be passed differently or stored in metadata + _logger.LogWarning("DownloadAndInstallAsync not yet implemented - needs serial number lookup"); + return false; + + // TODO: Implement full flow: + // 1. Get P12 data from cloud + // 2. Get password from cloud + // 3. Import into local keychain using security import command + } + + /// + /// Downloads and installs a certificate from cloud using its serial number + /// + public async Task DownloadAndInstallBySerialAsync( + string serialNumber, + CancellationToken cancellationToken = default) + { + if (_cloudSecretsService.ActiveProvider == null) + { + _logger.LogWarning("No active cloud secrets provider configured"); + return false; + } + + try + { + Console.WriteLine($"DownloadAndInstallBySerialAsync starting for serial: {serialNumber}"); + _logger.LogInformation($"DownloadAndInstallBySerialAsync starting for serial: {serialNumber}"); + + // Get P12 data + var p12Key = GetCertificateSecretKey(serialNumber); + Console.WriteLine($"Looking for P12 with key: {p12Key}"); + _logger.LogInformation($"Looking for P12 with key: {p12Key}"); + var p12Data = await _cloudSecretsService.GetSecretAsync(p12Key, cancellationToken); + if (p12Data == null) + { + Console.WriteLine($"P12 data not found in cloud for serial {serialNumber} (key: {p12Key})"); + _logger.LogError($"P12 data not found in cloud for serial {serialNumber} (key: {p12Key})"); + return false; + } + Console.WriteLine($"Got P12 data: {p12Data.Length} bytes"); + _logger.LogInformation($"Got P12 data: {p12Data.Length} bytes"); + + // Get password + var pwdKey = GetCertificatePasswordKey(serialNumber); + Console.WriteLine($"Looking for password with key: {pwdKey}"); + _logger.LogInformation($"Looking for password with key: {pwdKey}"); + var pwdData = await _cloudSecretsService.GetSecretAsync(pwdKey, cancellationToken); + if (pwdData == null) + { + Console.WriteLine($"Password not found in cloud for serial {serialNumber} (key: {pwdKey})"); + _logger.LogError($"Password not found in cloud for serial {serialNumber} (key: {pwdKey})"); + return false; + } + var password = Encoding.UTF8.GetString(pwdData); + Console.WriteLine($"Got password: {password.Length} chars"); + _logger.LogInformation($"Got password: {password.Length} chars"); + + // Import into local keychain + Console.WriteLine("Importing P12 to keychain..."); + _logger.LogInformation("Importing P12 to keychain..."); + return await ImportP12ToKeychainAsync(p12Data, password, cancellationToken); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to download and install certificate: {ex}"); + _logger.LogError($"Failed to download and install certificate: {ex.Message}", ex); + return false; + } + } + + /// + /// Gets certificate metadata from cloud storage + /// + public async Task GetCertificateMetadataAsync( + string serialNumber, + CancellationToken cancellationToken = default) + { + if (_cloudSecretsService.ActiveProvider == null) + return null; + + try + { + var metaKey = GetCertificateMetadataKey(serialNumber); + var metaData = await _cloudSecretsService.GetSecretAsync(metaKey, cancellationToken); + if (metaData == null) + return null; + + var metaJson = Encoding.UTF8.GetString(metaData); + return JsonSerializer.Deserialize(metaJson); + } + catch (Exception ex) + { + _logger.LogWarning($"Could not get certificate metadata: {ex.Message}"); + return null; + } + } + + public async Task DeleteFromCloudAsync(string serialNumber, CancellationToken cancellationToken = default) + { + if (_cloudSecretsService.ActiveProvider == null) + { + _logger.LogError("No cloud secrets provider configured"); + return false; + } + + _logger.LogInformation($"Deleting certificate from cloud: {serialNumber}"); + + var p12Key = GetCertificateSecretKey(serialNumber); + var pwdKey = GetCertificatePasswordKey(serialNumber); + var metaKey = GetCertificateMetadataKey(serialNumber); + + var success = true; + + // Delete P12 data + try + { + if (!await _cloudSecretsService.DeleteSecretAsync(p12Key, cancellationToken)) + { + _logger.LogWarning($"Failed to delete P12 secret: {p12Key}"); + success = false; + } + } + catch (Exception ex) + { + _logger.LogWarning($"Error deleting P12 secret: {ex.Message}"); + } + + // Delete password + try + { + if (!await _cloudSecretsService.DeleteSecretAsync(pwdKey, cancellationToken)) + { + _logger.LogWarning($"Failed to delete password secret: {pwdKey}"); + } + } + catch (Exception ex) + { + _logger.LogWarning($"Error deleting password secret: {ex.Message}"); + } + + // Delete metadata + try + { + if (!await _cloudSecretsService.DeleteSecretAsync(metaKey, cancellationToken)) + { + _logger.LogWarning($"Failed to delete metadata secret: {metaKey}"); + } + } + catch (Exception ex) + { + _logger.LogWarning($"Error deleting metadata secret: {ex.Message}"); + } + + if (success) + { + _logger.LogInformation($"Successfully deleted certificate from cloud: {serialNumber}"); + } + + return success; + } + + #region Private Helpers + + private static string SanitizeSerialNumber(string serialNumber) + { + // Remove any non-alphanumeric characters and convert to uppercase + var sb = new StringBuilder(); + foreach (var c in serialNumber) + { + if (char.IsLetterOrDigit(c)) + sb.Append(char.ToUpperInvariant(c)); + } + return sb.ToString(); + } + + private async Task ImportP12ToKeychainAsync( + byte[] p12Data, + string password, + CancellationToken cancellationToken) + { + // Write P12 to temp file + var tempFile = Path.GetTempFileName() + ".p12"; + try + { + await File.WriteAllBytesAsync(tempFile, p12Data, cancellationToken); + + // Use security command to import + var loginKeychain = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Library/Keychains/login.keychain-db"); + + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "security", + Arguments = $"import \"{tempFile}\" -k \"{loginKeychain}\" -P \"{password}\" -T /usr/bin/codesign -T /usr/bin/security", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + process.Start(); + await process.WaitForExitAsync(cancellationToken); + + var output = await process.StandardOutput.ReadToEndAsync(cancellationToken); + var error = await process.StandardError.ReadToEndAsync(cancellationToken); + + if (process.ExitCode != 0) + { + _logger.LogError($"Failed to import P12: {error}"); + return false; + } + + _logger.LogInformation("Successfully imported certificate to keychain"); + return true; + } + finally + { + // Clean up temp file + try { File.Delete(tempFile); } catch { } + } + } + + #endregion +} diff --git a/src/MauiSherpa.Core/Services/CloudSecretsProviderFactory.cs b/src/MauiSherpa.Core/Services/CloudSecretsProviderFactory.cs new file mode 100644 index 00000000..eb93d4af --- /dev/null +++ b/src/MauiSherpa.Core/Services/CloudSecretsProviderFactory.cs @@ -0,0 +1,186 @@ +using MauiSherpa.Core.Interfaces; + +namespace MauiSherpa.Core.Services; + +/// +/// Factory for creating cloud secrets provider instances +/// +public class CloudSecretsProviderFactory : ICloudSecretsProviderFactory +{ + private readonly ILoggingService _logger; + + public CloudSecretsProviderFactory(ILoggingService logger) + { + _logger = logger; + } + + public IReadOnlyList SupportedProviders => new[] + { + CloudSecretsProviderType.Infisical, + CloudSecretsProviderType.AzureKeyVault, + CloudSecretsProviderType.AwsSecretsManager, + CloudSecretsProviderType.GoogleSecretManager + }; + + public ICloudSecretsProvider CreateProvider(CloudSecretsProviderConfig config) + { + return config.ProviderType switch + { + CloudSecretsProviderType.Infisical => new InfisicalProvider(config, _logger), + CloudSecretsProviderType.AzureKeyVault => new AzureKeyVaultProvider(config, _logger), + CloudSecretsProviderType.AwsSecretsManager => new AwsSecretsManagerProvider(config, _logger), + CloudSecretsProviderType.GoogleSecretManager => new GoogleSecretManagerProvider(config, _logger), + _ => throw new NotSupportedException($"Provider type {config.ProviderType} is not supported") + }; + } + + public IReadOnlyList GetProviderSettings(CloudSecretsProviderType providerType) + { + return providerType switch + { + CloudSecretsProviderType.Infisical => GetInfisicalSettings(), + CloudSecretsProviderType.AzureKeyVault => GetAzureKeyVaultSettings(), + CloudSecretsProviderType.AwsSecretsManager => GetAwsSecretsManagerSettings(), + CloudSecretsProviderType.GoogleSecretManager => GetGoogleSecretManagerSettings(), + _ => Array.Empty() + }; + } + + public string GetProviderDisplayName(CloudSecretsProviderType providerType) + { + return providerType switch + { + CloudSecretsProviderType.Infisical => "Infisical", + CloudSecretsProviderType.AzureKeyVault => "Azure Key Vault", + CloudSecretsProviderType.AwsSecretsManager => "AWS Secrets Manager", + CloudSecretsProviderType.GoogleSecretManager => "Google Secret Manager", + CloudSecretsProviderType.None => "None", + _ => providerType.ToString() + }; + } + + private static IReadOnlyList GetInfisicalSettings() => new[] + { + new CloudProviderSettingInfo( + "SiteUrl", + "Site URL", + "The Infisical instance URL (use https://app.infisical.com for cloud)", + IsRequired: true, + IsSecret: false, + DefaultValue: "https://app.infisical.com", + Placeholder: "https://app.infisical.com"), + new CloudProviderSettingInfo( + "ClientId", + "Client ID", + "The Machine Identity Client ID", + IsRequired: true, + IsSecret: false, + Placeholder: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"), + new CloudProviderSettingInfo( + "ClientSecret", + "Client Secret", + "The Machine Identity Client Secret", + IsRequired: true, + IsSecret: true, + Placeholder: "xxxxxxxxxxxxxxxxxxxxxxxxxxxx"), + new CloudProviderSettingInfo( + "ProjectId", + "Project ID", + "The Infisical project ID to store secrets in", + IsRequired: true, + IsSecret: false, + Placeholder: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"), + new CloudProviderSettingInfo( + "Environment", + "Environment", + "The environment slug (e.g., dev, staging, prod)", + IsRequired: true, + IsSecret: false, + DefaultValue: "prod", + Placeholder: "prod"), + new CloudProviderSettingInfo( + "SecretPath", + "Secret Path", + "The folder path for secrets (leave blank for root)", + IsRequired: false, + IsSecret: false, + DefaultValue: "/maui-sherpa", + Placeholder: "/maui-sherpa") + }; + + private static IReadOnlyList GetAzureKeyVaultSettings() => new[] + { + new CloudProviderSettingInfo( + "VaultUrl", + "Vault URL", + "The Azure Key Vault URL", + IsRequired: true, + IsSecret: false, + Placeholder: "https://your-vault.vault.azure.net/"), + new CloudProviderSettingInfo( + "TenantId", + "Tenant ID", + "The Azure AD tenant ID", + IsRequired: true, + IsSecret: false, + Placeholder: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"), + new CloudProviderSettingInfo( + "ClientId", + "Client ID", + "The Azure AD application (client) ID", + IsRequired: true, + IsSecret: false, + Placeholder: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"), + new CloudProviderSettingInfo( + "ClientSecret", + "Client Secret", + "The Azure AD application client secret", + IsRequired: true, + IsSecret: true, + Placeholder: "xxxxxxxxxxxxxxxxxxxxxxxxxxxx") + }; + + private static IReadOnlyList GetAwsSecretsManagerSettings() => new[] + { + new CloudProviderSettingInfo( + "Region", + "AWS Region", + "The AWS region (e.g., us-east-1)", + IsRequired: true, + IsSecret: false, + DefaultValue: "us-east-1", + Placeholder: "us-east-1"), + new CloudProviderSettingInfo( + "AccessKeyId", + "Access Key ID", + "The AWS access key ID", + IsRequired: true, + IsSecret: false, + Placeholder: "AKIAXXXXXXXXXXXXXXXX"), + new CloudProviderSettingInfo( + "SecretAccessKey", + "Secret Access Key", + "The AWS secret access key", + IsRequired: true, + IsSecret: true, + Placeholder: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx") + }; + + private static IReadOnlyList GetGoogleSecretManagerSettings() => new[] + { + new CloudProviderSettingInfo( + "ProjectId", + "Project ID", + "The Google Cloud project ID", + IsRequired: true, + IsSecret: false, + Placeholder: "my-project-id"), + new CloudProviderSettingInfo( + "CredentialsJson", + "Service Account JSON", + "The service account credentials JSON (paste the entire JSON content)", + IsRequired: true, + IsSecret: true, + Placeholder: "{\"type\": \"service_account\", ...}") + }; +} diff --git a/src/MauiSherpa.Core/Services/CloudSecretsService.cs b/src/MauiSherpa.Core/Services/CloudSecretsService.cs new file mode 100644 index 00000000..833a74dc --- /dev/null +++ b/src/MauiSherpa.Core/Services/CloudSecretsService.cs @@ -0,0 +1,400 @@ +using System.Text.Json; +using MauiSherpa.Core.Interfaces; + +namespace MauiSherpa.Core.Services; + +/// +/// Service for managing cloud secrets storage providers and operations. +/// Stores provider configurations in secure storage, with metadata in JSON. +/// +public class CloudSecretsService : ICloudSecretsService +{ + private readonly ISecureStorageService _secureStorage; + private readonly IFileSystemService _fileSystem; + private readonly ILoggingService _logger; + private readonly ICloudSecretsProviderFactory _providerFactory; + private readonly string _settingsPath; + + private List _providerMetadata = new(); + private string? _activeProviderId; + private ICloudSecretsProvider? _activeProviderInstance; + + // Internal record for storing non-sensitive data in JSON + private record CloudSecretsProviderMetadata( + string Id, + string Name, + CloudSecretsProviderType ProviderType, + // Only store non-secret setting keys here; secrets go in secure storage + List NonSecretSettingKeys + ); + + private const string SecureKeyPrefix = "cloud_secrets_provider_"; + private const string ActiveProviderKey = "cloud_secrets_active_provider"; + + public CloudSecretsService( + ISecureStorageService secureStorage, + IFileSystemService fileSystem, + ILoggingService logger, + ICloudSecretsProviderFactory providerFactory) + { + _secureStorage = secureStorage; + _fileSystem = fileSystem; + _logger = logger; + _providerFactory = providerFactory; + _settingsPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "MauiSherpa", + "cloud-secrets-providers.json"); + } + + public CloudSecretsProviderConfig? ActiveProvider { get; private set; } + + public event Action? OnActiveProviderChanged; + + #region Provider Management + + public async Task> GetProvidersAsync() + { + await LoadMetadataAsync(); + var result = new List(); + + foreach (var meta in _providerMetadata) + { + var config = await LoadProviderConfigAsync(meta); + if (config != null) + result.Add(config); + } + + return result.AsReadOnly(); + } + + public async Task SaveProviderAsync(CloudSecretsProviderConfig provider) + { + await LoadMetadataAsync(); + + var providerSettings = _providerFactory.GetProviderSettings(provider.ProviderType); + var secretKeys = providerSettings.Where(s => s.IsSecret).Select(s => s.Key).ToHashSet(); + var nonSecretKeys = provider.Settings.Keys.Where(k => !secretKeys.Contains(k)).ToList(); + + // Store secret settings in secure storage + var secretSettings = provider.Settings.Where(kvp => secretKeys.Contains(kvp.Key)) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + if (secretSettings.Count > 0) + { + var secretJson = JsonSerializer.Serialize(secretSettings); + await _secureStorage.SetAsync(SecureKeyPrefix + provider.Id, secretJson); + } + + // Store non-secret settings in metadata + var metadata = new CloudSecretsProviderMetadata( + provider.Id, + provider.Name, + provider.ProviderType, + nonSecretKeys + ); + + var existing = _providerMetadata.FindIndex(m => m.Id == provider.Id); + if (existing >= 0) + _providerMetadata[existing] = metadata; + else + _providerMetadata.Add(metadata); + + await PersistMetadataAsync(); + + // Store non-secret settings separately (since they're not in the metadata) + var nonSecretSettingsJson = JsonSerializer.Serialize( + provider.Settings.Where(kvp => !secretKeys.Contains(kvp.Key)) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value)); + await _fileSystem.WriteFileAsync(GetNonSecretSettingsPath(provider.Id), nonSecretSettingsJson); + + _logger.LogInformation($"Saved cloud secrets provider: {provider.Name}"); + + // Update active provider if it's the one we just saved + if (_activeProviderId == provider.Id) + { + ActiveProvider = provider; + _activeProviderInstance = null; // Force re-creation + OnActiveProviderChanged?.Invoke(); + } + } + + public async Task DeleteProviderAsync(string providerId) + { + await LoadMetadataAsync(); + + // Remove from secure storage + await _secureStorage.RemoveAsync(SecureKeyPrefix + providerId); + + // Remove non-secret settings file + var nonSecretPath = GetNonSecretSettingsPath(providerId); + if (await _fileSystem.FileExistsAsync(nonSecretPath)) + await _fileSystem.DeleteFileAsync(nonSecretPath); + + var removed = _providerMetadata.RemoveAll(m => m.Id == providerId); + if (removed > 0) + { + await PersistMetadataAsync(); + _logger.LogInformation($"Deleted cloud secrets provider: {providerId}"); + + // Clear active provider if deleted + if (_activeProviderId == providerId) + { + await SetActiveProviderAsync(null); + } + } + } + + public async Task TestProviderConnectionAsync(string providerId) + { + try + { + var providers = await GetProvidersAsync(); + var config = providers.FirstOrDefault(p => p.Id == providerId); + if (config == null) + { + _logger.LogError($"Provider not found: {providerId}"); + return false; + } + + var provider = _providerFactory.CreateProvider(config); + var result = await provider.TestConnectionAsync(); + + _logger.LogInformation($"Connection test for {config.Name}: {(result ? "SUCCESS" : "FAILED")}"); + return result; + } + catch (Exception ex) + { + _logger.LogError($"Connection test failed: {ex.Message}", ex); + return false; + } + } + + #endregion + + #region Active Provider + + public async Task SetActiveProviderAsync(string? providerId) + { + if (providerId == null) + { + _activeProviderId = null; + ActiveProvider = null; + _activeProviderInstance = null; + await _secureStorage.RemoveAsync(ActiveProviderKey); + } + else + { + var providers = await GetProvidersAsync(); + var config = providers.FirstOrDefault(p => p.Id == providerId); + if (config == null) + { + _logger.LogError($"Cannot set active provider: {providerId} not found"); + return; + } + + _activeProviderId = providerId; + ActiveProvider = config; + _activeProviderInstance = null; // Force re-creation on next use + await _secureStorage.SetAsync(ActiveProviderKey, providerId); + } + + OnActiveProviderChanged?.Invoke(); + _logger.LogInformation($"Active cloud secrets provider: {ActiveProvider?.Name ?? "None"}"); + } + + /// + /// Initializes the service by loading the active provider + /// + public async Task InitializeAsync() + { + _activeProviderId = await _secureStorage.GetAsync(ActiveProviderKey); + if (!string.IsNullOrEmpty(_activeProviderId)) + { + var providers = await GetProvidersAsync(); + ActiveProvider = providers.FirstOrDefault(p => p.Id == _activeProviderId); + if (ActiveProvider == null) + { + // Provider was deleted, clear the active provider + _activeProviderId = null; + await _secureStorage.RemoveAsync(ActiveProviderKey); + } + } + } + + #endregion + + #region Secret Operations + + public async Task StoreSecretAsync(string key, byte[] value, Dictionary? metadata = null, CancellationToken cancellationToken = default) + { + var provider = await GetActiveProviderInstanceAsync(); + if (provider == null) + { + _logger.LogWarning("No active cloud secrets provider configured"); + return false; + } + + return await provider.StoreSecretAsync(key, value, metadata, cancellationToken); + } + + public async Task GetSecretAsync(string key, CancellationToken cancellationToken = default) + { + var provider = await GetActiveProviderInstanceAsync(); + if (provider == null) + { + _logger.LogWarning("No active cloud secrets provider configured"); + return null; + } + + return await provider.GetSecretAsync(key, cancellationToken); + } + + public async Task DeleteSecretAsync(string key, CancellationToken cancellationToken = default) + { + var provider = await GetActiveProviderInstanceAsync(); + if (provider == null) + { + _logger.LogWarning("No active cloud secrets provider configured"); + return false; + } + + return await provider.DeleteSecretAsync(key, cancellationToken); + } + + public async Task SecretExistsAsync(string key, CancellationToken cancellationToken = default) + { + var provider = await GetActiveProviderInstanceAsync(); + if (provider == null) + { + _logger.LogWarning("No active cloud secrets provider configured"); + return false; + } + + return await provider.SecretExistsAsync(key, cancellationToken); + } + + public async Task> ListSecretsAsync(string? prefix = null, CancellationToken cancellationToken = default) + { + var provider = await GetActiveProviderInstanceAsync(); + if (provider == null) + { + _logger.LogWarning("No active cloud secrets provider configured"); + return Array.Empty(); + } + + return await provider.ListSecretsAsync(prefix, cancellationToken); + } + + #endregion + + #region Private Helpers + + private async Task GetActiveProviderInstanceAsync() + { + if (_activeProviderInstance != null) + return _activeProviderInstance; + + if (ActiveProvider == null) + return null; + + _activeProviderInstance = _providerFactory.CreateProvider(ActiveProvider); + return _activeProviderInstance; + } + + private string GetNonSecretSettingsPath(string providerId) => + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "MauiSherpa", + $"cloud-secrets-{providerId}.json"); + + private async Task LoadProviderConfigAsync(CloudSecretsProviderMetadata metadata) + { + try + { + var settings = new Dictionary(); + + // Load non-secret settings + var nonSecretPath = GetNonSecretSettingsPath(metadata.Id); + if (await _fileSystem.FileExistsAsync(nonSecretPath)) + { + var json = await _fileSystem.ReadFileAsync(nonSecretPath); + if (!string.IsNullOrEmpty(json)) + { + var nonSecretSettings = JsonSerializer.Deserialize>(json); + if (nonSecretSettings != null) + { + foreach (var kvp in nonSecretSettings) + settings[kvp.Key] = kvp.Value; + } + } + } + + // Load secret settings from secure storage + var secretJson = await _secureStorage.GetAsync(SecureKeyPrefix + metadata.Id); + if (!string.IsNullOrEmpty(secretJson)) + { + var secretSettings = JsonSerializer.Deserialize>(secretJson); + if (secretSettings != null) + { + foreach (var kvp in secretSettings) + settings[kvp.Key] = kvp.Value; + } + } + + return new CloudSecretsProviderConfig( + metadata.Id, + metadata.Name, + metadata.ProviderType, + settings + ); + } + catch (Exception ex) + { + _logger.LogError($"Failed to load provider config {metadata.Id}: {ex.Message}", ex); + return null; + } + } + + private async Task LoadMetadataAsync() + { + try + { + if (await _fileSystem.FileExistsAsync(_settingsPath)) + { + var json = await _fileSystem.ReadFileAsync(_settingsPath); + if (!string.IsNullOrEmpty(json)) + { + _providerMetadata = JsonSerializer.Deserialize>(json) ?? new(); + return; + } + } + } + catch (Exception ex) + { + _logger.LogWarning($"Failed to load cloud secrets metadata: {ex.Message}"); + } + _providerMetadata = new(); + } + + private async Task PersistMetadataAsync() + { + try + { + var directory = Path.GetDirectoryName(_settingsPath); + if (!string.IsNullOrEmpty(directory)) + await _fileSystem.CreateDirectoryAsync(directory); + + var json = JsonSerializer.Serialize(_providerMetadata, new JsonSerializerOptions + { + WriteIndented = true + }); + await _fileSystem.WriteFileAsync(_settingsPath, json); + } + catch (Exception ex) + { + _logger.LogError($"Failed to persist cloud secrets metadata: {ex.Message}", ex); + } + } + + #endregion +} diff --git a/src/MauiSherpa.Core/Services/GoogleSecretManagerProvider.cs b/src/MauiSherpa.Core/Services/GoogleSecretManagerProvider.cs new file mode 100644 index 00000000..2bffeb4b --- /dev/null +++ b/src/MauiSherpa.Core/Services/GoogleSecretManagerProvider.cs @@ -0,0 +1,368 @@ +using System.Text; +using Google.Api.Gax.ResourceNames; +using Google.Cloud.SecretManager.V1; +using Google.Protobuf; +using MauiSherpa.Core.Interfaces; +using Google.Apis.Auth.OAuth2; +using GcpSecret = Google.Cloud.SecretManager.V1.Secret; + +namespace MauiSherpa.Core.Services; + +/// +/// Cloud secrets provider implementation for Google Cloud Secret Manager +/// Uses the official Google.Cloud.SecretManager.V1 SDK +/// +public class GoogleSecretManagerProvider : ICloudSecretsProvider +{ + private readonly CloudSecretsProviderConfig _config; + private readonly ILoggingService _logger; + private SecretManagerServiceClient? _client; + + public GoogleSecretManagerProvider(CloudSecretsProviderConfig config, ILoggingService logger) + { + _config = config; + _logger = logger; + } + + public CloudSecretsProviderType ProviderType => CloudSecretsProviderType.GoogleSecretManager; + public string DisplayName => "Google Secret Manager"; + + #region Configuration Helpers + + private string ProjectId => _config.Settings.GetValueOrDefault("ProjectId", ""); + private string CredentialsJson => _config.Settings.GetValueOrDefault("CredentialsJson", ""); + private string SecretPrefix => _config.Settings.GetValueOrDefault("SecretPrefix", ""); + + #endregion + + #region Client Initialization + + private SecretManagerServiceClient GetClient() + { + if (_client != null) + return _client; + +#pragma warning disable CS0618 // Using deprecated method - will update when CredentialFactory is stable + var credential = GoogleCredential.FromJson(CredentialsJson); +#pragma warning restore CS0618 + var builder = new SecretManagerServiceClientBuilder + { + Credential = credential + }; + + _client = builder.Build(); + return _client; + } + + #endregion + + #region ICloudSecretsProvider Implementation + + public async Task TestConnectionAsync(CancellationToken cancellationToken = default) + { + try + { + var client = GetClient(); + var parent = $"projects/{ProjectId}"; + + // Try to list secrets to verify access + var request = new ListSecretsRequest { Parent = parent }; + var response = client.ListSecretsAsync(request); + + await foreach (var _ in response.AsRawResponses().WithCancellation(cancellationToken)) + { + break; // Just need to verify we can access + } + + _logger.LogInformation($"Google Secret Manager connection test successful for project {ProjectId}"); + return true; + } + catch (Grpc.Core.RpcException ex) + { + _logger.LogError($"Google Secret Manager connection test failed: {ex.StatusCode} - {ex.Message}", ex); + return false; + } + catch (Exception ex) + { + _logger.LogError($"Google Secret Manager connection test error: {ex.Message}", ex); + return false; + } + } + + public async Task StoreSecretAsync(string key, byte[] value, Dictionary? metadata = null, CancellationToken cancellationToken = default) + { + try + { + var client = GetClient(); + var secretId = GetSecretId(key); + var parent = $"projects/{ProjectId}"; + + // Check if secret exists + var secretExists = await SecretExistsInternalAsync(secretId, cancellationToken); + + string secretResourceName; + if (!secretExists) + { + // Create the secret first + var secret = new GcpSecret + { + Replication = new Replication { Automatic = new Replication.Types.Automatic() } + }; + + // Add labels if metadata provided + if (metadata != null) + { + foreach (var kvp in metadata) + { + secret.Labels[SanitizeLabel(kvp.Key)] = SanitizeLabel(kvp.Value); + } + } + + var createRequest = new CreateSecretRequest + { + Parent = parent, + SecretId = secretId, + Secret = secret + }; + + var createdSecret = await client.CreateSecretAsync(createRequest, cancellationToken); + secretResourceName = createdSecret.Name; + } + else + { + secretResourceName = $"projects/{ProjectId}/secrets/{secretId}"; + } + + // Add a new version with the secret data + var addVersionRequest = new AddSecretVersionRequest + { + Parent = secretResourceName, + Payload = new SecretPayload { Data = ByteString.CopyFrom(value) } + }; + + await client.AddSecretVersionAsync(addVersionRequest, cancellationToken); + + _logger.LogInformation($"Stored secret: {key}"); + return true; + } + catch (Grpc.Core.RpcException ex) + { + _logger.LogError($"Google Secret Manager store secret failed: {ex.StatusCode} - {ex.Message}", ex); + return false; + } + catch (Exception ex) + { + _logger.LogError($"Google Secret Manager store secret error: {ex.Message}", ex); + return false; + } + } + + public async Task GetSecretAsync(string key, CancellationToken cancellationToken = default) + { + try + { + var client = GetClient(); + var secretId = GetSecretId(key); + var secretVersionName = $"projects/{ProjectId}/secrets/{secretId}/versions/latest"; + + var request = new AccessSecretVersionRequest { Name = secretVersionName }; + var response = await client.AccessSecretVersionAsync(request, cancellationToken); + + return response.Payload?.Data?.ToByteArray(); + } + catch (Grpc.Core.RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) + { + return null; + } + catch (Grpc.Core.RpcException ex) + { + _logger.LogError($"Google Secret Manager get secret failed: {ex.StatusCode} - {ex.Message}", ex); + return null; + } + catch (Exception ex) + { + _logger.LogError($"Google Secret Manager get secret error: {ex.Message}", ex); + return null; + } + } + + public async Task DeleteSecretAsync(string key, CancellationToken cancellationToken = default) + { + try + { + var client = GetClient(); + var secretId = GetSecretId(key); + var secretName = $"projects/{ProjectId}/secrets/{secretId}"; + + var request = new DeleteSecretRequest { Name = secretName }; + await client.DeleteSecretAsync(request, cancellationToken); + + _logger.LogInformation($"Deleted secret: {key}"); + return true; + } + catch (Grpc.Core.RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) + { + _logger.LogInformation($"Secret already deleted or not found: {key}"); + return true; + } + catch (Grpc.Core.RpcException ex) + { + _logger.LogError($"Google Secret Manager delete secret failed: {ex.StatusCode} - {ex.Message}", ex); + return false; + } + catch (Exception ex) + { + _logger.LogError($"Google Secret Manager delete secret error: {ex.Message}", ex); + return false; + } + } + + public async Task SecretExistsAsync(string key, CancellationToken cancellationToken = default) + { + try + { + var secretId = GetSecretId(key); + return await SecretExistsInternalAsync(secretId, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError($"Google Secret Manager secret exists check error: {ex.Message}", ex); + return false; + } + } + + private async Task SecretExistsInternalAsync(string secretId, CancellationToken cancellationToken) + { + try + { + var client = GetClient(); + var secretName = $"projects/{ProjectId}/secrets/{secretId}"; + + var request = new GetSecretRequest { Name = secretName }; + await client.GetSecretAsync(request, cancellationToken); + return true; + } + catch (Grpc.Core.RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) + { + return false; + } + } + + public async Task> ListSecretsAsync(string? prefix = null, CancellationToken cancellationToken = default) + { + try + { + var client = GetClient(); + var parent = $"projects/{ProjectId}"; + var allSecrets = new List(); + var effectivePrefix = GetSecretId(prefix ?? ""); + + var request = new ListSecretsRequest { Parent = parent }; + var response = client.ListSecretsAsync(request); + + await foreach (var secret in response.WithCancellation(cancellationToken)) + { + // Extract secret ID from name (format: projects/{project}/secrets/{secretId}) + var name = secret.Name; + var lastSlash = name.LastIndexOf('/'); + if (lastSlash < 0) continue; + var secretId = name[(lastSlash + 1)..]; + + // Filter by prefix if specified + if (!string.IsNullOrEmpty(effectivePrefix) && !secretId.StartsWith(effectivePrefix, StringComparison.OrdinalIgnoreCase)) + continue; + + // Remove our prefix to get the original key + var originalKey = RemoveSecretPrefix(secretId); + allSecrets.Add(originalKey); + } + + return allSecrets.AsReadOnly(); + } + catch (Exception ex) + { + _logger.LogError($"Google Secret Manager list secrets error: {ex.Message}", ex); + return Array.Empty(); + } + } + + #endregion + + #region Private Helpers + + private string GetSecretId(string key) + { + if (string.IsNullOrEmpty(SecretPrefix)) + return SanitizeSecretId(key); + + return SanitizeSecretId($"{SecretPrefix}-{key}"); + } + + private string RemoveSecretPrefix(string secretId) + { + if (string.IsNullOrEmpty(SecretPrefix)) + return secretId; + + var prefix = SanitizeSecretId($"{SecretPrefix}-"); + if (secretId.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + return secretId[prefix.Length..]; + + return secretId; + } + + /// + /// Sanitize secret ID for Google Secret Manager + /// + private static string SanitizeSecretId(string id) + { + var sanitized = new StringBuilder(); + foreach (var c in id) + { + if (char.IsLetterOrDigit(c) || c == '-' || c == '_') + sanitized.Append(c); + else + sanitized.Append('-'); + } + + var result = sanitized.ToString(); + + // Must start with a letter + if (result.Length > 0 && !char.IsLetter(result[0])) + result = "S" + result; + + // Limit to 255 characters + if (result.Length > 255) + result = result[..255]; + + return result; + } + + /// + /// Sanitize label key/value for Google Cloud + /// + private static string SanitizeLabel(string value) + { + var sanitized = new StringBuilder(); + foreach (var c in value.ToLowerInvariant()) + { + if (char.IsLetterOrDigit(c) || c == '-' || c == '_') + sanitized.Append(c); + else + sanitized.Append('-'); + } + + var result = sanitized.ToString(); + + // Must start with a letter + if (result.Length > 0 && !char.IsLetter(result[0])) + result = "l" + result; + + // Limit to 63 characters + if (result.Length > 63) + result = result[..63]; + + return result; + } + + #endregion +} diff --git a/src/MauiSherpa.Core/Services/InfisicalProvider.cs b/src/MauiSherpa.Core/Services/InfisicalProvider.cs new file mode 100644 index 00000000..15526abf --- /dev/null +++ b/src/MauiSherpa.Core/Services/InfisicalProvider.cs @@ -0,0 +1,352 @@ +using System.Text; +using Infisical.Sdk; +using Infisical.Sdk.Model; +using MauiSherpa.Core.Interfaces; + +namespace MauiSherpa.Core.Services; + +/// +/// Cloud secrets provider implementation for Infisical +/// Uses the official Infisical.Sdk SDK +/// +public class InfisicalProvider : ICloudSecretsProvider +{ + private readonly CloudSecretsProviderConfig _config; + private readonly ILoggingService _logger; + private InfisicalClient? _client; + private bool _isAuthenticated; + + public InfisicalProvider(CloudSecretsProviderConfig config, ILoggingService logger) + { + _config = config; + _logger = logger; + } + + public CloudSecretsProviderType ProviderType => CloudSecretsProviderType.Infisical; + public string DisplayName => "Infisical"; + + #region Configuration Helpers + + private string SiteUrl => _config.Settings.GetValueOrDefault("SiteUrl", "https://app.infisical.com").TrimEnd('/'); + private string ClientId => _config.Settings.GetValueOrDefault("ClientId", ""); + private string ClientSecretValue => _config.Settings.GetValueOrDefault("ClientSecret", ""); + private string ProjectId => _config.Settings.GetValueOrDefault("ProjectId", ""); + private string Environment => _config.Settings.GetValueOrDefault("Environment", "prod"); + private string SecretPath => _config.Settings.GetValueOrDefault("SecretPath", "/maui-sherpa"); + + #endregion + + #region Client Initialization + + private async Task GetClientAsync(CancellationToken cancellationToken = default) + { + if (_client != null && _isAuthenticated) + return _client; + + try + { + var settings = new InfisicalSdkSettingsBuilder() + .WithHostUri(SiteUrl) + .Build(); + + _client = new InfisicalClient(settings); + + await _client.Auth().UniversalAuth().LoginAsync(ClientId, ClientSecretValue); + _isAuthenticated = true; + + return _client; + } + catch (Exception ex) + { + _logger.LogError($"Infisical authentication failed: {ex.Message}", ex); + _isAuthenticated = false; + return null; + } + } + + #endregion + + #region ICloudSecretsProvider Implementation + + public async Task TestConnectionAsync(CancellationToken cancellationToken = default) + { + try + { + var client = await GetClientAsync(cancellationToken); + if (client == null) + return false; + + // Try to list secrets to verify access + var options = new ListSecretsOptions + { + EnvironmentSlug = Environment, + SecretPath = SecretPath, + ProjectId = ProjectId, + }; + + await client.Secrets().ListAsync(options); + + _logger.LogInformation($"Infisical connection test successful for project {ProjectId}"); + return true; + } + catch (InfisicalException ex) + { + _logger.LogError($"Infisical connection test failed: {ex.Message}", ex); + return false; + } + catch (Exception ex) + { + _logger.LogError($"Infisical connection test error: {ex.Message}", ex); + return false; + } + } + + public async Task StoreSecretAsync(string key, byte[] value, Dictionary? metadata = null, CancellationToken cancellationToken = default) + { + try + { + var client = await GetClientAsync(cancellationToken); + if (client == null) + return false; + + var secretName = SanitizeSecretName(key); + // Store binary data as base64 encoded string + var base64Value = Convert.ToBase64String(value); + + // Check if secret exists to decide create vs update + var exists = await SecretExistsInternalAsync(client, secretName, cancellationToken); + + if (exists) + { + var updateOptions = new UpdateSecretOptions + { + SecretName = secretName, + EnvironmentSlug = Environment, + SecretPath = SecretPath, + ProjectId = ProjectId, + NewSecretValue = base64Value + }; + + await client.Secrets().UpdateAsync(updateOptions); + } + else + { + var createOptions = new CreateSecretOptions + { + SecretName = secretName, + SecretValue = base64Value, + EnvironmentSlug = Environment, + SecretPath = SecretPath, + ProjectId = ProjectId, + }; + + await client.Secrets().CreateAsync(createOptions); + } + + _logger.LogInformation($"Stored secret: {key}"); + return true; + } + catch (InfisicalException ex) + { + _logger.LogError($"Infisical store secret failed: {ex.Message}", ex); + return false; + } + catch (Exception ex) + { + _logger.LogError($"Infisical store secret error: {ex.Message}", ex); + return false; + } + } + + public async Task GetSecretAsync(string key, CancellationToken cancellationToken = default) + { + try + { + var client = await GetClientAsync(cancellationToken); + if (client == null) + return null; + + var secretName = SanitizeSecretName(key); + + var options = new GetSecretOptions + { + SecretName = secretName, + EnvironmentSlug = Environment, + SecretPath = SecretPath, + ProjectId = ProjectId, + }; + + var secret = await client.Secrets().GetAsync(options); + + if (secret?.SecretValue == null) + return null; + + // Decode from base64 + return Convert.FromBase64String(secret.SecretValue); + } + catch (InfisicalException ex) when (ex.Message.Contains("not found", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + catch (FormatException ex) + { + _logger.LogError($"Infisical secret not base64 encoded: {key} - {ex.Message}"); + return null; + } + catch (Exception ex) + { + _logger.LogError($"Infisical get secret error: {ex.Message}", ex); + return null; + } + } + + public async Task DeleteSecretAsync(string key, CancellationToken cancellationToken = default) + { + try + { + var client = await GetClientAsync(cancellationToken); + if (client == null) + return false; + + var secretName = SanitizeSecretName(key); + + var options = new DeleteSecretOptions + { + SecretName = secretName, + EnvironmentSlug = Environment, + SecretPath = SecretPath, + ProjectId = ProjectId, + }; + + await client.Secrets().DeleteAsync(options); + + _logger.LogInformation($"Deleted secret: {key}"); + return true; + } + catch (InfisicalException ex) when (ex.Message.Contains("not found", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogInformation($"Secret already deleted or not found: {key}"); + return true; + } + catch (InfisicalException ex) + { + _logger.LogError($"Infisical delete secret failed: {ex.Message}", ex); + return false; + } + catch (Exception ex) + { + _logger.LogError($"Infisical delete secret error: {ex.Message}", ex); + return false; + } + } + + public async Task SecretExistsAsync(string key, CancellationToken cancellationToken = default) + { + try + { + var client = await GetClientAsync(cancellationToken); + if (client == null) + return false; + + var secretName = SanitizeSecretName(key); + return await SecretExistsInternalAsync(client, secretName, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError($"Infisical secret exists check error: {ex.Message}", ex); + return false; + } + } + + private async Task SecretExistsInternalAsync(InfisicalClient client, string secretName, CancellationToken cancellationToken) + { + try + { + var options = new GetSecretOptions + { + SecretName = secretName, + EnvironmentSlug = Environment, + SecretPath = SecretPath, + ProjectId = ProjectId, + }; + + await client.Secrets().GetAsync(options); + return true; + } + catch (InfisicalException) + { + return false; + } + } + + public async Task> ListSecretsAsync(string? prefix = null, CancellationToken cancellationToken = default) + { + try + { + var client = await GetClientAsync(cancellationToken); + if (client == null) + return Array.Empty(); + + var options = new ListSecretsOptions + { + EnvironmentSlug = Environment, + SecretPath = SecretPath, + ProjectId = ProjectId, + }; + + var secrets = await client.Secrets().ListAsync(options); + + if (secrets == null) + return Array.Empty(); + + var result = new List(); + foreach (var secret in secrets) + { + var secretKey = secret.SecretKey; + + // Filter by prefix if specified + if (!string.IsNullOrEmpty(prefix) && !secretKey.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + continue; + + result.Add(secretKey); + } + + return result.AsReadOnly(); + } + catch (Exception ex) + { + _logger.LogError($"Infisical list secrets error: {ex.Message}", ex); + return Array.Empty(); + } + } + + #endregion + + #region Private Helpers + + /// + /// Sanitize secret name for Infisical + /// Secret names must be uppercase and can only contain letters, numbers, and underscores + /// + private static string SanitizeSecretName(string name) + { + var sanitized = new StringBuilder(); + foreach (var c in name.ToUpperInvariant()) + { + if (char.IsLetterOrDigit(c) || c == '_') + sanitized.Append(c); + else + sanitized.Append('_'); + } + + var result = sanitized.ToString(); + + // Must start with a letter + if (result.Length > 0 && !char.IsLetter(result[0])) + result = "S" + result; + + return result; + } + + #endregion +} diff --git a/src/MauiSherpa.Core/Services/LocalCertificateService.cs b/src/MauiSherpa.Core/Services/LocalCertificateService.cs new file mode 100644 index 00000000..9f301cd3 --- /dev/null +++ b/src/MauiSherpa.Core/Services/LocalCertificateService.cs @@ -0,0 +1,569 @@ +using System.Diagnostics; +using System.Text.Json; +using System.Text.RegularExpressions; +using MauiSherpa.Core.Interfaces; + +namespace MauiSherpa.Core.Services; + +/// +/// Service for managing local signing identities in the macOS keychain. +/// Uses the 'security' command-line tool to query and export certificates. +/// +public partial class LocalCertificateService : ILocalCertificateService +{ + private readonly ILoggingService _logger; + private readonly IPlatformService _platform; + + // Cache of signing identities (in-memory, expires after 5 minutes) + private List? _cachedIdentities; + private DateTime _cacheExpiry = DateTime.MinValue; + private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5); + + // Persistent cache of hash -> serial number mappings (on disk) + private Dictionary? _serialNumberCache; + private readonly string _serialCachePath; + + public LocalCertificateService(ILoggingService logger, IPlatformService platform) + { + _logger = logger; + _platform = platform; + + // Set up persistent cache path + var cacheDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".maui-sherpa", "cache"); + Directory.CreateDirectory(cacheDir); + _serialCachePath = Path.Combine(cacheDir, "cert-serials.json"); + } + + public bool IsSupported => _platform.IsMacCatalyst; + + public void InvalidateCache() + { + _cachedIdentities = null; + _cacheExpiry = DateTime.MinValue; + _logger.LogInformation("Local certificate cache invalidated"); + } + + public async Task> GetSigningIdentitiesAsync() + { + if (!IsSupported) + { + _logger.LogWarning("LocalCertificateService is only supported on macOS"); + return Array.Empty(); + } + + // Return cached results if still valid + if (_cachedIdentities != null && DateTime.UtcNow < _cacheExpiry) + { + return _cachedIdentities.AsReadOnly(); + } + + _logger.LogInformation("Querying local keychain for signing identities..."); + + var identities = new List(); + + try + { + // Run: security find-identity -v -p codesigning + var result = await RunSecurityCommandAsync("find-identity", "-v", "-p", "codesigning"); + + if (result.ExitCode != 0) + { + _logger.LogError($"security find-identity failed with exit code {result.ExitCode}"); + return identities.AsReadOnly(); + } + + // 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); + + foreach (var line in lines) + { + var identity = ParseIdentityLine(line); + if (identity != null) + { + // Look up the serial number for this identity + if (!string.IsNullOrEmpty(identity.Hash)) + { + var serialNumber = await GetCertificateSerialNumberAsync(identity.Hash); + if (!string.IsNullOrEmpty(serialNumber)) + { + identity = identity with { SerialNumber = serialNumber }; + _logger.LogDebug($"Found identity: {identity.CommonName}, Serial: {serialNumber} (Valid: {identity.IsValid})"); + } + else + { + _logger.LogDebug($"Found identity: {identity.CommonName}, Serial: (not found) (Valid: {identity.IsValid})"); + } + } + + identities.Add(identity); + } + } + + _logger.LogInformation($"Found {identities.Count} signing identities in keychain"); + + // Cache the results + _cachedIdentities = identities; + _cacheExpiry = DateTime.UtcNow + CacheDuration; + } + catch (Exception ex) + { + _logger.LogError($"Failed to query signing identities: {ex.Message}", ex); + } + + return identities.AsReadOnly(); + } + + public async Task HasPrivateKeyAsync(string serialNumber) + { + if (!IsSupported || string.IsNullOrEmpty(serialNumber)) + return false; + + var identities = await GetSigningIdentitiesAsync(); + + // Check if any local identity matches the serial number + return identities.Any(i => + i.SerialNumber != null && + i.SerialNumber.Equals(serialNumber, StringComparison.OrdinalIgnoreCase)); + } + + public async Task ExportP12Async(string identity, string password) + { + if (!IsSupported) + throw new PlatformNotSupportedException("P12 export is only supported on macOS"); + + if (string.IsNullOrEmpty(identity)) + throw new ArgumentException("Identity cannot be empty", nameof(identity)); + + _logger.LogInformation($"Exporting P12 for identity: {identity}"); + + var tempFile = Path.GetTempFileName(); + var loginKeychain = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Library/Keychains/login.keychain-db"); + + try + { + // Use security command to export the identity + // security export -t identities -f pkcs12 -P password -o output.p12 + var result = await RunSecurityCommandAsync( + "export", + "-t", "identities", + "-f", "pkcs12", + "-P", password, + "-o", tempFile, + "-k", loginKeychain + ); + + if (result.ExitCode != 0) + { + _logger.LogError($"P12 export failed: {result.Error}"); + throw new InvalidOperationException($"Failed to export P12: {result.Error}"); + } + + // Read the exported file + var p12Data = await File.ReadAllBytesAsync(tempFile); + _logger.LogInformation($"Exported P12: {p12Data.Length} bytes"); + + return p12Data; + } + finally + { + // Clean up temp file + try { File.Delete(tempFile); } catch { } + } + } + + public async Task ExportCertificateAsync(string serialNumber) + { + if (!IsSupported) + throw new PlatformNotSupportedException("Certificate export is only supported on macOS"); + + if (string.IsNullOrEmpty(serialNumber)) + throw new ArgumentException("Serial number cannot be empty", nameof(serialNumber)); + + _logger.LogInformation($"Exporting certificate for serial: {serialNumber}"); + + // Find the certificate with this serial number from the keychain + var result = await RunSecurityCommandAsync("find-certificate", "-a", "-Z", "-p"); + if (result.ExitCode != 0) + { + throw new InvalidOperationException($"Failed to list certificates: {result.Error}"); + } + + // Parse output to find the certificate with matching serial + var lines = result.Output.Split('\n'); + string? currentPem = null; + var inCert = false; + var pemBuilder = new System.Text.StringBuilder(); + + foreach (var line in lines) + { + if (line.StartsWith("-----BEGIN CERTIFICATE-----")) + { + inCert = true; + pemBuilder.Clear(); + pemBuilder.AppendLine(line); + } + else if (line.StartsWith("-----END CERTIFICATE-----")) + { + pemBuilder.AppendLine(line); + currentPem = pemBuilder.ToString(); + inCert = false; + + // Check if this cert has our serial number + var serial = await GetSerialFromPemAsync(currentPem); + if (serial?.Equals(serialNumber, StringComparison.OrdinalIgnoreCase) == true) + { + // Convert PEM to DER + var base64 = currentPem + .Replace("-----BEGIN CERTIFICATE-----", "") + .Replace("-----END CERTIFICATE-----", "") + .Replace("\n", "") + .Replace("\r", "") + .Trim(); + return Convert.FromBase64String(base64); + } + } + else if (inCert) + { + pemBuilder.AppendLine(line); + } + } + + throw new InvalidOperationException($"Certificate with serial {serialNumber} not found in keychain"); + } + + private async Task GetSerialFromPemAsync(string pem) + { + var tempFile = Path.GetTempFileName(); + try + { + await File.WriteAllTextAsync(tempFile, pem); + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "openssl", + Arguments = $"x509 -serial -noout -in \"{tempFile}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + process.Start(); + var output = await process.StandardOutput.ReadToEndAsync(); + await process.WaitForExitAsync(); + + // Output is like "serial=3561ADFB67EF2B1DF51F4B1B29299BB5" + if (output.StartsWith("serial=", StringComparison.OrdinalIgnoreCase)) + { + return output.Substring(7).Trim(); + } + return null; + } + finally + { + try { File.Delete(tempFile); } catch { } + } + } + + public async Task DeleteCertificateAsync(string identity) + { + if (!IsSupported) + throw new PlatformNotSupportedException("Certificate deletion is only supported on macOS"); + + if (string.IsNullOrEmpty(identity)) + throw new ArgumentException("Identity cannot be empty", nameof(identity)); + + _logger.LogInformation($"Deleting certificate: {identity}"); + + // Delete the identity (certificate + private key) from the keychain + // security delete-identity -c "Common Name" deletes by common name + // We'll use the hash to be more precise + + // First, try to delete the private key + var deleteKeyResult = await RunSecurityCommandAsync( + "delete-identity", + "-t", // delete the identity (cert + key) + "-c", identity // common name from the identity string + ); + + if (deleteKeyResult.ExitCode != 0) + { + _logger.LogWarning($"Failed to delete identity, trying certificate only: {deleteKeyResult.Error}"); + + // Try to delete just the certificate + var deleteCertResult = await RunSecurityCommandAsync( + "delete-certificate", + "-c", identity + ); + + if (deleteCertResult.ExitCode != 0) + { + throw new InvalidOperationException($"Failed to delete certificate: {deleteCertResult.Error}"); + } + } + + _logger.LogInformation($"Certificate deleted successfully: {identity}"); + } + + /// + /// Matches a local identity to an API certificate by finding common attributes + /// + public LocalSigningIdentity? FindMatchingIdentity( + IReadOnlyList localIdentities, + AppleCertificate apiCertificate) + { + // Try to match by serial number first (most reliable) + if (!string.IsNullOrEmpty(apiCertificate.SerialNumber)) + { + var bySerial = localIdentities.FirstOrDefault(i => + i.SerialNumber?.Equals(apiCertificate.SerialNumber, StringComparison.OrdinalIgnoreCase) == true); + + if (bySerial != null) + return bySerial; + } + + // Fall back to name matching (less reliable but useful) + var byName = localIdentities.FirstOrDefault(i => + i.CommonName.Contains(apiCertificate.Name, StringComparison.OrdinalIgnoreCase) || + apiCertificate.Name.Contains(i.CommonName, StringComparison.OrdinalIgnoreCase)); + + return byName; + } + + private LocalSigningIdentity? ParseIdentityLine(string line) + { + // Example lines: + // 1) ABC123... "Apple Development: John Doe (TEAMID)" + // 2) DEF456... "Developer ID Application: Company (TEAMID)" (CSSMERR_TP_CERT_EXPIRED) + + var match = IdentityLineRegex().Match(line); + if (!match.Success) + return null; + + var hash = match.Groups["hash"].Value; + var identityString = match.Groups["identity"].Value; + var isValid = !line.Contains("CSSMERR_TP_CERT_EXPIRED") && + !line.Contains("CSSMERR_TP_CERT_REVOKED") && + !line.Contains("CSSMERR_TP_NOT_TRUSTED"); + + // Extract team ID from identity string + var teamIdMatch = TeamIdRegex().Match(identityString); + var teamId = teamIdMatch.Success ? teamIdMatch.Groups[1].Value : null; + + // Extract common name (everything before the team ID part) + var commonName = identityString; + if (teamIdMatch.Success) + { + var parenIndex = identityString.LastIndexOf('('); + if (parenIndex > 0) + commonName = identityString.Substring(0, parenIndex).Trim(); + } + + return new LocalSigningIdentity( + Identity: identityString, + CommonName: commonName, + TeamId: teamId, + SerialNumber: null, // Will be populated by GetCertificateSerialNumber + ExpirationDate: null, + IsValid: isValid, + Hash: hash // Store the hash for later serial number lookup + ); + } + + /// + /// Gets the serial number for a certificate using its SHA-1 hash. + /// Uses persistent cache to avoid repeated openssl calls. + /// + private async Task GetCertificateSerialNumberAsync(string hash) + { + // Check persistent cache first + await LoadSerialCacheAsync(); + if (_serialNumberCache!.TryGetValue(hash.ToUpperInvariant(), out var cachedSerial)) + { + return cachedSerial; + } + + try + { + var result = await RunSecurityCommandAsync("find-certificate", "-a", "-Z", "-p"); + if (result.ExitCode != 0) + return null; + + // The output contains certificate info blocks. Find the one matching our hash. + var lines = result.Output.Split('\n'); + var foundHash = false; + var certPem = new System.Text.StringBuilder(); + var inCert = false; + + for (int i = 0; i < lines.Length; i++) + { + var line = lines[i].Trim(); + + if (line.StartsWith("SHA-1 hash:", StringComparison.OrdinalIgnoreCase)) + { + var certHash = line.Substring("SHA-1 hash:".Length).Trim(); + foundHash = certHash.Equals(hash, StringComparison.OrdinalIgnoreCase); + certPem.Clear(); + inCert = false; + } + else if (foundHash && line.Contains("BEGIN CERTIFICATE")) + { + inCert = true; + certPem.AppendLine(line); + } + else if (inCert) + { + certPem.AppendLine(line); + if (line.Contains("END CERTIFICATE")) + { + // We have the full certificate - extract serial using openssl + var serial = await ExtractSerialFromPemAsync(certPem.ToString()); + + // Cache the result persistently + if (!string.IsNullOrEmpty(serial)) + { + _serialNumberCache[hash.ToUpperInvariant()] = serial; + await SaveSerialCacheAsync(); + } + + return serial; + } + } + } + } + catch (Exception ex) + { + _logger.LogWarning($"Failed to get certificate serial number: {ex.Message}"); + } + + return null; + } + + private async Task LoadSerialCacheAsync() + { + if (_serialNumberCache != null) + return; + + try + { + if (File.Exists(_serialCachePath)) + { + var json = await File.ReadAllTextAsync(_serialCachePath); + _serialNumberCache = JsonSerializer.Deserialize>(json) ?? new(); + return; + } + } + catch (Exception ex) + { + _logger.LogWarning($"Failed to load serial cache: {ex.Message}"); + } + + _serialNumberCache = new(); + } + + private async Task SaveSerialCacheAsync() + { + try + { + var json = JsonSerializer.Serialize(_serialNumberCache, new JsonSerializerOptions { WriteIndented = true }); + await File.WriteAllTextAsync(_serialCachePath, json); + } + catch (Exception ex) + { + _logger.LogWarning($"Failed to save serial cache: {ex.Message}"); + } + } + + private async Task ExtractSerialFromPemAsync(string pemCert) + { + try + { + // Write PEM to temp file + var tempFile = Path.GetTempFileName(); + try + { + await File.WriteAllTextAsync(tempFile, pemCert); + + // Use openssl to extract serial + var psi = new ProcessStartInfo + { + FileName = "openssl", + ArgumentList = { "x509", "-in", tempFile, "-serial", "-noout" }, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(psi); + if (process == null) + return null; + + var output = await process.StandardOutput.ReadToEndAsync(); + await process.WaitForExitAsync(); + + if (process.ExitCode != 0) + return null; + + // Output format: serial=3561ADFB67EF2B1DF51F4B1B29299BB5 + var serialMatch = Regex.Match(output, @"serial=([A-Fa-f0-9]+)"); + if (serialMatch.Success) + { + return serialMatch.Groups[1].Value.ToUpperInvariant(); + } + } + finally + { + try { File.Delete(tempFile); } catch { } + } + } + catch (Exception ex) + { + _logger.LogWarning($"Failed to extract serial from PEM: {ex.Message}"); + } + + return null; + } + + private async Task<(int ExitCode, string Output, string Error)> RunSecurityCommandAsync(params string[] args) + { + var psi = new ProcessStartInfo + { + FileName = "security", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + foreach (var arg in args) + { + psi.ArgumentList.Add(arg); + } + + using var process = Process.Start(psi); + if (process == null) + return (-1, "", "Failed to start security process"); + + var output = await process.StandardOutput.ReadToEndAsync(); + var error = await process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + + return (process.ExitCode, output, error); + } + + // 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(); + + // Regex to extract team ID from identity string (usually in parentheses at the end) + [GeneratedRegex(@"\(([A-Z0-9]{10})\)\s*$")] + private static partial Regex TeamIdRegex(); +} diff --git a/src/MauiSherpa/Components/CISecretsWizard.razor b/src/MauiSherpa/Components/CISecretsWizard.razor new file mode 100644 index 00000000..3dda4f21 --- /dev/null +++ b/src/MauiSherpa/Components/CISecretsWizard.razor @@ -0,0 +1,1935 @@ +@using MauiSherpa.Core.Interfaces +@inject IAppleConnectService AppleService +@inject IAppleIdentityStateService IdentityState +@inject ILocalCertificateService LocalCertService +@inject IAlertService AlertService +@inject IDialogService DialogService +@inject ILoggingService Logger +@inject NavigationManager Navigation + +@if (IsVisible) +{ +
+
+
+

CI Secrets Wizard

+ +
+ + +
+ @for (int i = 1; i <= TotalSteps; i++) + { + var step = i; +
step ? "completed" : "")"> +
@(currentStep > step ? "✓" : step.ToString())
+
@GetStepLabel(step)
+
+ @if (i < TotalSteps) + { +
+ } + } +
+ +
+ @if (isLoading) + { +
+ + @loadingMessage +
+ } + else + { + @switch (currentStep) + { + case 1: + RenderPlatformStep(); + break; + case 2: + RenderDistributionStep(); + break; + case 3: + @if (ShowInstallerStep) + { + RenderInstallerStep(); + } + else + { + RenderResourcesStep(); + } + break; + case 4: + @if (ShowInstallerStep) + { + RenderResourcesStep(); + } + else + { + RenderExportStep(); + } + break; + case 5: + RenderExportStep(); + break; + } + } +
+ + +
+
+} + +@* Step 1: Platform Selection *@ +@{ void RenderPlatformStep() { +
+

Select Platform

+

Which platform are you building for?

+ +
+ + + + + +
+
+}} + +@* Step 2: Distribution Type *@ +@{ void RenderDistributionStep() { +
+

Select Distribution Type

+

How will you distribute your app?

+ +
+ @foreach (var dist in GetAvailableDistributionTypes()) + { + + } +
+
+}} + +@* Step 3: Installer Certificate (Mac only) *@ +@{ void RenderInstallerStep() { +
+

Installer Package

+

Do you need to create a signed installer package (.pkg)?

+ +
+ + + +
+
+}} + +@* Step 4: Resource Validation *@ +@{ void RenderResourcesStep() { +
+

Required Resources

+

Select the resources for your CI pipeline.

+ + +
+
+ + Bundle ID + @if (wizardState.SelectedBundleId != null) + { + + } +
+ @if (bundleIds.Any()) + { + +
+ @foreach (var bundle in FilteredBundleIds) + { + + } + @if (!FilteredBundleIds.Any()) + { +
+ + No matching bundle IDs +
+ } +
+ } + else + { +
+ + No bundle IDs found. + +
+ } +
+ + +
+
+ + @GetSigningCertLabel() + @if (wizardState.SigningCertificate != null && HasPrivateKey(wizardState.SigningCertificate)) + { + + } + else if (wizardState.SigningCertificate != null) + { + + Missing Key + + } +
+ @if (FilteredSigningCerts.Any()) + { +
+ @foreach (var cert in FilteredSigningCerts) + { + var hasKey = HasPrivateKey(cert); + + } +
+ } + else + { +
+ + No @GetSigningCertLabel().ToLower() found. + +
+ } +
+ + + @if (wizardState.NeedsInstallerCert) + { +
+
+ + Developer ID Installer Certificate + @if (wizardState.InstallerCertificate != null && HasPrivateKey(wizardState.InstallerCertificate)) + { + + } +
+ @if (InstallerCerts.Any()) + { +
+ @foreach (var cert in InstallerCerts) + { + var hasKey = HasPrivateKey(cert); + + } +
+ } + else + { +
+ + No Developer ID Installer certificate found. + +
+ } +
+ } + + + @if (NeedsProvisioningProfile) + { +
+
+ + Provisioning Profile + @if (wizardState.ProvisioningProfile != null) + { + + } +
+ @if (FilteredProfiles.Any()) + { + + } + else + { +
+ + No matching provisioning profile found. + +
+ } +
+ } + + + @if (NeedsNotarization) + { +
+
+ + Notarization Credentials +
+
+ + +
+
+ + + + Generate at appleid.apple.com + +
+
+ } + + +
+
+ + Certificate Export Password +
+
+ + + + This password will protect your exported certificate. You'll need it in your CI pipeline. + +
+
+
+}} + +@* Step 5: Export Secrets *@ +@{ void RenderExportStep() { +
+

Export CI Secrets

+

Copy these secrets to your CI pipeline configuration (e.g., GitHub Actions).

+ + @if (!string.IsNullOrEmpty(exportError)) + { +
+ + @exportError +
+ } + +
+ @foreach (var secret in exportedSecrets) + { +
+
+ @secret.Name + +
+
@secret.Description
+ @if (!secret.IsSensitive) + { +
+ @TruncateValue(secret.Value) +
+ } + else + { +
+ •••••••••••••••• + (sensitive) +
+ } +
+ } +
+ + @if (exportedSecrets.Any()) + { +
+ +
+ + +
+

Build Command

+

Use these commands to build and sign your app in CI. Set the environment variables above, then run:

+ +
+ + + +
+ +
+
+ @GetShellLabel() + +
+
@GetBuildCommand()
+
+ + @if (selectedShell == "github") + { +
+ + Add these as GitHub Secrets in your repository settings. +
+ } +
+ } +
+}} + + + +@code { + [Parameter] public bool IsVisible { get; set; } + [Parameter] public EventCallback IsVisibleChanged { get; set; } + + private int currentStep = 1; + private bool isLoading = false; + private string loadingMessage = "Loading..."; + private string exportError = ""; + + private CISecretsWizardState wizardState = new() + { + Platform = ApplePlatformType.iOS, + Distribution = AppleDistributionType.Development + }; + + // Data + private List bundleIds = new(); + private List certificates = new(); + private List profiles = new(); + private List localIdentities = new(); + private List exportedSecrets = new(); + + // Build command shell selection + private string selectedShell = "bash"; + + // P12 export password + private string p12ExportPassword = ""; + + // Search/filter + private string bundleIdSearch = ""; + + private int TotalSteps => ShowInstallerStep ? 5 : 4; + + private bool ShowInstallerStep => + wizardState.Distribution == AppleDistributionType.Direct && + (wizardState.Platform == ApplePlatformType.MacCatalyst || wizardState.Platform == ApplePlatformType.macOS); + + private bool NeedsProvisioningProfile => + !(wizardState.Platform == ApplePlatformType.macOS && wizardState.Distribution == AppleDistributionType.Direct); + + private bool NeedsNotarization => + wizardState.Distribution == AppleDistributionType.Direct; + + private bool CanProceed => currentStep switch + { + 1 => true, // Platform always selected + 2 => true, // Distribution always selected + 3 => ShowInstallerStep ? true : ValidateResourcesStep(), // Either installer choice or resources + 4 => ShowInstallerStep ? ValidateResourcesStep() : true, // Resources or Export + _ => true + }; + + private bool ValidateResourcesStep() + { + if (wizardState.SelectedBundleId == null) return false; + if (wizardState.SigningCertificate == null || !HasPrivateKey(wizardState.SigningCertificate)) return false; + if (wizardState.NeedsInstallerCert && (wizardState.InstallerCertificate == null || !HasPrivateKey(wizardState.InstallerCertificate))) return false; + if (NeedsProvisioningProfile && wizardState.ProvisioningProfile == null) return false; + return true; + } + + private IEnumerable FilteredBundleIds => bundleIds + .Where(b => string.IsNullOrEmpty(bundleIdSearch) || + b.Name.Contains(bundleIdSearch, StringComparison.OrdinalIgnoreCase) || + b.Identifier.Contains(bundleIdSearch, StringComparison.OrdinalIgnoreCase)) + .OrderBy(b => b.Name); + + private string GetStepLabel(int step) + { + if (ShowInstallerStep) + { + return step switch + { + 1 => "Platform", + 2 => "Distribution", + 3 => "Installer", + 4 => "Resources", + 5 => "Export", + _ => "" + }; + } + else + { + return step switch + { + 1 => "Platform", + 2 => "Distribution", + 3 => "Resources", + 4 => "Export", + _ => "" + }; + } + } + + protected override async Task OnParametersSetAsync() + { + if (IsVisible && bundleIds.Count == 0) + { + await LoadDataAsync(); + } + } + + private async Task LoadDataAsync() + { + if (IdentityState.SelectedIdentity == null) return; + + isLoading = true; + loadingMessage = "Loading resources..."; + + try + { + var bundleTask = AppleService.GetBundleIdsAsync(); + var certTask = AppleService.GetCertificatesAsync(); + var profileTask = AppleService.GetProfilesAsync(); + var identityTask = LocalCertService.GetSigningIdentitiesAsync(); + + await Task.WhenAll(bundleTask, certTask, profileTask, identityTask); + + bundleIds = (await bundleTask).ToList(); + certificates = (await certTask).ToList(); + profiles = (await profileTask).ToList(); + localIdentities = (await identityTask).ToList(); + + Logger.LogInformation($"Loaded {bundleIds.Count} bundle IDs, {certificates.Count} certs, {profiles.Count} profiles, {localIdentities.Count} local identities"); + } + catch (Exception ex) + { + Logger.LogError($"Failed to load data: {ex.Message}", ex); + await AlertService.ShowAlertAsync("Error", $"Failed to load data: {ex.Message}"); + } + finally + { + isLoading = false; + } + } + + private IEnumerable FilteredSigningCerts => certificates + .Where(c => IsSigningCertCompatible(c.CertificateType)) + .Where(c => c.ExpirationDate > DateTime.UtcNow) + .OrderByDescending(c => HasPrivateKey(c)) + .ThenBy(c => c.Name); + + private IEnumerable InstallerCerts => certificates + .Where(c => c.CertificateType.Contains("INSTALLER", StringComparison.OrdinalIgnoreCase)) + .Where(c => c.ExpirationDate > DateTime.UtcNow) + .OrderByDescending(c => HasPrivateKey(c)) + .ThenBy(c => c.Name); + + private IEnumerable FilteredProfiles => profiles + .Where(p => IsProfileCompatible(p.ProfileType)) + .Where(p => p.State == "ACTIVE") + .OrderBy(p => p.Name); + + private bool IsSigningCertCompatible(string certType) + { + var ct = certType.ToUpperInvariant(); + + return wizardState.Distribution switch + { + AppleDistributionType.Development => ct.Contains("DEVELOPMENT"), + AppleDistributionType.AdHoc => ct.Contains("DISTRIBUTION") && !ct.Contains("DEVELOPER_ID"), + AppleDistributionType.AppStore => ct.Contains("DISTRIBUTION") && !ct.Contains("DEVELOPER_ID"), + AppleDistributionType.Direct => ct.Contains("DEVELOPER_ID_APPLICATION"), + _ => false + }; + } + + private bool IsProfileCompatible(string profileType) + { + var pt = profileType.ToUpperInvariant(); + + // Check platform + var platformMatch = wizardState.Platform switch + { + ApplePlatformType.iOS => pt.StartsWith("IOS_"), + ApplePlatformType.MacCatalyst => pt.StartsWith("MAC_CATALYST_"), + ApplePlatformType.macOS => pt.StartsWith("MAC_APP_"), + _ => false + }; + + if (!platformMatch) return false; + + // Check distribution type + return wizardState.Distribution switch + { + AppleDistributionType.Development => pt.Contains("DEVELOPMENT"), + AppleDistributionType.AdHoc => pt.Contains("ADHOC"), + AppleDistributionType.AppStore => pt.Contains("STORE"), + AppleDistributionType.Direct => pt.Contains("DIRECT"), + _ => false + }; + } + + private bool HasPrivateKey(AppleCertificate cert) + { + // Check if any local identity matches this certificate + return localIdentities.Any(li => + (li.SerialNumber != null && li.SerialNumber.Equals(cert.SerialNumber, StringComparison.OrdinalIgnoreCase)) || + li.CommonName.Contains(cert.Name, StringComparison.OrdinalIgnoreCase) || + cert.Name.Contains(li.CommonName, StringComparison.OrdinalIgnoreCase)); + } + + private string GetSigningCertLabel() => wizardState.Distribution switch + { + AppleDistributionType.Development => "Development Certificate", + AppleDistributionType.AdHoc => "Distribution Certificate", + AppleDistributionType.AppStore => "Distribution Certificate", + AppleDistributionType.Direct => "Developer ID Application Certificate", + _ => "Signing Certificate" + }; + + private IEnumerable<(AppleDistributionType Type, string Name, string Description)> GetAvailableDistributionTypes() + { + yield return (AppleDistributionType.Development, "Development", "Build and test on registered devices"); + + if (wizardState.Platform == ApplePlatformType.iOS) + { + yield return (AppleDistributionType.AdHoc, "Ad Hoc", "Distribute to specific registered devices"); + } + + yield return (AppleDistributionType.AppStore, "App Store / TestFlight", "Submit to the App Store or TestFlight"); + + if (wizardState.Platform != ApplePlatformType.iOS) + { + yield return (AppleDistributionType.Direct, "Direct Distribution", "Distribute outside the App Store (notarized)"); + } + } + + // Event handlers + private void SelectPlatform(ApplePlatformType platform) + { + wizardState = wizardState with + { + Platform = platform, + Distribution = AppleDistributionType.Development, // Reset + SigningCertificate = null, + ProvisioningProfile = null + }; + } + + private void SelectDistribution(AppleDistributionType dist) + { + wizardState = wizardState with + { + Distribution = dist, + SigningCertificate = null, + ProvisioningProfile = null, + NeedsInstallerCert = false + }; + } + + private void SetNeedsInstaller(bool needs) + { + wizardState = wizardState with { NeedsInstallerCert = needs }; + } + + private void SelectBundleId(AppleBundleId bundle) + { + wizardState = wizardState with { SelectedBundleId = bundle }; + } + + private void SelectSigningCert(AppleCertificate cert) + { + wizardState = wizardState with { SigningCertificate = cert }; + } + + private void SelectInstallerCert(AppleCertificate cert) + { + wizardState = wizardState with { InstallerCertificate = cert }; + } + + private void OnProfileChanged(ChangeEventArgs e) + { + var id = e.Value?.ToString(); + wizardState = wizardState with + { + ProvisioningProfile = profiles.FirstOrDefault(p => p.Id == id) + }; + } + + private void UpdateNotarization(string? appleId = null, string? password = null) + { + wizardState = wizardState with + { + NotarizationAppleId = appleId ?? wizardState.NotarizationAppleId, + NotarizationPassword = password ?? wizardState.NotarizationPassword + }; + } + + private async Task NextStep() + { + currentStep++; + + if (currentStep == TotalSteps) + { + await GenerateSecrets(); + } + } + + private void PreviousStep() + { + currentStep--; + } + + private async Task GenerateSecrets() + { + isLoading = true; + loadingMessage = "Generating secrets..."; + exportError = ""; + exportedSecrets.Clear(); + + try + { + // TODO: Implement P12 export + // For now, show placeholder secrets + + var teamId = IdentityState.SelectedIdentity?.IssuerId ?? "TEAM_ID"; + var exportPassword = string.IsNullOrEmpty(p12ExportPassword) ? "changeit" : p12ExportPassword; + + // Export signing certificate P12 + if (wizardState.SigningCertificate != null) + { + var signingIdentity = FindMatchingIdentity(wizardState.SigningCertificate); + if (signingIdentity != null) + { + try + { + loadingMessage = "Exporting signing certificate..."; + StateHasChanged(); + + var p12Data = await LocalCertService.ExportP12Async(signingIdentity.Identity, exportPassword); + var p12Base64 = Convert.ToBase64String(p12Data); + + exportedSecrets.Add(new CISecretExport( + "APPLE_CERTIFICATE_P12", + p12Base64, + $"Base64-encoded signing certificate ({wizardState.SigningCertificate.Name})", + true + )); + } + catch (Exception ex) + { + Logger.LogError($"Failed to export signing certificate: {ex.Message}", ex); + exportedSecrets.Add(new CISecretExport( + "APPLE_CERTIFICATE_P12", + $"[Export failed: {ex.Message}]", + $"Base64-encoded signing certificate ({wizardState.SigningCertificate.Name})", + true + )); + } + } + else + { + exportedSecrets.Add(new CISecretExport( + "APPLE_CERTIFICATE_P12", + "[No matching local identity found]", + $"Base64-encoded signing certificate ({wizardState.SigningCertificate.Name})", + true + )); + } + + exportedSecrets.Add(new CISecretExport( + "APPLE_CERTIFICATE_PASSWORD", + exportPassword, + "Password for the P12 certificate", + true + )); + + exportedSecrets.Add(new CISecretExport( + "APPLE_CODESIGN_IDENTITY", + signingIdentity?.Identity ?? wizardState.SigningCertificate.Name, + "Code signing identity name", + false + )); + } + + // Export installer certificate P12 + if (wizardState.NeedsInstallerCert && wizardState.InstallerCertificate != null) + { + var installerIdentity = FindMatchingIdentity(wizardState.InstallerCertificate); + if (installerIdentity != null) + { + try + { + loadingMessage = "Exporting installer certificate..."; + StateHasChanged(); + + var p12Data = await LocalCertService.ExportP12Async(installerIdentity.Identity, exportPassword); + var p12Base64 = Convert.ToBase64String(p12Data); + + exportedSecrets.Add(new CISecretExport( + "APPLE_INSTALLER_CERTIFICATE_P12", + p12Base64, + $"Base64-encoded installer certificate ({wizardState.InstallerCertificate.Name})", + true + )); + } + catch (Exception ex) + { + Logger.LogError($"Failed to export installer certificate: {ex.Message}", ex); + exportedSecrets.Add(new CISecretExport( + "APPLE_INSTALLER_CERTIFICATE_P12", + $"[Export failed: {ex.Message}]", + $"Base64-encoded installer certificate ({wizardState.InstallerCertificate.Name})", + true + )); + } + } + + exportedSecrets.Add(new CISecretExport( + "APPLE_INSTALLER_CERTIFICATE_PASSWORD", + exportPassword, + "Password for the installer P12 certificate", + true + )); + } + + if (NeedsProvisioningProfile && wizardState.ProvisioningProfile != null) + { + loadingMessage = "Downloading provisioning profile..."; + StateHasChanged(); + + // Download and base64 encode the profile + var profileData = await AppleService.DownloadProfileAsync(wizardState.ProvisioningProfile.Id); + var profileBase64 = Convert.ToBase64String(profileData); + + exportedSecrets.Add(new CISecretExport( + "APPLE_PROVISIONING_PROFILE", + profileBase64, + $"Base64-encoded provisioning profile ({wizardState.ProvisioningProfile.Name})", + true + )); + + exportedSecrets.Add(new CISecretExport( + "APPLE_PROVISIONING_PROFILE_NAME", + wizardState.ProvisioningProfile.Name, + "Provisioning profile name", + false + )); + } + + if (NeedsNotarization) + { + exportedSecrets.Add(new CISecretExport( + "APPLE_NOTARIZATION_APPLE_ID", + wizardState.NotarizationAppleId ?? "", + "Apple ID for notarization", + false + )); + + exportedSecrets.Add(new CISecretExport( + "APPLE_NOTARIZATION_PASSWORD", + wizardState.NotarizationPassword ?? "", + "App-specific password for notarization", + true + )); + + exportedSecrets.Add(new CISecretExport( + "APPLE_NOTARIZATION_TEAM_ID", + teamId, + "Team ID for notarization", + false + )); + } + + // API Key (optional but useful) + if (IdentityState.SelectedIdentity != null) + { + exportedSecrets.Add(new CISecretExport( + "APPLE_API_KEY_ID", + IdentityState.SelectedIdentity.KeyId, + "App Store Connect API Key ID", + false + )); + + exportedSecrets.Add(new CISecretExport( + "APPLE_API_ISSUER_ID", + IdentityState.SelectedIdentity.IssuerId, + "App Store Connect API Issuer ID", + false + )); + } + } + catch (Exception ex) + { + exportError = $"Failed to generate secrets: {ex.Message}"; + Logger.LogError(exportError, ex); + } + finally + { + isLoading = false; + } + } + + private LocalSigningIdentity? FindMatchingIdentity(AppleCertificate cert) + { + return localIdentities.FirstOrDefault(li => + (li.SerialNumber != null && li.SerialNumber.Equals(cert.SerialNumber, StringComparison.OrdinalIgnoreCase)) || + li.CommonName.Contains(cert.Name, StringComparison.OrdinalIgnoreCase) || + cert.Name.Contains(li.CommonName, StringComparison.OrdinalIgnoreCase)); + } + + private async Task CopySecret(CISecretExport secret) + { + await DialogService.CopyToClipboardAsync(secret.Value); + await AlertService.ShowToastAsync($"Copied {secret.Name} to clipboard"); + } + + private async Task CopyAllSecrets() + { + var allSecrets = string.Join("\n", exportedSecrets.Select(s => $"{s.Name}={s.Value}")); + await DialogService.CopyToClipboardAsync(allSecrets); + await AlertService.ShowToastAsync("Copied all secrets to clipboard"); + } + + private async Task DownloadEnvFile() + { + try + { + var content = string.Join("\n", exportedSecrets.Select(s => $"{s.Name}=\"{s.Value}\"")); + var downloadsFolder = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"); + var filePath = Path.Combine(downloadsFolder, "apple-ci-secrets.env"); + + await File.WriteAllTextAsync(filePath, content); + await AlertService.ShowToastAsync($"Saved to {Path.GetFileName(filePath)}"); + } + catch (Exception ex) + { + await AlertService.ShowAlertAsync("Error", $"Failed to save file: {ex.Message}"); + } + } + + private string TruncateValue(string value) + { + if (value.Length <= 60) return value; + return value.Substring(0, 57) + "..."; + } + + private void NavigateTo(string path) + { + Navigation.NavigateTo(path); + _ = Close(); + } + + private void HandleOverlayClick() + { + // Don't close on overlay click during loading + if (!isLoading) + { + _ = Close(); + } + } + + private async Task Close() + { + IsVisible = false; + await IsVisibleChanged.InvokeAsync(false); + + // Reset state + currentStep = 1; + exportedSecrets.Clear(); + exportError = ""; + selectedShell = "bash"; + p12ExportPassword = ""; + } + + private string GetShellLabel() => selectedShell switch + { + "bash" => "Bash / macOS Terminal", + "powershell" => "PowerShell", + "github" => "GitHub Actions Workflow", + _ => "Shell" + }; + + private string GetBuildCommand() + { + var tfm = GetTargetFramework(); + var props = GetMSBuildProperties(); + + return selectedShell switch + { + "bash" => GenerateBashCommand(tfm, props), + "powershell" => GeneratePowerShellCommand(tfm, props), + "github" => GenerateGitHubActionsCommand(tfm, props), + _ => "" + }; + } + + private string GetTargetFramework() => wizardState.Platform switch + { + ApplePlatformType.iOS => "net9.0-ios", + ApplePlatformType.MacCatalyst => "net9.0-maccatalyst", + ApplePlatformType.macOS => "net9.0-macos", + _ => "net9.0-ios" + }; + + private List<(string Property, string EnvVar, string Description)> GetMSBuildProperties() + { + var props = new List<(string Property, string EnvVar, string Description)>(); + + // Code signing identity + if (exportedSecrets.Any(s => s.Name == "APPLE_CODESIGN_IDENTITY")) + { + props.Add(("CodesignKey", "APPLE_CODESIGN_IDENTITY", "Signing identity")); + } + + // Provisioning profile + if (exportedSecrets.Any(s => s.Name == "APPLE_PROVISIONING_PROFILE_NAME")) + { + props.Add(("CodesignProvision", "APPLE_PROVISIONING_PROFILE_NAME", "Provisioning profile")); + } + + // Archive on build for iOS/Mac Catalyst + if (wizardState.Platform is ApplePlatformType.iOS or ApplePlatformType.MacCatalyst) + { + props.Add(("ArchiveOnBuild", "true", "Create archive")); + } + + // Mac Catalyst specific + if (wizardState.Platform == ApplePlatformType.MacCatalyst) + { + if (wizardState.Distribution == AppleDistributionType.AppStore) + { + props.Add(("CreatePackage", "true", "Create .pkg installer")); + } + else if (wizardState.Distribution == AppleDistributionType.Direct && wizardState.NeedsInstallerCert) + { + props.Add(("CreatePackage", "true", "Create .pkg installer")); + if (exportedSecrets.Any(s => s.Name == "APPLE_INSTALLER_CERTIFICATE_P12")) + { + props.Add(("PackageSigningKey", "APPLE_INSTALLER_IDENTITY", "Installer signing identity")); + } + } + } + + // Notarization + if (NeedsNotarization) + { + props.Add(("NotarizeAcUsername", "APPLE_NOTARIZATION_APPLE_ID", "Notarization Apple ID")); + props.Add(("NotarizeAcPassword", "APPLE_NOTARIZATION_PASSWORD", "Notarization password")); + } + + return props; + } + + private string GenerateBashCommand(string tfm, List<(string Property, string EnvVar, string Description)> props) + { + var sb = new System.Text.StringBuilder(); + sb.AppendLine("# Set environment variables first"); + sb.AppendLine("# export APPLE_CODESIGN_IDENTITY=\"Apple Distribution: ...\""); + sb.AppendLine("# export APPLE_PROVISIONING_PROFILE_NAME=\"My Profile\""); + sb.AppendLine(); + sb.AppendLine("dotnet publish -f " + tfm + " -c Release \\"); + + for (int i = 0; i < props.Count; i++) + { + var (property, envVar, _) = props[i]; + var isLast = i == props.Count - 1; + var value = envVar == "true" ? "true" : $"\"${envVar}\""; + sb.AppendLine($" -p:{property}={value}" + (isLast ? "" : " \\")); + } + + return sb.ToString(); + } + + private string GeneratePowerShellCommand(string tfm, List<(string Property, string EnvVar, string Description)> props) + { + var sb = new System.Text.StringBuilder(); + sb.AppendLine("# Set environment variables first"); + sb.AppendLine("# $env:APPLE_CODESIGN_IDENTITY = \"Apple Distribution: ...\""); + sb.AppendLine("# $env:APPLE_PROVISIONING_PROFILE_NAME = \"My Profile\""); + sb.AppendLine(); + sb.AppendLine("dotnet publish -f " + tfm + " -c Release `"); + + for (int i = 0; i < props.Count; i++) + { + var (property, envVar, _) = props[i]; + var isLast = i == props.Count - 1; + var value = envVar == "true" ? "true" : $"\"$env:{envVar}\""; + sb.AppendLine($" -p:{property}={value}" + (isLast ? "" : " `")); + } + + return sb.ToString(); + } + + private string GenerateGitHubActionsCommand(string tfm, List<(string Property, string EnvVar, string Description)> props) + { + var sb = new System.Text.StringBuilder(); + var hasP12 = exportedSecrets.Any(s => s.Name == "APPLE_CERTIFICATE_P12"); + var hasProfile = exportedSecrets.Any(s => s.Name == "APPLE_PROVISIONING_PROFILE"); + var hasInstallerP12 = exportedSecrets.Any(s => s.Name == "APPLE_INSTALLER_CERTIFICATE_P12"); + + sb.AppendLine("# Add these steps to your workflow (runs-on: macos-latest)"); + sb.AppendLine(); + + // Certificate installation step + if (hasP12) + { + sb.AppendLine("- name: Install Apple Certificate"); + sb.AppendLine(" env:"); + sb.AppendLine(" P12_BASE64: ${{ secrets.APPLE_CERTIFICATE_P12 }}"); + sb.AppendLine(" P12_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}"); + sb.AppendLine(" run: |"); + sb.AppendLine(" # Create temporary keychain"); + sb.AppendLine(" KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db"); + sb.AppendLine(" KEYCHAIN_PASSWORD=$(openssl rand -base64 32)"); + sb.AppendLine(" security create-keychain -p \"$KEYCHAIN_PASSWORD\" $KEYCHAIN_PATH"); + sb.AppendLine(" security set-keychain-settings -lut 21600 $KEYCHAIN_PATH"); + sb.AppendLine(" security unlock-keychain -p \"$KEYCHAIN_PASSWORD\" $KEYCHAIN_PATH"); + sb.AppendLine(" "); + sb.AppendLine(" # Import certificate"); + sb.AppendLine(" echo \"$P12_BASE64\" | base64 --decode > $RUNNER_TEMP/certificate.p12"); + sb.AppendLine(" security import $RUNNER_TEMP/certificate.p12 -P \"$P12_PASSWORD\" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH"); + sb.AppendLine(" security set-key-partition-list -S apple-tool:,apple: -k \"$KEYCHAIN_PASSWORD\" $KEYCHAIN_PATH"); + sb.AppendLine(" security list-keychain -d user -s $KEYCHAIN_PATH"); + sb.AppendLine(); + } + + // Installer certificate if needed + if (hasInstallerP12) + { + sb.AppendLine("- name: Install Installer Certificate"); + sb.AppendLine(" env:"); + sb.AppendLine(" P12_BASE64: ${{ secrets.APPLE_INSTALLER_CERTIFICATE_P12 }}"); + sb.AppendLine(" P12_PASSWORD: ${{ secrets.APPLE_INSTALLER_CERTIFICATE_PASSWORD }}"); + sb.AppendLine(" run: |"); + sb.AppendLine(" KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db"); + sb.AppendLine(" KEYCHAIN_PASSWORD=$(cat $RUNNER_TEMP/.keychain_password 2>/dev/null || echo \"\")"); + sb.AppendLine(" echo \"$P12_BASE64\" | base64 --decode > $RUNNER_TEMP/installer.p12"); + sb.AppendLine(" security import $RUNNER_TEMP/installer.p12 -P \"$P12_PASSWORD\" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH"); + sb.AppendLine(); + } + + // Provisioning profile installation step + if (hasProfile) + { + sb.AppendLine("- name: Install Provisioning Profile"); + sb.AppendLine(" env:"); + sb.AppendLine(" PROFILE_BASE64: ${{ secrets.APPLE_PROVISIONING_PROFILE }}"); + sb.AppendLine(" run: |"); + sb.AppendLine(" mkdir -p ~/Library/MobileDevice/Provisioning\\ Profiles"); + sb.AppendLine(" echo \"$PROFILE_BASE64\" | base64 --decode > ~/Library/MobileDevice/Provisioning\\ Profiles/profile.mobileprovision"); + sb.AppendLine(); + } + + // Build step + sb.AppendLine("- name: Publish App"); + sb.AppendLine(" run: |"); + sb.Append(" dotnet publish -f " + tfm + " -c Release"); + + foreach (var (property, envVar, _) in props) + { + var value = envVar == "true" ? "true" : $"\"${{{{ secrets.{envVar} }}}}\""; + sb.Append($" \\\n -p:{property}={value}"); + } + + sb.AppendLine(); + + // Cleanup step + if (hasP12) + { + sb.AppendLine(); + sb.AppendLine("- name: Cleanup Keychain"); + sb.AppendLine(" if: always()"); + sb.AppendLine(" run: |"); + sb.AppendLine(" security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true"); + sb.AppendLine(" rm -f $RUNNER_TEMP/certificate.p12 $RUNNER_TEMP/installer.p12 || true"); + } + + sb.AppendLine(); + sb.AppendLine("# Required GitHub Secrets:"); + foreach (var secret in exportedSecrets.Where(s => s.IsSensitive)) + { + sb.AppendLine($"# - {secret.Name}: {secret.Description}"); + } + + return sb.ToString(); + } + + private async Task CopyBuildCommand() + { + var command = GetBuildCommand(); + await DialogService.CopyToClipboardAsync(command); + await AlertService.ShowToastAsync("Build command copied to clipboard"); + } +} diff --git a/src/MauiSherpa/MauiProgram.cs b/src/MauiSherpa/MauiProgram.cs index d3d94090..5e2f6a9e 100644 --- a/src/MauiSherpa/MauiProgram.cs +++ b/src/MauiSherpa/MauiProgram.cs @@ -65,6 +65,12 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + // Cloud Secrets Storage services + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); // ViewModels builder.Services.AddSingleton(); diff --git a/src/MauiSherpa/Pages/Certificates.razor b/src/MauiSherpa/Pages/Certificates.razor index fa5438db..04137b58 100644 --- a/src/MauiSherpa/Pages/Certificates.razor +++ b/src/MauiSherpa/Pages/Certificates.razor @@ -9,6 +9,11 @@ @inject IAlertService AlertService @inject IDialogService DialogService @inject OperationModalService OperationModal +@inject MultiOperationModalService MultiOpModal +@inject ICertificateSyncService CertificateSyncService +@inject ICloudSecretsService CloudSecretsService +@inject ILocalCertificateService LocalCertificateService +@inject ILoggingService Logger @implements IDisposable

Certificates

@@ -24,8 +29,32 @@ + @if (CloudSecretsService.ActiveProvider != null) + { + + + } + + @if (CloudSecretsService.ActiveProvider == null && !syncHintDismissed) + { +
+
+ +
+ Sync certificates across machines +

Configure a cloud secrets provider (like Infisical) in Settings to securely sync your signing certificates and private keys across all your development machines.

+
+
+ +
+ } +
+ @* Sync actions dropdown - only show if cloud provider is configured *@ + @if (CloudSecretsService.ActiveProvider != null) + { + var currentCertId = cert.Id; + var currentSyncStatus = syncStatus; + + } +
} +@if (showExportDialog && exportCertificate != null) +{ + +} + @code { @@ -469,6 +816,29 @@ private string newCertType = "IOS_DEVELOPMENT"; private string newCertCommonName = ""; private string newCertPassphrase = ""; + + // Export dialog state + private bool showExportDialog = false; + private bool isExporting = false; + private AppleCertificate? exportCertificate = null; + private string exportType = "p12"; + private bool exportWithPassword = false; + private string exportPassword = ""; + private string exportPasswordConfirm = ""; + private IReadOnlyList? localIdentities = null; + + // Sync state + private Dictionary syncStatuses = new(); + private string? openSyncMenuCertId = null; + private bool syncHintDismissed = false; + private const string SyncHintDismissedKey = "CertificatesSyncHintDismissed"; + + private bool CanExport => exportCertificate != null && + !isExporting && + (exportType == "cer" || + (exportType == "p12" && + localIdentities?.Any(i => i.SerialNumber?.Equals(exportCertificate.SerialNumber, StringComparison.OrdinalIgnoreCase) == true) == true && + (!exportWithPassword || (exportPassword == exportPasswordConfirm && !string.IsNullOrEmpty(exportPassword))))); private bool HasActiveFilters => !string.IsNullOrEmpty(searchQuery) || !string.IsNullOrEmpty(filterType) || @@ -485,6 +855,10 @@ .OrderByDescending(c => c.ExpirationDate > DateTime.UtcNow) // Valid first .ThenBy(c => c.ExpirationDate); + // Computed properties for bulk sync buttons + private bool HasCloudOnlyCerts => syncStatuses.Values.Any(s => s.Location == SecretLocation.CloudOnly); + private bool HasLocalOnlyCerts => syncStatuses.Values.Any(s => s.Location == SecretLocation.LocalOnly); + private string GetCertStatus(AppleCertificate cert) { if (cert.ExpirationDate < DateTime.UtcNow) return "expired"; @@ -503,10 +877,27 @@ protected override async Task OnInitializedAsync() { IdentityState.OnSelectionChanged += OnIdentityChanged; + + // Load sync hint dismissed preference + syncHintDismissed = Preferences.Default.Get(SyncHintDismissedKey, false); + + // Initialize cloud secrets service to load active provider + if (CloudSecretsService is MauiSherpa.Core.Services.CloudSecretsService svc) + { + await svc.InitializeAsync(); + } + if (IdentityState.SelectedIdentity != null) await RefreshData(); } + private void DismissSyncHint() + { + syncHintDismissed = true; + Preferences.Default.Set(SyncHintDismissedKey, true); + StateHasChanged(); + } + private void OnIdentityChanged() { InvokeAsync(async () => @@ -529,11 +920,20 @@ errorMessage = ""; try { + // Invalidate local certificate cache on force refresh + if (forceRefresh) + { + LocalCertificateService.InvalidateCache(); + } + var request = new GetCertificatesRequest(IdentityState.SelectedIdentity.Id); var (_, result) = forceRefresh ? await Mediator.Request(request, CancellationToken.None, ctx => ctx.ClearCache()) : await Mediator.Request(request); certificates = result.ToList(); + + // Load sync statuses if cloud provider is configured + await LoadSyncStatuses(); } catch (Exception ex) { @@ -545,6 +945,26 @@ await InvokeAsync(StateHasChanged); } } + + private async Task LoadSyncStatuses() + { + if (CloudSecretsService.ActiveProvider == null || certificates.Count == 0) + { + syncStatuses.Clear(); + return; + } + + try + { + var statuses = await CertificateSyncService.GetCertificateStatusesAsync(certificates); + syncStatuses = statuses.ToDictionary(s => s.CertificateId, s => s); + } + catch (Exception ex) + { + Logger.LogError($"Failed to load sync statuses: {ex.Message}", ex); + syncStatuses.Clear(); + } + } private void ShowCreateDialog() { @@ -558,6 +978,91 @@ { showCreateDialog = false; } + + private async Task ShowExportDialog(AppleCertificate cert) + { + exportCertificate = cert; + exportType = "p12"; + exportWithPassword = false; + exportPassword = ""; + exportPasswordConfirm = ""; + + // Load local identities to check if we have the private key + if (LocalCertificateService.IsSupported) + { + localIdentities = await LocalCertificateService.GetSigningIdentitiesAsync(); + } + + showExportDialog = true; + } + + private void CloseExportDialog() + { + showExportDialog = false; + exportCertificate = null; + } + + private async Task ExportCertificate() + { + if (exportCertificate == null) return; + + isExporting = true; + StateHasChanged(); + + try + { + var cert = exportCertificate; + var fileName = $"{cert.Name?.Replace(" ", "_") ?? "certificate"}_{DateTime.Now:yyyyMMdd}"; + byte[] data; + string extension; + + if (exportType == "p12") + { + extension = ".p12"; + var matchingIdentity = localIdentities?.FirstOrDefault(i => + i.SerialNumber?.Equals(cert.SerialNumber, StringComparison.OrdinalIgnoreCase) == true); + + if (matchingIdentity == null) + { + await AlertService.ShowAlertAsync("Error", "Private key not found in local keychain."); + return; + } + + var password = exportWithPassword ? exportPassword : ""; + data = await LocalCertificateService.ExportP12Async(matchingIdentity.Identity, password); + } + else + { + extension = ".cer"; + data = await LocalCertificateService.ExportCertificateAsync(cert.SerialNumber ?? ""); + } + + // Get save location + var savePath = await DialogService.ShowFileDialogAsync( + "Save Certificate", + isSave: true, + defaultFileName: fileName + extension); + + if (string.IsNullOrEmpty(savePath)) + { + return; // User cancelled + } + + await File.WriteAllBytesAsync(savePath, data); + await AlertService.ShowToastAsync($"Certificate exported to {Path.GetFileName(savePath)}"); + CloseExportDialog(); + } + catch (Exception ex) + { + Logger.LogError($"Export failed: {ex}"); + await AlertService.ShowAlertAsync("Export Failed", ex.Message); + } + finally + { + isExporting = false; + StateHasChanged(); + } + } private async Task CreateCertificate() { @@ -623,21 +1128,491 @@ await RefreshData(forceRefresh: true); } } + + private async Task DeleteLocalCertificate(AppleCertificate cert) + { + openSyncMenuCertId = null; + + if (!LocalCertificateService.IsSupported) + { + await AlertService.ShowAlertAsync("Not Supported", "Certificate deletion is only supported on macOS."); + return; + } + + // Confirm deletion + var confirmed = await AlertService.ShowConfirmAsync( + "Delete from Keychain", + $"Are you sure you want to delete '{cert.Name}' from your local keychain?\n\nThis will remove both the certificate and its private key. This action cannot be undone."); + + if (!confirmed) + return; + + try + { + // Find the matching local identity + var identities = await LocalCertificateService.GetSigningIdentitiesAsync(); + var matchingIdentity = identities.FirstOrDefault(i => + i.SerialNumber?.Equals(cert.SerialNumber, StringComparison.OrdinalIgnoreCase) == true); + + if (matchingIdentity == null) + { + await AlertService.ShowAlertAsync("Not Found", "Certificate not found in local keychain."); + return; + } + + // Extract the common name from the identity for deletion + // Identity looks like: "Apple Development: John Doe (TEAMID)" + var commonName = matchingIdentity.Identity; + + await LocalCertificateService.DeleteCertificateAsync(commonName); + await AlertService.ShowToastAsync("Certificate deleted from keychain"); + + // Invalidate cache and refresh sync statuses + LocalCertificateService.InvalidateCache(); + await LoadSyncStatuses(); + StateHasChanged(); + } + catch (Exception ex) + { + Logger.LogError($"Failed to delete certificate: {ex}"); + await AlertService.ShowAlertAsync("Delete Failed", ex.Message); + } + } + + private async Task DeleteFromCloud(AppleCertificate cert) + { + openSyncMenuCertId = null; + + if (string.IsNullOrEmpty(cert.SerialNumber)) + { + await AlertService.ShowAlertAsync("Error", "Certificate serial number is not available."); + return; + } + + // Confirm deletion + var confirmed = await AlertService.ShowConfirmAsync( + "Delete from Cloud", + $"Are you sure you want to delete '{cert.Name}' from cloud storage?\n\nThis will remove the private key from your cloud secrets provider. This action cannot be undone."); + + if (!confirmed) + return; + + try + { + var success = await CertificateSyncService.DeleteFromCloudAsync(cert.SerialNumber); + + if (success) + { + await AlertService.ShowToastAsync("Certificate deleted from cloud"); + + // Refresh sync statuses + await LoadSyncStatuses(); + StateHasChanged(); + } + else + { + await AlertService.ShowAlertAsync("Delete Failed", "Failed to delete certificate from cloud storage."); + } + } + catch (Exception ex) + { + Logger.LogError($"Failed to delete certificate from cloud: {ex}"); + await AlertService.ShowAlertAsync("Delete Failed", ex.Message); + } + } + + private async Task SyncAllFromCloud() + { + // Get all cloud-only certificates + var cloudOnlyCerts = certificates + .Where(c => syncStatuses.TryGetValue(c.Id, out var status) && status.Location == SecretLocation.CloudOnly) + .ToList(); + + if (cloudOnlyCerts.Count == 0) + { + await AlertService.ShowToastAsync("No certificates to install from cloud"); + return; + } + + var operations = cloudOnlyCerts.Select(cert => new OperationItem( + Id: cert.Id, + Name: cert.Name, + Description: $"{FormatCertType(cert.CertificateType ?? "Unknown")} - {cert.SerialNumber}", + Execute: async ctx => + { + ctx.LogInfo($"Installing {cert.Name} from cloud..."); + + try + { + var success = await CertificateSyncService.DownloadAndInstallAsync(cert.Id); + + if (success) + { + ctx.LogSuccess($"Installed {cert.Name} to local keychain"); + return true; + } + else + { + ctx.LogError($"Failed to install {cert.Name}"); + return false; + } + } + catch (Exception ex) + { + ctx.LogError($"Error installing {cert.Name}: {ex.Message}"); + return false; + } + }, + IsEnabled: true, + CanDisable: true + )).ToList(); + + var result = await MultiOpModal.RunAsync( + "Install Certificates from Cloud", + $"The following {cloudOnlyCerts.Count} certificate(s) will be installed to your local keychain. Uncheck any you want to skip.", + operations); + + if (result.Completed > 0) + { + // Refresh local identities cache and sync statuses + LocalCertificateService.InvalidateCache(); + await LoadSyncStatuses(); + StateHasChanged(); + } + } + + private async Task SyncAllToCloud() + { + // Get all local-only certificates + var localOnlyCerts = certificates + .Where(c => syncStatuses.TryGetValue(c.Id, out var status) && status.Location == SecretLocation.LocalOnly) + .ToList(); + + if (localOnlyCerts.Count == 0) + { + await AlertService.ShowToastAsync("No certificates to upload to cloud"); + return; + } + + // Get local identities for P12 export + var identities = await LocalCertificateService.GetSigningIdentitiesAsync(); + + var operations = localOnlyCerts.Select(cert => new OperationItem( + Id: cert.Id, + Name: cert.Name, + Description: $"{FormatCertType(cert.CertificateType ?? "Unknown")} - {cert.SerialNumber}", + Execute: async ctx => + { + ctx.LogInfo($"Uploading {cert.Name} to cloud..."); + + try + { + // Find matching local identity + var identity = identities.FirstOrDefault(i => + i.SerialNumber?.Equals(cert.SerialNumber, StringComparison.OrdinalIgnoreCase) == true); + + if (identity == null) + { + ctx.LogError($"Could not find local identity for {cert.Name}"); + return false; + } + + // Generate a random password for the P12 + var password = GenerateRandomPassword(); + + // Export P12 from keychain + ctx.LogInfo("Exporting certificate from keychain..."); + var p12Data = await LocalCertificateService.ExportP12Async(identity.Identity, password); + + // Upload to cloud + ctx.LogInfo("Uploading to cloud storage..."); + var metadata = new CertificateSecretMetadata( + CertificateId: cert.Id, + SerialNumber: cert.SerialNumber ?? "", + CommonName: cert.Name, + CertificateType: cert.CertificateType ?? "Unknown", + ExpirationDate: cert.ExpirationDate, + CreatedByMachine: Environment.MachineName, + CreatedAt: DateTime.UtcNow + ); + + var success = await CertificateSyncService.UploadToCloudAsync(cert, p12Data, password, metadata); + + if (success) + { + ctx.LogSuccess($"Uploaded {cert.Name} to cloud"); + return true; + } + else + { + ctx.LogError($"Failed to upload {cert.Name}"); + return false; + } + } + catch (Exception ex) + { + ctx.LogError($"Error uploading {cert.Name}: {ex.Message}"); + return false; + } + }, + IsEnabled: true, + CanDisable: true + )).ToList(); + + var result = await MultiOpModal.RunAsync( + "Upload Certificates to Cloud", + $"The following {localOnlyCerts.Count} certificate(s) will be uploaded to cloud storage. Uncheck any you want to skip.", + operations); + + if (result.Completed > 0) + { + await LoadSyncStatuses(); + StateHasChanged(); + } + } - private string FormatCertType(string certType) + private string FormatCertType(string? certType) { + if (string.IsNullOrEmpty(certType)) + return "Unknown"; + return certType.Replace("_", " ").ToLowerInvariant() switch { var t when t.Contains("development") => "Development", var t when t.Contains("distribution") => "Distribution", var t when t.Contains("push") => "Push Notification", + var t when t.Contains("developer id application") => "Developer ID App", + var t when t.Contains("developer id installer") => "Developer ID Installer", + var t when t.Contains("developer id") => "Developer ID", _ => certType.Replace("_", " ") }; } + private string FormatPlatform(string? platform) + { + if (string.IsNullOrEmpty(platform) || platform.Equals("Unknown", StringComparison.OrdinalIgnoreCase)) + return ""; + + return platform.ToUpperInvariant() switch + { + "IOS" => "iOS", + "MAC_OS" or "MACOS" => "macOS", + "TV_OS" or "TVOS" => "tvOS", + "VISION_OS" or "VISIONOS" => "visionOS", + "UNIVERSAL" => "Universal", + _ => platform.Replace("_", " ") // Fall back to cleaned-up original value + }; + } + + private static string GenerateRandomPassword() + { + return Convert.ToBase64String(Guid.NewGuid().ToByteArray())[..16]; + } + private async Task CopyToClipboard(string text) { await DialogService.CopyToClipboardAsync(text); await AlertService.ShowToastAsync("Copied to clipboard"); } + + // Sync helper methods + + private SecretLocation GetSyncStatus(AppleCertificate cert) + { + if (syncStatuses.TryGetValue(cert.Id ?? "", out var status)) + return status.Location; + return SecretLocation.None; + } + + private string GetSyncBadgeClass(SecretLocation location) => location switch + { + SecretLocation.Both => "badge-sync-both", + SecretLocation.LocalOnly => "badge-sync-local", + SecretLocation.CloudOnly => "badge-sync-cloud", + _ => "badge-sync-none" + }; + + private string GetSyncStatusIcon(SecretLocation location) => location switch + { + SecretLocation.Both => "🟢", + SecretLocation.LocalOnly => "🔵", + SecretLocation.CloudOnly => "🟡", + _ => "⚫" + }; + + private string GetSyncStatusText(SecretLocation location) => location switch + { + SecretLocation.Both => "Synced", + SecretLocation.LocalOnly => "Local", + SecretLocation.CloudOnly => "Cloud", + _ => "No Key" + }; + + private string GetSyncStatusTooltip(SecretLocation location) => location switch + { + SecretLocation.Both => "Private key exists locally and in cloud storage", + SecretLocation.LocalOnly => "Private key exists locally only - can upload to cloud", + SecretLocation.CloudOnly => "Private key in cloud only - can install locally", + _ => "No private key available - cannot sign with this certificate" + }; + + private void ToggleSyncMenu(string? certId) + { + openSyncMenuCertId = openSyncMenuCertId == certId ? null : certId; + } + + private async void HandleUploadClick(string certId) + { + try + { + Logger.LogInformation($"HandleUploadClick called for cert ID: {certId}"); + var cert = certificates?.FirstOrDefault(c => c.Id == certId); + if (cert != null) + { + await UploadToCloud(cert); + } + else + { + await AlertService.ShowAlertAsync("Error", $"Could not find cert with ID: {certId}"); + } + } + catch (Exception ex) + { + Logger.LogError($"HandleUploadClick error: {ex}"); + await AlertService.ShowAlertAsync("Error", $"Upload failed: {ex.Message}"); + } + } + + private void HandleInstallClick(string certId) + { + Logger.LogInformation($"HandleInstallClick called for cert ID: {certId}"); + var cert = certificates?.FirstOrDefault(c => c.Id == certId); + if (cert != null) + { + _ = InstallLocally(cert); + } + } + + private async Task UploadToCloud(AppleCertificate cert) + { + try + { + Logger.LogInformation($"UploadToCloud called for cert: {cert.Id} ({cert.Name})"); + openSyncMenuCertId = null; + StateHasChanged(); + + // We need to get the P12 from the local keychain + if (!LocalCertificateService.IsSupported) + { + await AlertService.ShowAlertAsync("Not Supported", "Certificate export is only supported on macOS."); + return; + } + + // Get the local signing identities to find the matching one + Logger.LogInformation($"Looking for identity with serial: {cert.SerialNumber}"); + var identities = await LocalCertificateService.GetSigningIdentitiesAsync(); + var matchingIdentity = identities.FirstOrDefault(i => + i.SerialNumber?.Equals(cert.SerialNumber, StringComparison.OrdinalIgnoreCase) == true); + + if (matchingIdentity == null) + { + Logger.LogWarning($"No matching identity found for serial {cert.SerialNumber}"); + await AlertService.ShowAlertAsync("Not Found", "Could not find the private key for this certificate in your local keychain."); + return; + } + + Logger.LogInformation($"Found matching identity: {matchingIdentity.Identity}"); + + // Generate a random password for the P12 - user doesn't need to know it since we store it in cloud + var password = Convert.ToBase64String(Guid.NewGuid().ToByteArray())[..16]; + + var result = await OperationModal.RunAsync( + "Upload to Cloud", + $"Exporting and uploading certificate...", + async ctx => + { + ctx.LogInfo("Exporting certificate from keychain..."); + var p12Data = await LocalCertificateService.ExportP12Async(matchingIdentity.Identity, password); + + ctx.LogInfo("Uploading to cloud storage..."); + var metadata = new CertificateSecretMetadata( + cert.Id ?? "", + cert.SerialNumber ?? "", + cert.Name ?? "", + cert.CertificateType ?? "", + cert.ExpirationDate, + Environment.MachineName, + DateTime.UtcNow + ); + + var success = await CertificateSyncService.UploadToCloudAsync(cert, p12Data, password, metadata); + if (!success) + throw new Exception("Failed to upload certificate to cloud storage"); + + return true; + }, + canCancel: false); + + if (result.Success) + { + await AlertService.ShowToastAsync("Certificate uploaded to cloud"); + await LoadSyncStatuses(); + StateHasChanged(); + } + } + catch (Exception ex) + { + Logger.LogError($"Error in UploadToCloud: {ex}"); + await AlertService.ShowAlertAsync("Error", $"Upload failed: {ex.Message}"); + } + } + + private async Task InstallLocally(AppleCertificate cert) + { + openSyncMenuCertId = null; + + if (!LocalCertificateService.IsSupported) + { + await AlertService.ShowAlertAsync("Not Supported", "Certificate installation is only supported on macOS."); + return; + } + + Logger.LogInformation($"InstallLocally starting for cert: {cert.Id}, serial: {cert.SerialNumber}"); + + var result = await OperationModal.RunAsync( + "Install Certificate", + $"Downloading and installing certificate...", + async ctx => + { + ctx.LogInfo($"Certificate: {cert.Name}"); + ctx.LogInfo($"Serial: {cert.SerialNumber}"); + ctx.LogInfo("Downloading from cloud storage..."); + + // Use the CertificateSyncService to download and install + if (CertificateSyncService is MauiSherpa.Core.Services.CertificateSyncService syncService) + { + var success = await syncService.DownloadAndInstallBySerialAsync(cert.SerialNumber ?? ""); + if (!success) + { + ctx.LogError("Failed to download and install certificate"); + throw new Exception("Failed to download and install certificate - check logs for details"); + } + ctx.LogSuccess("Certificate installed successfully"); + return true; + } + else + { + throw new Exception("Sync service not available"); + } + }, + canCancel: false); + + if (result.Success) + { + await AlertService.ShowToastAsync("Certificate installed to local keychain"); + LocalCertificateService.InvalidateCache(); + await LoadSyncStatuses(); + StateHasChanged(); + } + } } diff --git a/src/MauiSherpa/Pages/ProvisioningProfiles.razor b/src/MauiSherpa/Pages/ProvisioningProfiles.razor index 19c4b1bc..186f8a02 100644 --- a/src/MauiSherpa/Pages/ProvisioningProfiles.razor +++ b/src/MauiSherpa/Pages/ProvisioningProfiles.razor @@ -31,6 +31,10 @@ Install All (@GetInstallableProfiles().Count()) } +
+
@@ -187,11 +191,13 @@ + @code { @@ -501,12 +849,30 @@ private string p8InputMode = "file"; // "file" or "text" private bool isLoadingSdk = true; + + // Cloud provider state + private List cloudProviders = new(); + private bool showProviderDialog = false; + private CloudSecretsProviderConfig? editingProvider = null; + private string providerName = ""; + private CloudSecretsProviderType providerType = CloudSecretsProviderType.Infisical; + private Dictionary providerSettings = new(); private bool CanSaveIdentity => !string.IsNullOrWhiteSpace(identityName) && !string.IsNullOrWhiteSpace(identityKeyId) && !string.IsNullOrWhiteSpace(identityIssuerId) && !string.IsNullOrWhiteSpace(identityP8Content); + + private bool CanSaveProvider + { + get + { + if (string.IsNullOrWhiteSpace(providerName)) return false; + var requiredSettings = GetCurrentProviderSettings().Where(s => s.IsRequired); + return requiredSettings.All(s => !string.IsNullOrWhiteSpace(providerSettings.GetValueOrDefault(s.Key))); + } + } protected override async Task OnInitializedAsync() { @@ -516,7 +882,11 @@ // Subscribe to SDK path changes SdkSettings.SdkPathChanged += OnSdkPathChanged; + // Subscribe to cloud provider changes + CloudSecretsService.OnActiveProviderChanged += OnCloudProviderChanged; + await LoadAppleIdentities(); + await LoadCloudProviders(); await InitializeSdk(); } @@ -543,16 +913,33 @@ { InvokeAsync(StateHasChanged); } + + private void OnCloudProviderChanged() + { + InvokeAsync(StateHasChanged); + } public void Dispose() { SdkSettings.SdkPathChanged -= OnSdkPathChanged; + CloudSecretsService.OnActiveProviderChanged -= OnCloudProviderChanged; } private async Task LoadAppleIdentities() { appleIdentities = (await AppleIdentityService.GetIdentitiesAsync()).ToList(); } + + private async Task LoadCloudProviders() + { + cloudProviders = (await CloudSecretsService.GetProvidersAsync()).ToList(); + + // Initialize service if needed + if (CloudSecretsService is CloudSecretsService svc) + { + await svc.InitializeAsync(); + } + } private void OnThemeChanged(ChangeEventArgs e) { @@ -718,4 +1105,159 @@ await DialogService.CopyToClipboardAsync(text); await AlertService.ShowToastAsync("Copied to clipboard"); } + + // Cloud Provider Methods + + private string GetProviderIcon(CloudSecretsProviderType type) => type switch + { + CloudSecretsProviderType.Infisical => "fas fa-shield-alt", + CloudSecretsProviderType.AzureKeyVault => "fas fa-key", + CloudSecretsProviderType.AwsSecretsManager => "fab fa-aws", + CloudSecretsProviderType.GoogleSecretManager => "fab fa-google", + _ => "fas fa-lock" + }; + + private string? GetProviderUrl(CloudSecretsProviderConfig provider) + { + return provider.ProviderType switch + { + CloudSecretsProviderType.Infisical => GetInfisicalUrl(provider), + CloudSecretsProviderType.AzureKeyVault => GetAzureKeyVaultUrl(provider), + CloudSecretsProviderType.AwsSecretsManager => GetAwsSecretsManagerUrl(provider), + CloudSecretsProviderType.GoogleSecretManager => GetGoogleSecretManagerUrl(provider), + _ => null + }; + } + + private string? GetInfisicalUrl(CloudSecretsProviderConfig provider) + { + var siteUrl = provider.Settings.GetValueOrDefault("SiteUrl", "https://app.infisical.com").TrimEnd('/'); + var projectId = provider.Settings.GetValueOrDefault("ProjectId", ""); + var environment = provider.Settings.GetValueOrDefault("Environment", "prod"); + + if (string.IsNullOrEmpty(projectId)) + return siteUrl; + + return $"{siteUrl}/projects/secret-management/{projectId}/overview"; + } + + private string? GetAzureKeyVaultUrl(CloudSecretsProviderConfig provider) + { + var vaultUrl = provider.Settings.GetValueOrDefault("VaultUrl", "").TrimEnd('/'); + + if (string.IsNullOrEmpty(vaultUrl)) + return null; + + // Extract vault name from URL like https://my-vault.vault.azure.net + try + { + var uri = new Uri(vaultUrl); + var vaultName = uri.Host.Split('.')[0]; + // Link to Azure Portal for the Key Vault + return $"https://portal.azure.com/#view/Microsoft_Azure_KeyVault/VaultBlade/vaultName/{vaultName}"; + } + catch + { + return vaultUrl; + } + } + + private string? GetAwsSecretsManagerUrl(CloudSecretsProviderConfig provider) + { + var region = provider.Settings.GetValueOrDefault("Region", "us-east-1"); + // Link to AWS Secrets Manager console for the region + return $"https://{region}.console.aws.amazon.com/secretsmanager/listsecrets?region={region}"; + } + + private string? GetGoogleSecretManagerUrl(CloudSecretsProviderConfig provider) + { + var projectId = provider.Settings.GetValueOrDefault("ProjectId", ""); + if (string.IsNullOrEmpty(projectId)) + return null; + // Link to Google Cloud Console Secret Manager for the project + return $"https://console.cloud.google.com/security/secret-manager?project={projectId}"; + } + + private IReadOnlyList GetCurrentProviderSettings() + { + return CloudSecretsProviderFactory.GetProviderSettings(providerType); + } + + private void ShowAddProviderDialog() + { + editingProvider = null; + providerName = ""; + providerType = CloudSecretsProviderFactory.SupportedProviders.FirstOrDefault(); + providerSettings = new Dictionary(); + + // Initialize with default values + foreach (var setting in GetCurrentProviderSettings()) + { + if (!string.IsNullOrEmpty(setting.DefaultValue)) + { + providerSettings[setting.Key] = setting.DefaultValue; + } + } + + showProviderDialog = true; + } + + private void EditProvider(CloudSecretsProviderConfig provider) + { + editingProvider = provider; + providerName = provider.Name; + providerType = provider.ProviderType; + providerSettings = new Dictionary(provider.Settings); + showProviderDialog = true; + } + + private void CloseProviderDialog() + { + showProviderDialog = false; + editingProvider = null; + } + + private async Task SaveProvider() + { + var config = new CloudSecretsProviderConfig( + editingProvider?.Id ?? Guid.NewGuid().ToString("N"), + providerName, + providerType, + new Dictionary(providerSettings) + ); + + await CloudSecretsService.SaveProviderAsync(config); + await AlertService.ShowToastAsync($"Provider '{providerName}' saved"); + CloseProviderDialog(); + await LoadCloudProviders(); + } + + private async Task DeleteProvider(CloudSecretsProviderConfig provider) + { + var confirm = await AlertService.ShowConfirmAsync( + "Delete Provider", + $"Are you sure you want to delete '{provider.Name}'?"); + + if (!confirm) return; + + await CloudSecretsService.DeleteProviderAsync(provider.Id); + await AlertService.ShowToastAsync("Provider deleted"); + await LoadCloudProviders(); + } + + private async Task TestProvider(CloudSecretsProviderConfig provider) + { + var success = await CloudSecretsService.TestProviderConnectionAsync(provider.Id); + if (success) + await AlertService.ShowToastAsync("Connection successful!"); + else + await AlertService.ShowAlertAsync("Connection Failed", "Could not connect to the cloud provider. Check your credentials and settings."); + } + + private async Task SetActiveProvider(CloudSecretsProviderConfig provider) + { + await CloudSecretsService.SetActiveProviderAsync(provider.Id); + await AlertService.ShowToastAsync($"'{provider.Name}' is now the active provider"); + StateHasChanged(); + } } diff --git a/src/MauiSherpa/Services/DialogService.cs b/src/MauiSherpa/Services/DialogService.cs index 8af5336f..6a1ab709 100644 --- a/src/MauiSherpa/Services/DialogService.cs +++ b/src/MauiSherpa/Services/DialogService.cs @@ -19,14 +19,64 @@ public Task HideLoadingAsync() return Task.CompletedTask; } - public Task ShowInputDialogAsync(string title, string message, string placeholder = "") + public async Task ShowInputDialogAsync(string title, string message, string placeholder = "") { - return Task.FromResult(null); +#if MACCATALYST + var tcs = new TaskCompletionSource(); + + await MainThread.InvokeOnMainThreadAsync(() => + { + var alertController = UIAlertController.Create(title, message, UIAlertControllerStyle.Alert); + + alertController.AddTextField(textField => + { + textField.Placeholder = placeholder; + textField.SecureTextEntry = title.Contains("Password", StringComparison.OrdinalIgnoreCase); + }); + + alertController.AddAction(UIAlertAction.Create("Cancel", UIAlertActionStyle.Cancel, _ => + { + tcs.TrySetResult(null); + })); + + alertController.AddAction(UIAlertAction.Create("OK", UIAlertActionStyle.Default, _ => + { + var text = alertController.TextFields?.FirstOrDefault()?.Text; + tcs.TrySetResult(text); + })); + + var viewController = Microsoft.Maui.ApplicationModel.Platform.GetCurrentUIViewController(); + viewController?.PresentViewController(alertController, true, null); + }); + + return await tcs.Task; +#else + // Windows implementation using ContentDialog would go here + return await Task.FromResult(null); +#endif } - public Task ShowFileDialogAsync(string title, bool isSave = false, string[]? filters = null) + public async Task ShowFileDialogAsync(string title, bool isSave = false, string[]? filters = null, string? defaultFileName = null) { - return Task.FromResult(null); +#if MACCATALYST + if (isSave) + { + // For save, we use a folder picker and then append the filename + var folder = await PickFolderAsync(title); + if (folder != null && !string.IsNullOrEmpty(defaultFileName)) + { + return Path.Combine(folder, defaultFileName); + } + return folder; + } + else + { + // TODO: Implement file open picker + return null; + } +#else + return await Task.FromResult(null); +#endif } public async Task PickFolderAsync(string title)