mirror of
https://github.com/blacktop/ipsw.git
synced 2026-06-07 12:27:36 +00:00
950 lines
35 KiB
Go
950 lines
35 KiB
Go
package appstore
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/base64"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/apex/log"
|
|
"github.com/blacktop/go-plist"
|
|
"github.com/blacktop/ipsw/internal/certs"
|
|
"github.com/blacktop/ipsw/internal/utils"
|
|
"github.com/fullsailor/pkcs7"
|
|
"software.sslmate.com/src/go-pkcs12"
|
|
)
|
|
|
|
const rsaKeySize = 2048 // Standard key size
|
|
|
|
type ProvisionSigningFilesConfig struct {
|
|
CertType string
|
|
Platform string // "ios" (default), "macos", "tvos", "catalyst"
|
|
BundleID string
|
|
CSR bool
|
|
Email string
|
|
Country string
|
|
Install bool
|
|
KeepKey bool
|
|
Output string
|
|
}
|
|
|
|
// ProvisionSigningFiles retrieves or creates, then installs, the necessary
|
|
// certificate and provisioning profile for iOS development signing.
|
|
func (as *AppStore) ProvisionSigningFiles(conf *ProvisionSigningFilesConfig) error {
|
|
if as.token == "" {
|
|
log.Info("Authenticating with App Store Connect API...")
|
|
if err := as.createToken(defaultJWTLife); err != nil {
|
|
return fmt.Errorf("failed to create API token: %w", err)
|
|
}
|
|
}
|
|
|
|
as.conf = conf
|
|
|
|
// Determine required certificate type and profile type based on input
|
|
var requiredCertType CertificateType
|
|
var requiredProfileType ProfileType
|
|
var typeLabel string
|
|
|
|
platform := strings.ToLower(conf.Platform)
|
|
if platform == "" {
|
|
platform = "ios"
|
|
}
|
|
|
|
switch strings.ToLower(conf.CertType) {
|
|
case "development":
|
|
requiredCertType = CT_DEVELOPMENT
|
|
switch platform {
|
|
case "macos":
|
|
requiredProfileType = MAC_APP_DEVELOPMENT
|
|
case "tvos":
|
|
requiredProfileType = TVOS_APP_DEVELOPMENT
|
|
case "catalyst":
|
|
requiredProfileType = MAC_CATALYST_APP_DEVELOPMENT
|
|
default:
|
|
requiredProfileType = IOS_APP_DEVELOPMENT
|
|
}
|
|
typeLabel = "Development"
|
|
case "adhoc":
|
|
requiredCertType = CT_DISTRIBUTION
|
|
switch platform {
|
|
case "tvos":
|
|
requiredProfileType = TVOS_APP_ADHOC
|
|
default:
|
|
requiredProfileType = IOS_APP_ADHOC
|
|
}
|
|
typeLabel = "AdHoc"
|
|
case "distribution":
|
|
requiredCertType = CT_DISTRIBUTION
|
|
switch platform {
|
|
case "macos":
|
|
requiredProfileType = MAC_APP_STORE
|
|
case "tvos":
|
|
requiredProfileType = TVOS_APP_STORE
|
|
case "catalyst":
|
|
requiredProfileType = MAC_CATALYST_APP_STORE
|
|
default:
|
|
requiredProfileType = IOS_APP_STORE
|
|
}
|
|
typeLabel = "AppStore"
|
|
default:
|
|
return fmt.Errorf("invalid certificate type specified: %s", conf.CertType)
|
|
}
|
|
|
|
log.Infof("Checking for %s Certificate...", typeLabel)
|
|
certResource, generatedKeyPath, err := as.ensureCertificate(requiredCertType, typeLabel, conf.Output, conf.CSR)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to ensure %s certificate: %w", typeLabel, err)
|
|
}
|
|
|
|
certFilename := fmt.Sprintf("%s_%s.cer", strings.ToLower(typeLabel), certResource.ID)
|
|
certPath := filepath.Join(conf.Output, certFilename)
|
|
if err = os.WriteFile(certPath, certResource.Attributes.CertificateContent, 0644); err != nil {
|
|
// Attempt cleanup if key was generated
|
|
if generatedKeyPath != "" {
|
|
os.Remove(generatedKeyPath)
|
|
}
|
|
return fmt.Errorf("failed saving %s certificate file %s: %w", typeLabel, certPath, err)
|
|
}
|
|
log.Infof("%s Certificate ID: %s (Saved to %s)", typeLabel, certResource.ID, certPath)
|
|
// Bundle cert+key as P12 and optionally install the identity into Keychain.
|
|
// This runs BEFORE the profile step so that even if profile creation fails,
|
|
// the codesigning identity is available for manual use.
|
|
if generatedKeyPath != "" {
|
|
p12Filename := fmt.Sprintf("%s_%s.p12", strings.ToLower(typeLabel), certResource.ID)
|
|
p12Path := filepath.Join(conf.Output, p12Filename)
|
|
if err := bundlePKCS12(certPath, generatedKeyPath, p12Path, "ipsw"); err != nil {
|
|
log.Warnf("Failed to bundle cert+key as P12: %v (cert and key saved separately)", err)
|
|
log.Warnf("‼️ Private key saved to %s — secure or delete after manual import", generatedKeyPath)
|
|
} else {
|
|
log.Infof("Certificate + key bundled as %s", p12Path)
|
|
if conf.Install {
|
|
if err := installP12(p12Path, "ipsw"); err != nil {
|
|
log.Warnf("Failed to install P12 into Keychain: %v", err)
|
|
utils.Indent(log.Warn, 2)("Install manually: security import " + p12Path + " -k login.keychain-db -P ipsw -T /usr/bin/codesign")
|
|
log.Warnf("‼️ Private key files retained at %s and %s (p12 password: \"ipsw\")", generatedKeyPath, p12Path)
|
|
} else {
|
|
log.Infof("Certificate + key identity installed into Keychain")
|
|
if !conf.KeepKey {
|
|
os.Remove(generatedKeyPath)
|
|
os.Remove(p12Path)
|
|
generatedKeyPath = "" // nothing on disk to reference
|
|
log.Infof("Removed on-disk private key files (Keychain is canonical). Use --keep-key to retain.")
|
|
} else {
|
|
log.Warnf("‼️ Private key files retained at %s and %s (p12 password: \"ipsw\"). Back up offline then delete.",
|
|
generatedKeyPath, p12Path)
|
|
}
|
|
}
|
|
if err := ensureWWDRG3(); err != nil {
|
|
log.Warnf("Failed to install Apple WWDR G3 intermediate: %v", err)
|
|
utils.Indent(log.Warn, 2)("Download from https://www.apple.com/certificateauthority/AppleWWDRCAG3.cer and import manually")
|
|
}
|
|
} else {
|
|
log.Warnf("‼️ Private key files saved at %s and %s (p12 password: \"ipsw\"). Secure or delete after import.",
|
|
generatedKeyPath, p12Path)
|
|
}
|
|
}
|
|
} else if conf.Install {
|
|
// No generated key — install cert only (user provided their own key)
|
|
if err := InstallCertificateAndKey(certPath, ""); err != nil {
|
|
log.Warnf("Failed to install certificate into Keychain: %v", err)
|
|
} else {
|
|
log.Infof("Certificate %s installed into login keychain.", certFilename)
|
|
}
|
|
if err := ensureWWDRG3(); err != nil {
|
|
log.Warnf("Failed to install Apple WWDR G3 intermediate: %v", err)
|
|
utils.Indent(log.Warn, 2)("Download from https://www.apple.com/certificateauthority/AppleWWDRCAG3.cer and import manually")
|
|
}
|
|
}
|
|
|
|
log.Infof("Checking for %s Provisioning Profile...", typeLabel)
|
|
profileResource, err := as.ensureProvisioningProfile(conf.BundleID, certResource.ID, requiredProfileType, typeLabel, platform)
|
|
if err != nil {
|
|
if generatedKeyPath != "" {
|
|
log.Warnf("Profile step failed but cert + key are saved at %s and %s; "+
|
|
"retry with `ipsw appstore profile create`", certPath, generatedKeyPath)
|
|
} else if conf.Install {
|
|
log.Warnf("Profile step failed. Cert identity is in Keychain; "+
|
|
"retry with `ipsw appstore profile create --bundle-id %s --certs %s`",
|
|
conf.BundleID, certResource.ID)
|
|
} else {
|
|
log.Warnf("Profile step failed but cert is saved at %s; "+
|
|
"retry with `ipsw appstore profile create`", certPath)
|
|
}
|
|
return fmt.Errorf("failed to ensure %s provisioning profile: %w", typeLabel, err)
|
|
}
|
|
|
|
profileFilename := fmt.Sprintf("ipsw_%s_%s.mobileprovision", typeLabel, conf.BundleID)
|
|
profilePath := filepath.Join(conf.Output, profileFilename)
|
|
if err = os.WriteFile(profilePath, profileResource.Attributes.ProfileContent, 0644); err != nil {
|
|
return fmt.Errorf("failed saving %s provisioning profile file %s: %w", typeLabel, profilePath, err)
|
|
}
|
|
log.Infof("%s Provisioning Profile ID: %s (Saved to %s)", typeLabel, profileResource.ID, profilePath)
|
|
|
|
if conf.Install {
|
|
log.Info("Installing Provisioning Profile...")
|
|
if installedName, err := InstallProvisioningProfile(profilePath); err != nil {
|
|
log.Warnf("Failed to install provisioning profile: %v", err)
|
|
utils.Indent(log.Warn, 2)("You may need to install it manually by copying it to ~/Library/MobileDevice/Provisioning Profiles/ (using the profile's UUID as filename.mobileprovision)")
|
|
} else {
|
|
log.Infof("Provisioning Profile installed: %s", installedName)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ensureCertificate finds a valid certificate of the required type or creates a new one.
|
|
// Takes the required CertificateType constant and a label for logging/filenames.
|
|
func (as *AppStore) ensureCertificate(requiredCertType CertificateType, typeLabel, outputDir string, newCSR bool) (certRes *Certificate, keyPath string, err error) {
|
|
if !newCSR {
|
|
log.Debugf("Fetching all certificates...")
|
|
allCerts, err := as.GetCertificates()
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("getting certificates: %w", err)
|
|
}
|
|
log.Debugf("Retrieved %d certificates. Filtering for valid %s certs...", len(allCerts), typeLabel)
|
|
for _, cert := range allCerts {
|
|
// Check if it's the required type
|
|
if cert.Attributes.CertificateType != requiredCertType {
|
|
continue // Skip wrong types
|
|
}
|
|
// Check if the certificate is valid (not expired and more than 30 days left)
|
|
if time.Time(cert.Attributes.ExpirationDate).After(time.Now().Add(30 * 24 * time.Hour)) {
|
|
log.WithFields(log.Fields{
|
|
"id": cert.ID,
|
|
"name": cert.Attributes.Name,
|
|
"serial": cert.Attributes.SerialNumber,
|
|
}).Infof("Found valid existing %s", typeLabel)
|
|
return &cert, "", nil // No key generated
|
|
}
|
|
log.WithFields(log.Fields{
|
|
"id": cert.ID,
|
|
"name": cert.Attributes.Name,
|
|
"expires": cert.Attributes.ExpirationDate.Format(time.RFC1123),
|
|
}).Debugf("Found %s expired/expiring soon", typeLabel)
|
|
}
|
|
log.Infof("No valid existing %s certificate found. Creating a new one...", typeLabel)
|
|
} else {
|
|
log.Infof("Forcing creation of a new %s certificate...", typeLabel)
|
|
}
|
|
|
|
log.Debug("Generating RSA private key...")
|
|
privateKey, err := rsa.GenerateKey(rand.Reader, rsaKeySize)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("generating private key: %w", err)
|
|
}
|
|
privateKeyPEM := pem.EncodeToMemory(&pem.Block{
|
|
Type: "RSA PRIVATE KEY",
|
|
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
|
|
})
|
|
// Save private key locally using typeLabel in filename
|
|
keyFilename := fmt.Sprintf("%s_private_key.pem", strings.ToLower(typeLabel))
|
|
generatedKeyPath := filepath.Join(outputDir, keyFilename)
|
|
if err := os.WriteFile(generatedKeyPath, privateKeyPEM, 0600); err != nil {
|
|
return nil, "", fmt.Errorf("saving generated private key: %w", err)
|
|
}
|
|
log.Debugf("Private key generated and saved to %s (Permissions 0600)", generatedKeyPath)
|
|
|
|
log.Debug("Generating Certificate Signing Request (CSR)...")
|
|
csrTemplate := x509.CertificateRequest{
|
|
Subject: pkix.Name{
|
|
ExtraNames: []pkix.AttributeTypeAndValue{
|
|
{Type: certs.OIDEmailAddress, Value: as.conf.Email},
|
|
},
|
|
CommonName: fmt.Sprintf("%s Dev Key %s", typeLabel, time.Now().Format("20060102150405")),
|
|
Country: []string{as.conf.Country},
|
|
},
|
|
SignatureAlgorithm: x509.SHA256WithRSA,
|
|
// EmailAddresses: []string{as.conf.Email},
|
|
}
|
|
csrBytes, err := x509.CreateCertificateRequest(rand.Reader, &csrTemplate, privateKey)
|
|
if err != nil {
|
|
os.Remove(generatedKeyPath) // Cleanup generated key
|
|
return nil, "", fmt.Errorf("creating CSR: %w", err)
|
|
}
|
|
// csrPath := filepath.Join(outputDir, fmt.Sprintf("%s.certSigningRequest", strings.ToLower(typeLabel)))
|
|
// if err := os.WriteFile(csrPath, csrBytes, 0600); err != nil {
|
|
// return nil, "", fmt.Errorf("saving CSR: %w", err)
|
|
// }
|
|
// log.Infof("Certificate Signing Request (CSR) saved to %s", csrPath)
|
|
// NOTE: openssl req -in development.certSigningRequest -noout -text
|
|
log.Info("Submitting CSR to App Store Connect...")
|
|
createdCert, err := as.CreateCertificate(
|
|
string(requiredCertType), // Pass the required type as string
|
|
base64.StdEncoding.EncodeToString(csrBytes),
|
|
)
|
|
if err != nil {
|
|
os.Remove(generatedKeyPath) // Cleanup generated key
|
|
return nil, "", fmt.Errorf("creating %s certificate via API: %w", typeLabel, err)
|
|
}
|
|
|
|
log.WithField("id", createdCert.ID).Infof("Successfully created new %s certificate", typeLabel)
|
|
utils.Indent(log.Info, 2)(
|
|
fmt.Sprintf("New Certificate Details - Name: %s, Serial: %s, Expires: %s",
|
|
createdCert.Attributes.Name,
|
|
createdCert.Attributes.SerialNumber,
|
|
createdCert.Attributes.ExpirationDate.Format(time.RFC1123),
|
|
))
|
|
|
|
return createdCert, generatedKeyPath, nil
|
|
}
|
|
|
|
// ensureProvisioningProfile finds or creates a provisioning profile of the specified type.
|
|
// Takes the required ProfileType constant and a label for logging/filenames.
|
|
func (as *AppStore) ensureProvisioningProfile(bundleIDIdentifier string, certID string, requiredProfileType ProfileType, typeLabel, platform string) (prof *Profile, err error) {
|
|
|
|
log.Debugf("Looking up Bundle ID resource for identifier: %s", bundleIDIdentifier)
|
|
bundle, err := as.GetBundleIDByIdentifier(bundleIDIdentifier)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("finding bundle ID '%s': %w", bundleIDIdentifier, err)
|
|
}
|
|
if bundle == nil {
|
|
log.Infof("Bundle ID '%s' not found, registering...", bundleIDIdentifier)
|
|
// ASC name field requires alphanumeric+spaces only; replace dots.
|
|
displayName := strings.ReplaceAll(bundleIDIdentifier, ".", " ")
|
|
resp, err := as.RegisterBundleID(displayName, bundleIDIdentifier)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("registering bundle ID '%s': %w", bundleIDIdentifier, err)
|
|
}
|
|
bundle = &resp.Data
|
|
log.Infof("Registered Bundle ID: %s (resource ID: %s)", bundleIDIdentifier, bundle.ID)
|
|
}
|
|
|
|
log.Debug("Listing all devices...")
|
|
allDevices, err := as.GetDevices()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing devices: %w", err)
|
|
}
|
|
|
|
deviceIDs := make([]string, 0)
|
|
enabledDeviceCount := 0
|
|
// Only include devices for Development and AdHoc profiles
|
|
// AppStore profiles don't include specific devices
|
|
if profileTypeNeedsDevices(requiredProfileType) {
|
|
for _, dev := range allDevices {
|
|
if dev.Attributes.Status == "ENABLED" { // Filter for enabled status
|
|
deviceIDs = append(deviceIDs, dev.ID)
|
|
enabledDeviceCount++
|
|
}
|
|
}
|
|
if enabledDeviceCount == 0 {
|
|
// Attempt to register the host Mac as a development device
|
|
if udid, err := getHostProvisioningUDID(); err == nil {
|
|
hostname, _ := os.Hostname()
|
|
devPlatform := "MAC_OS"
|
|
if platform == "ios" || platform == "tvos" {
|
|
devPlatform = "IOS" // won't auto-register for iOS — user must register manually
|
|
log.Warnf("No enabled devices found. Register your iOS device manually.")
|
|
} else {
|
|
log.Infof("No devices registered. Registering this Mac (%s)...", udid)
|
|
dev, err := as.RegisterDevice(hostname, devPlatform, udid)
|
|
if err != nil {
|
|
log.Warnf("Device registration failed: %v (profile will be created without devices)", err)
|
|
} else {
|
|
deviceIDs = append(deviceIDs, dev.ID)
|
|
enabledDeviceCount++
|
|
log.Infof("Registered device: %s (%s)", hostname, udid)
|
|
}
|
|
}
|
|
} else {
|
|
log.Warnf("No enabled devices found and could not determine host UDID: %v", err)
|
|
}
|
|
}
|
|
if enabledDeviceCount == 0 {
|
|
log.Warnf("No enabled devices. %s profile will be created without devices.", typeLabel)
|
|
} else {
|
|
log.Infof("Found %d enabled devices to include in %s profile.", enabledDeviceCount, typeLabel)
|
|
}
|
|
} else {
|
|
log.Debugf("%s profiles do not include specific devices.", typeLabel)
|
|
}
|
|
|
|
profileName := fmt.Sprintf("ipsw %s %s", typeLabel, bundleIDIdentifier)
|
|
log.Infof("Attempting to find existing profile named: %s", profileName)
|
|
|
|
allProfiles, err := as.GetProfiles()
|
|
if err != nil {
|
|
log.Warnf("Failed to list existing profiles, will attempt creation: %v", err)
|
|
} else {
|
|
log.Debugf("Retrieved %d profiles. Filtering for suitable %s profile...", len(allProfiles), typeLabel)
|
|
for _, p := range allProfiles {
|
|
// Check name, type, and state
|
|
if p.Attributes.Name == profileName &&
|
|
p.Attributes.ProfileType == requiredProfileType && // Check against the required type
|
|
p.Attributes.ProfileState == "ACTIVE" {
|
|
// Check if it contains the required certificate
|
|
containsCert := false
|
|
certs, err := as.GetProfileCerts(p.ID)
|
|
if err != nil {
|
|
log.Warnf("Failed to list certificates for profile %s: %v", p.ID, err)
|
|
continue // Skip this profile
|
|
}
|
|
for _, c := range certs {
|
|
if c.ID == certID {
|
|
containsCert = true
|
|
break
|
|
}
|
|
}
|
|
// Check if it's for the correct bundle ID
|
|
containsBundleID := false
|
|
pb, err := as.GetProfileBundleID(p.ID)
|
|
if err != nil {
|
|
log.Warnf("Failed to list bundle IDs for profile %s: %v", p.ID, err)
|
|
continue // Skip this profile
|
|
}
|
|
if pb.ID == bundle.ID {
|
|
containsBundleID = true
|
|
}
|
|
// Check device count consistency (optional but good)
|
|
// AdHoc/Dev profiles should ideally have the devices we found earlier
|
|
// AppStore profiles should have zero devices associated
|
|
devicesMatch := true
|
|
if profileTypeNeedsDevices(requiredProfileType) {
|
|
profileDevices, err := as.GetProfileDevices(p.ID)
|
|
if err != nil {
|
|
log.Warnf("Could not verify devices for existing profile %s: %v", p.ID, err)
|
|
// Decide if this is critical - maybe continue if cert/bundle match
|
|
} else if len(profileDevices) != len(deviceIDs) {
|
|
log.Debugf("Existing profile %s has %d devices, but %d enabled devices found now. May need regeneration.", p.ID, len(profileDevices), len(deviceIDs))
|
|
// Could force regeneration here if needed: continue
|
|
devicesMatch = false // Mark as mismatch for now, maybe regenerate later
|
|
}
|
|
} else { // AppStore profile
|
|
profileDevices, _ := as.GetProfileDevices(p.ID) // Check anyway
|
|
if len(profileDevices) > 0 {
|
|
log.Warnf("Existing AppStore profile %s unexpectedly contains devices.", p.ID)
|
|
devicesMatch = false // AppStore profiles shouldn't have devices
|
|
}
|
|
}
|
|
// If all conditions match, use this profile
|
|
if containsCert && containsBundleID && devicesMatch { // Added devicesMatch check
|
|
log.Infof("Found suitable existing %s profile: ID %s, State: %s", typeLabel, p.ID, p.Attributes.ProfileState)
|
|
return &p, nil
|
|
} else {
|
|
log.Debugf("Profile %s matched name/type/state but failed checks: cert=%t, bundleID=%t, devices=%t", p.ID, containsCert, containsBundleID, devicesMatch)
|
|
}
|
|
}
|
|
}
|
|
log.Infof("No suitable active profile found named '%s'. Proceeding to create.", profileName)
|
|
// Every profile with this name failed the find-suitable checks above
|
|
// (wrong cert, INVALID, wrong bundle, etc). The name is taken Apple-side;
|
|
// create will 409. Delete all name-matches regardless of state because
|
|
// they are all stale/unsuitable.
|
|
// (Apple's INVALID propagation is async after cert revoke, roughly 30s,
|
|
// so checking IsInvalid() alone misses the immediate-rerun race window.)
|
|
for _, p := range allProfiles {
|
|
if p.Attributes.Name == profileName {
|
|
log.Infof("Deleting stale profile %s (%s): name is taken and profile is unsuitable",
|
|
p.ID, p.Attributes.ProfileState)
|
|
if err := as.DeleteProfile(p.ID); err != nil {
|
|
log.Warnf("Could not delete stale profile %s: %v (create may 409)", p.ID, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
log.Infof("Attempting to create a new %s Provisioning Profile...", typeLabel)
|
|
// Use the requiredProfileType constant
|
|
createResp, err := as.CreateProfile(
|
|
profileName,
|
|
string(requiredProfileType), // Pass the required type as string
|
|
bundle.ID,
|
|
[]string{certID},
|
|
deviceIDs, // Pass the filtered list (empty for AppStore)
|
|
false, // offline=false
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating %s provisioning profile: %w", typeLabel, err)
|
|
}
|
|
|
|
log.WithFields(log.Fields{
|
|
"id": createResp.Data.ID,
|
|
"name": profileName,
|
|
}).Infof("Successfully created %s Provisioning Profile", typeLabel)
|
|
|
|
return &createResp.Data, nil
|
|
}
|
|
|
|
// InstallCertificateAndKey imports the certificate and optionally its private key into the login keychain.
|
|
func InstallCertificateAndKey(certPath string, keyPath string) error {
|
|
// If keyPath is provided, install the private key
|
|
if keyPath != "" {
|
|
if err := installPrivateKey(keyPath); err != nil {
|
|
return fmt.Errorf("installing private key: %w", err)
|
|
}
|
|
} else {
|
|
log.Info("No private key path provided, skipping key import (assuming key already exists in keychain).")
|
|
}
|
|
|
|
// Install the certificate
|
|
if err := installCertificate(certPath); err != nil {
|
|
return fmt.Errorf("installing certificate: %w", err)
|
|
}
|
|
|
|
// Verify installation (same as before)
|
|
return VerifyCertificateInstallation(certPath)
|
|
}
|
|
|
|
// installPrivateKey imports a private key into the login keychain.
|
|
func installPrivateKey(keyPath string) error {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return fmt.Errorf("getting user home directory: %w", err)
|
|
}
|
|
|
|
// Determine correct keychain path (.keychain-db suffix is newer)
|
|
keychainPath := filepath.Join(home, "Library", "Keychains", "login.keychain-db")
|
|
if _, err := os.Stat(keychainPath); os.IsNotExist(err) {
|
|
keychainPath = filepath.Join(home, "Library", "Keychains", "login.keychain")
|
|
if _, err := os.Stat(keychainPath); os.IsNotExist(err) {
|
|
return fmt.Errorf("cannot find login keychain at default locations")
|
|
}
|
|
}
|
|
|
|
log.Infof("Importing private key %s into keychain %s", keyPath, keychainPath)
|
|
// -P "" assumes empty passphrase. If key is encrypted, this will fail.
|
|
// -T /usr/bin/codesign : Allow codesign to use this key without UI prompt.
|
|
cmdKey := exec.Command("security", "import", keyPath, "-k", keychainPath, "-P", "", "-T", "/usr/bin/codesign")
|
|
outputKey, errKey := cmdKey.CombinedOutput()
|
|
if errKey != nil {
|
|
// Retry without -P "" (for non-encrypted keys that might reject empty passphrase)
|
|
log.Debugf("Initial key import failed, retrying without -P '': %v", errKey)
|
|
cmdKeyRetry := exec.Command("security", "import", keyPath, "-k", keychainPath, "-T", "/usr/bin/codesign")
|
|
outputKeyRetry, errKeyRetry := cmdKeyRetry.CombinedOutput()
|
|
if errKeyRetry != nil {
|
|
return fmt.Errorf("security import command failed for private key: %w\nAttempt 1 Output: %s\nAttempt 2 Output: %s", errKeyRetry, string(outputKey), string(outputKeyRetry))
|
|
}
|
|
log.Debugf("Security import (retry) output for key: %s", string(outputKeyRetry))
|
|
} else {
|
|
log.Debugf("Security import output for key: %s", string(outputKey))
|
|
}
|
|
log.Info("Private key imported.")
|
|
return nil
|
|
}
|
|
|
|
// installCertificate imports a certificate into the login keychain.
|
|
func installCertificate(certPath string) error {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return fmt.Errorf("getting user home directory: %w", err)
|
|
}
|
|
|
|
// Determine correct keychain path (.keychain-db suffix is newer)
|
|
keychainPath := filepath.Join(home, "Library", "Keychains", "login.keychain-db")
|
|
if _, err := os.Stat(keychainPath); os.IsNotExist(err) {
|
|
keychainPath = filepath.Join(home, "Library", "Keychains", "login.keychain")
|
|
if _, err := os.Stat(keychainPath); os.IsNotExist(err) {
|
|
return fmt.Errorf("cannot find login keychain at default locations")
|
|
}
|
|
}
|
|
|
|
// Import Certificate
|
|
log.Infof("Importing certificate %s into keychain %s", certPath, keychainPath)
|
|
cmdCert := exec.Command("security", "import", certPath, "-k", keychainPath)
|
|
outputCert, errCert := cmdCert.CombinedOutput()
|
|
if errCert != nil {
|
|
return fmt.Errorf("security import command failed for certificate: %w\nOutput: %s", errCert, string(outputCert))
|
|
}
|
|
log.Debugf("Security import output for certificate: %s", string(outputCert))
|
|
log.Info("Certificate imported.")
|
|
return nil
|
|
}
|
|
|
|
// VerifyCertificateInstallation verifies that a certificate was properly installed.
|
|
func VerifyCertificateInstallation(certPath string) error {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return fmt.Errorf("getting user home directory: %w", err)
|
|
}
|
|
|
|
// Determine correct keychain path (.keychain-db suffix is newer)
|
|
keychainPath := filepath.Join(home, "Library", "Keychains", "login.keychain-db")
|
|
if _, err := os.Stat(keychainPath); os.IsNotExist(err) {
|
|
keychainPath = filepath.Join(home, "Library", "Keychains", "login.keychain")
|
|
if _, err := os.Stat(keychainPath); os.IsNotExist(err) {
|
|
return fmt.Errorf("cannot find login keychain at default locations")
|
|
}
|
|
}
|
|
|
|
// Verify Installation (Find Identity)
|
|
// Need to parse the cert to get the Common Name for verification
|
|
certBytes, err := os.ReadFile(certPath)
|
|
if err != nil {
|
|
log.Warnf("Cannot read certificate file %s for verification: %v", certPath, err)
|
|
return nil // Don't fail the whole process, just warn
|
|
}
|
|
// Handle PEM encoding if necessary (App Store Connect usually provides DER)
|
|
block, _ := pem.Decode(certBytes)
|
|
var certRaw []byte
|
|
if block != nil {
|
|
certRaw = block.Bytes
|
|
} else {
|
|
certRaw = certBytes // Assume DER
|
|
}
|
|
|
|
parsedCert, err := x509.ParseCertificate(certRaw)
|
|
if err != nil {
|
|
log.Warnf("Cannot parse certificate %s for verification: %v", certPath, err)
|
|
return nil // Don't fail, just warn
|
|
}
|
|
commonName := parsedCert.Subject.CommonName
|
|
if commonName == "" {
|
|
log.Warnf("Certificate %s has no Common Name, cannot verify identity precisely.", certPath)
|
|
return nil
|
|
}
|
|
|
|
log.Infof("Verifying identity '%s' in keychain %s...", commonName, keychainPath)
|
|
// -v : valid identities only, -p codesigning : for code signing usage
|
|
cmdVerify := exec.Command("security", "find-identity", "-v", "-p", "codesigning", keychainPath)
|
|
verifyOutput, errVerify := cmdVerify.CombinedOutput()
|
|
|
|
found := false
|
|
if errVerify == nil {
|
|
for line := range strings.SplitSeq(string(verifyOutput), "\n") {
|
|
// Match common name within quotes: "iPhone Developer: Your Name (TEAMID)"
|
|
if strings.Contains(line, fmt.Sprintf(`"%s"`, commonName)) || strings.Contains(line, commonName) { // Be a bit lenient
|
|
log.Infof("Verification successful: Found identity matching '%s'", commonName)
|
|
log.Debugf("Full matching line: %s", strings.TrimSpace(line))
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
// This is a significant warning, but maybe not a fatal error depending on user workflow
|
|
log.Warnf("Could not verify identity '%s' was successfully installed for code signing in %s.", commonName, keychainPath)
|
|
if errVerify != nil {
|
|
log.Debugf("Verification command 'security find-identity' failed: %v", errVerify)
|
|
}
|
|
return fmt.Errorf("failed to verify certificate and key combination in keychain for CN '%s'", commonName)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// InstallProvisioningProfile copies the profile to the standard location.
|
|
func InstallProvisioningProfile(profilePath string) (string, error) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", fmt.Errorf("getting user home directory: %w", err)
|
|
}
|
|
|
|
profileDir := filepath.Join(home, "Library", "MobileDevice", "Provisioning Profiles")
|
|
if err = os.MkdirAll(profileDir, 0755); err != nil {
|
|
return "", fmt.Errorf("creating profiles directory %s: %w", profileDir, err)
|
|
}
|
|
|
|
profileData, err := os.ReadFile(profilePath)
|
|
if err != nil {
|
|
return "", fmt.Errorf("reading profile %s: %w", profilePath, err)
|
|
}
|
|
|
|
// Extract UUID from the profile
|
|
destName := filepath.Base(profilePath)
|
|
uuid, err := extractProfileUUID(profileData)
|
|
if err != nil {
|
|
log.Warnf("Could not extract UUID from profile: %v", err)
|
|
log.Warnf("Using original filename instead: %s", destName)
|
|
} else {
|
|
// Default to original name if parsing fails
|
|
destName = uuid + ".mobileprovision"
|
|
log.Debugf("Extracted UUID from profile: %s", uuid)
|
|
}
|
|
|
|
destPath := filepath.Join(profileDir, destName)
|
|
|
|
log.Infof("Copying profile %s to %s", profilePath, destPath)
|
|
if err = os.WriteFile(destPath, profileData, 0644); err != nil {
|
|
return "", fmt.Errorf("writing profile to %s: %w", destPath, err)
|
|
}
|
|
|
|
if err = os.Chmod(destPath, 0644); err != nil {
|
|
log.Warnf("Failed to set permissions on %s: %v", destPath, err)
|
|
}
|
|
|
|
return destPath, nil
|
|
}
|
|
|
|
// extractProfileUUID parses a provisioning profile to extract its UUID.
|
|
// .mobileprovision files are in PKCS#7 (CMS) format with an embedded plist
|
|
func extractProfileUUID(profileData []byte) (string, error) {
|
|
p7, err := pkcs7.Parse(profileData)
|
|
if err != nil {
|
|
return "", fmt.Errorf("parse PKCS#7 data: %w", err)
|
|
}
|
|
|
|
if len(p7.Content) == 0 {
|
|
return "", fmt.Errorf("no content found in PKCS#7 data")
|
|
}
|
|
|
|
type minimalProfile struct {
|
|
UUID string `plist:"UUID,omitempty"`
|
|
}
|
|
|
|
var profile minimalProfile
|
|
if _, err := plist.Unmarshal(p7.Content, &profile); err != nil {
|
|
return "", fmt.Errorf("unmarshal provisioning profile plist: %w", err)
|
|
}
|
|
|
|
if profile.UUID == "" {
|
|
return "", fmt.Errorf("no UUID found in provisioning profile")
|
|
}
|
|
|
|
return profile.UUID, nil
|
|
}
|
|
|
|
// exportToP12 combines a certificate and private key into a PKCS#12 (.p12) file
|
|
// func exportToP12(certPath, keyPath, p12Path, password string) error {
|
|
// certData, err := os.ReadFile(certPath)
|
|
// if err != nil {
|
|
// return fmt.Errorf("reading certificate file: %w", err)
|
|
// }
|
|
|
|
// // Parse certificate - handle both PEM and DER formats
|
|
// var cert *x509.Certificate
|
|
// block, _ := pem.Decode(certData)
|
|
// if block != nil && block.Type == "CERTIFICATE" {
|
|
// // PEM format
|
|
// cert, err = x509.ParseCertificate(block.Bytes)
|
|
// if err != nil {
|
|
// return fmt.Errorf("parsing PEM certificate: %w", err)
|
|
// }
|
|
// } else {
|
|
// // DER format
|
|
// cert, err = x509.ParseCertificate(certData)
|
|
// if err != nil {
|
|
// // If DER parsing fails, try PEM decoding again just in case
|
|
// if block, _ := pem.Decode(certData); block != nil {
|
|
// cert, err = x509.ParseCertificate(block.Bytes)
|
|
// if err != nil {
|
|
// return fmt.Errorf("parsing certificate (PEM fallback): %w", err)
|
|
// }
|
|
// } else {
|
|
// return fmt.Errorf("parsing DER certificate: %w", err)
|
|
// }
|
|
// }
|
|
// }
|
|
|
|
// // Read private key file
|
|
// keyData, err := os.ReadFile(keyPath)
|
|
// if err != nil {
|
|
// return fmt.Errorf("reading private key file: %w", err)
|
|
// }
|
|
|
|
// // Parse private key - handle different PEM formats and DER
|
|
// var privateKey any
|
|
// block, _ = pem.Decode(keyData)
|
|
// if block != nil {
|
|
// // PEM format key
|
|
// switch block.Type {
|
|
// case "RSA PRIVATE KEY":
|
|
// privateKey, err = x509.ParsePKCS1PrivateKey(block.Bytes)
|
|
// if err != nil {
|
|
// return fmt.Errorf("parsing PKCS1 private key: %w", err)
|
|
// }
|
|
// case "PRIVATE KEY": // PKCS#8
|
|
// privateKey, err = x509.ParsePKCS8PrivateKey(block.Bytes)
|
|
// if err != nil {
|
|
// return fmt.Errorf("parsing PKCS8 private key: %w", err)
|
|
// }
|
|
// // TODO: Add cases for other key types like EC PRIVATE KEY if needed
|
|
// default:
|
|
// return fmt.Errorf("unsupported PEM block type for private key: %s", block.Type)
|
|
// }
|
|
// } else {
|
|
// // Try to decode as PKCS#8 DER format first (more common)
|
|
// privateKey, err = x509.ParsePKCS8PrivateKey(keyData)
|
|
// if err != nil {
|
|
// // If PKCS#8 fails, try PKCS#1 DER format
|
|
// privateKey, err = x509.ParsePKCS1PrivateKey(keyData)
|
|
// if err != nil {
|
|
// return fmt.Errorf("parsing private key (DER PKCS8/PKCS1): %w", err)
|
|
// }
|
|
// }
|
|
// }
|
|
|
|
// // Ensure the key is usable by the Encode function
|
|
// switch pk := privateKey.(type) {
|
|
// case *rsa.PrivateKey:
|
|
// // Key is RSA, which is supported
|
|
// case *ecdsa.PrivateKey:
|
|
// // Key is ECDSA, which is supported
|
|
// default:
|
|
// return fmt.Errorf("unsupported private key type: %T", pk)
|
|
// }
|
|
|
|
// // Create PKCS#12 data using the SSLMate library's Encode function
|
|
// pfxData, err := pkcs12.Modern.Encode(privateKey, cert, nil, password)
|
|
// if err != nil {
|
|
// return fmt.Errorf("encoding PKCS#12 data: %w", err)
|
|
// }
|
|
|
|
// // Write the PKCS#12 data to file
|
|
// if err := os.WriteFile(p12Path, pfxData, 0600); err != nil {
|
|
// return fmt.Errorf("writing P12 file: %w", err)
|
|
// }
|
|
|
|
// log.Infof("Successfully exported certificate and key to %s", p12Path)
|
|
// return nil
|
|
// }
|
|
|
|
// bundlePKCS12 creates a PKCS#12 file from a DER certificate and PEM private key.
|
|
// Uses legacy 3DES/SHA1 encoding for macOS Keychain compatibility.
|
|
func bundlePKCS12(certPath, keyPath, p12Path, password string) error {
|
|
certDER, err := os.ReadFile(certPath)
|
|
if err != nil {
|
|
return fmt.Errorf("reading certificate: %w", err)
|
|
}
|
|
cert, err := x509.ParseCertificate(certDER)
|
|
if err != nil {
|
|
return fmt.Errorf("parsing certificate: %w", err)
|
|
}
|
|
|
|
keyPEM, err := os.ReadFile(keyPath)
|
|
if err != nil {
|
|
return fmt.Errorf("reading private key: %w", err)
|
|
}
|
|
block, _ := pem.Decode(keyPEM)
|
|
if block == nil {
|
|
return fmt.Errorf("failed to decode PEM private key")
|
|
}
|
|
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
|
if err != nil {
|
|
// Try PKCS1 as fallback
|
|
key, err = x509.ParsePKCS1PrivateKey(block.Bytes)
|
|
if err != nil {
|
|
return fmt.Errorf("parsing private key: %w", err)
|
|
}
|
|
}
|
|
|
|
p12Data, err := pkcs12.LegacyDES.Encode(key, cert, nil, password)
|
|
if err != nil {
|
|
return fmt.Errorf("encoding PKCS#12: %w", err)
|
|
}
|
|
|
|
if err := os.WriteFile(p12Path, p12Data, 0600); err != nil {
|
|
return fmt.Errorf("writing P12 file: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// installP12 imports a PKCS#12 file into the login keychain.
|
|
func installP12(p12Path, password string) error {
|
|
keychainPath, err := loginKeychainPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
log.Infof("Importing P12 %s into keychain %s", p12Path, keychainPath)
|
|
cmd := exec.Command("security", "import", p12Path,
|
|
"-k", keychainPath,
|
|
"-P", password,
|
|
"-T", "/usr/bin/codesign",
|
|
"-T", "/usr/bin/security",
|
|
)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("security import P12 failed: %w\nOutput: %s", err, string(out))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
const wwdrG3URL = "https://www.apple.com/certificateauthority/AppleWWDRCAG3.cer"
|
|
|
|
// ensureWWDRG3 downloads and imports the Apple WWDR G3 intermediate certificate
|
|
// into the login keychain. This is needed for the codesigning trust chain.
|
|
func ensureWWDRG3() error {
|
|
keychainPath, err := loginKeychainPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
resp, err := http.Get(wwdrG3URL)
|
|
if err != nil {
|
|
return fmt.Errorf("downloading WWDR G3: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
return fmt.Errorf("downloading WWDR G3: HTTP %s", resp.Status)
|
|
}
|
|
|
|
tmp, err := os.CreateTemp("", "wwdrg3-*.cer")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.Remove(tmp.Name())
|
|
|
|
if _, err := io.Copy(tmp, resp.Body); err != nil {
|
|
tmp.Close()
|
|
return fmt.Errorf("saving WWDR G3: %w", err)
|
|
}
|
|
tmp.Close()
|
|
|
|
cmd := exec.Command("security", "import", tmp.Name(), "-k", keychainPath)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
// "already exists" is fine
|
|
if strings.Contains(string(out), "already exists") {
|
|
log.Debug("WWDR G3 intermediate already in keychain")
|
|
return nil
|
|
}
|
|
return fmt.Errorf("importing WWDR G3: %w\nOutput: %s", err, string(out))
|
|
}
|
|
log.Info("Installed Apple WWDR G3 intermediate certificate")
|
|
return nil
|
|
}
|
|
|
|
// loginKeychainPath returns the path to the user's login keychain.
|
|
func loginKeychainPath() (string, error) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", fmt.Errorf("getting user home directory: %w", err)
|
|
}
|
|
path := filepath.Join(home, "Library", "Keychains", "login.keychain-db")
|
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
path = filepath.Join(home, "Library", "Keychains", "login.keychain")
|
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
return "", fmt.Errorf("cannot find login keychain at default locations")
|
|
}
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
// profileTypeNeedsDevices returns true for profile types that include device IDs.
|
|
func profileTypeNeedsDevices(pt ProfileType) bool {
|
|
switch pt {
|
|
case IOS_APP_DEVELOPMENT, IOS_APP_ADHOC,
|
|
MAC_APP_DEVELOPMENT,
|
|
TVOS_APP_DEVELOPMENT, TVOS_APP_ADHOC,
|
|
MAC_CATALYST_APP_DEVELOPMENT:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// getHostProvisioningUDID returns the Provisioning UDID of this Mac
|
|
// by parsing system_profiler output.
|
|
func getHostProvisioningUDID() (string, error) {
|
|
out, err := exec.Command("system_profiler", "SPHardwareDataType").Output()
|
|
if err != nil {
|
|
return "", fmt.Errorf("running system_profiler: %w", err)
|
|
}
|
|
for line := range strings.SplitSeq(string(out), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if after, ok := strings.CutPrefix(line, "Provisioning UDID:"); ok {
|
|
return strings.TrimSpace(after), nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("Provisioning UDID not found in system_profiler output")
|
|
}
|