From 5c4308549c7d85ac68c027a40ed405d6d41ba648 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:40:12 +0200 Subject: [PATCH 1/7] asc: bundle ID, certificate, device and profile endpoints The provisioning resources behind automatic signing: find/register App IDs (filter[identifier] also matches prefixes, so the exact identifier is checked), list/issue certificates with the CSR as csrContent and the DER decoded from certificateContent, list/register iOS devices, and list by name, create and delete profiles. Profile membership is read from the paginated relationships endpoints rather than include=, which caps the linkage arrays. --- internal/asc/bundleids.go | 54 +++++++++++ internal/asc/bundleids_test.go | 63 +++++++++++++ internal/asc/certificates.go | 96 ++++++++++++++++++++ internal/asc/certificates_test.go | 85 ++++++++++++++++++ internal/asc/devices.go | 63 +++++++++++++ internal/asc/devices_test.go | 52 +++++++++++ internal/asc/profiles.go | 144 ++++++++++++++++++++++++++++++ internal/asc/profiles_test.go | 127 ++++++++++++++++++++++++++ 8 files changed, 684 insertions(+) create mode 100644 internal/asc/bundleids.go create mode 100644 internal/asc/bundleids_test.go create mode 100644 internal/asc/certificates.go create mode 100644 internal/asc/certificates_test.go create mode 100644 internal/asc/devices.go create mode 100644 internal/asc/devices_test.go create mode 100644 internal/asc/profiles.go create mode 100644 internal/asc/profiles_test.go diff --git a/internal/asc/bundleids.go b/internal/asc/bundleids.go new file mode 100644 index 0000000..e681924 --- /dev/null +++ b/internal/asc/bundleids.go @@ -0,0 +1,54 @@ +package asc + +import ( + "context" + "net/url" +) + +// BundleID is a registered App ID (Certificates, Identifiers & Profiles → Identifiers). +type BundleID struct { + ID string + Identifier string + Name string + Platform string + SeedID string +} + +type bundleIDAttributes struct { + Identifier string `json:"identifier,omitempty"` + Name string `json:"name,omitempty"` + Platform string `json:"platform,omitempty"` + SeedID string `json:"seedId,omitempty"` +} + +func toBundleID(r Resource[bundleIDAttributes]) BundleID { + return BundleID{ID: r.ID, Identifier: r.Attributes.Identifier, Name: r.Attributes.Name, Platform: r.Attributes.Platform, SeedID: r.Attributes.SeedID} +} + +// BundleIDByIdentifier finds the App ID registered for an exact bundle +// identifier, or returns nil when none is. +func (c *Client) BundleIDByIdentifier(ctx context.Context, identifier string) (*BundleID, error) { + rs, err := getAll[bundleIDAttributes](ctx, c, "/v1/bundleIds", url.Values{"filter[identifier]": {identifier}}) + if err != nil { + return nil, err + } + for _, r := range rs { + // The filter also matches wildcard and prefixed identifiers. + if r.Attributes.Identifier == identifier { + b := toBundleID(r) + return &b, nil + } + } + return nil, nil +} + +// CreateBundleID registers an App ID. platform is PlatformIOS for iOS apps. +func (c *Client) CreateBundleID(ctx context.Context, identifier, name, platform string) (*BundleID, error) { + req := Resource[bundleIDAttributes]{Type: "bundleIds", Attributes: bundleIDAttributes{Identifier: identifier, Name: name, Platform: platform}} + r, err := post[bundleIDAttributes, bundleIDAttributes](ctx, c, "/v1/bundleIds", req) + if err != nil { + return nil, err + } + b := toBundleID(*r) + return &b, nil +} diff --git a/internal/asc/bundleids_test.go b/internal/asc/bundleids_test.go new file mode 100644 index 0000000..e9b7f51 --- /dev/null +++ b/internal/asc/bundleids_test.go @@ -0,0 +1,63 @@ +package asc + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestBundleIDByIdentifierSkipsPrefixMatches(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/bundleIds" || r.URL.Query().Get("filter[identifier]") != "com.example.app" { + t.Errorf("unexpected request %s %s", r.Method, r.URL) + } + writeJSON(w, 200, map[string]any{"data": []map[string]any{ + {"type": "bundleIds", "id": "bid-2", "attributes": map[string]any{"identifier": "com.example.app.watch", "name": "Watch", "platform": "IOS"}}, + {"type": "bundleIds", "id": "bid-1", "attributes": map[string]any{"identifier": "com.example.app", "name": "Example", "platform": "IOS", "seedId": "TEAM1"}}, + }}) + })) + defer srv.Close() + b, err := newTestClient(t, srv).BundleIDByIdentifier(context.Background(), "com.example.app") + if err != nil { + t.Fatal(err) + } + if b == nil || b.ID != "bid-1" || b.Name != "Example" || b.SeedID != "TEAM1" || b.Platform != PlatformIOS { + t.Errorf("bundle ID = %+v", b) + } +} + +func TestBundleIDByIdentifierAbsent(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, map[string]any{"data": []any{}}) + })) + defer srv.Close() + b, err := newTestClient(t, srv).BundleIDByIdentifier(context.Background(), "com.missing") + if err != nil || b != nil { + t.Errorf("bundle ID = %+v, err = %v", b, err) + } +} + +func TestCreateBundleID(t *testing.T) { + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/v1/bundleIds" { + t.Errorf("unexpected request %s %s", r.Method, r.URL) + } + _ = json.NewDecoder(r.Body).Decode(&body) + writeJSON(w, 201, map[string]any{"data": map[string]any{"type": "bundleIds", "id": "bid-9", "attributes": map[string]any{"identifier": "com.example.app", "name": "com example app", "platform": "IOS"}}}) + })) + defer srv.Close() + b, err := newTestClient(t, srv).CreateBundleID(context.Background(), "com.example.app", "com example app", PlatformIOS) + if err != nil { + t.Fatal(err) + } + if b.ID != "bid-9" || b.Identifier != "com.example.app" { + t.Errorf("bundle ID = %+v", b) + } + attrs := obj(t, body, "data", "attributes") + if obj(t, body, "data")["type"] != "bundleIds" || attrs["identifier"] != "com.example.app" || attrs["name"] != "com example app" || attrs["platform"] != "IOS" { + t.Errorf("POST body = %v", body) + } +} diff --git a/internal/asc/certificates.go b/internal/asc/certificates.go new file mode 100644 index 0000000..e4acc56 --- /dev/null +++ b/internal/asc/certificates.go @@ -0,0 +1,96 @@ +package asc + +import ( + "context" + "encoding/base64" + "fmt" + "net/url" + "time" +) + +// Certificate types Builder issues. DEVELOPMENT is "Apple Development", +// DISTRIBUTION is "Apple Distribution"; both sign iOS apps (the older +// IOS_DEVELOPMENT / IOS_DISTRIBUTION types are iOS-only variants). +const ( + CertificateTypeDevelopment = "DEVELOPMENT" + CertificateTypeDistribution = "DISTRIBUTION" +) + +// Certificate is a signing certificate issued to the team. +type Certificate struct { + ID string + Name string + DisplayName string + SerialNumber string + Type string + Platform string + ExpirationDate time.Time + // Content is the certificate in DER form, as the portal's .cer download. + Content []byte +} + +type certificateAttributes struct { + CertificateContent string `json:"certificateContent,omitempty"` + DisplayName string `json:"displayName,omitempty"` + ExpirationDate *time.Time `json:"expirationDate,omitempty"` + Name string `json:"name,omitempty"` + Platform string `json:"platform,omitempty"` + SerialNumber string `json:"serialNumber,omitempty"` + CertificateType string `json:"certificateType,omitempty"` + CSRContent string `json:"csrContent,omitempty"` +} + +func toCertificate(r Resource[certificateAttributes]) (Certificate, error) { + c := Certificate{ + ID: r.ID, + Name: r.Attributes.Name, + DisplayName: r.Attributes.DisplayName, + SerialNumber: r.Attributes.SerialNumber, + Type: r.Attributes.CertificateType, + Platform: r.Attributes.Platform, + } + if r.Attributes.ExpirationDate != nil { + c.ExpirationDate = *r.Attributes.ExpirationDate + } + if r.Attributes.CertificateContent != "" { + der, err := base64.StdEncoding.DecodeString(r.Attributes.CertificateContent) + if err != nil { + return c, fmt.Errorf("certificate %s: decode certificateContent: %w", r.ID, err) + } + c.Content = der + } + return c, nil +} + +// ListCertificates lists the team's certificates of one type +// (CertificateTypeDevelopment or CertificateTypeDistribution). +func (c *Client) ListCertificates(ctx context.Context, certificateType string) ([]Certificate, error) { + rs, err := getAll[certificateAttributes](ctx, c, "/v1/certificates", url.Values{"filter[certificateType]": {certificateType}}) + if err != nil { + return nil, err + } + certs := make([]Certificate, 0, len(rs)) + for _, r := range rs { + cert, err := toCertificate(r) + if err != nil { + return nil, err + } + certs = append(certs, cert) + } + return certs, nil +} + +// CreateCertificate has Apple issue a certificate for a PEM-encoded signing +// request (as written by signing.GenerateKeyAndCSR). +func (c *Client) CreateCertificate(ctx context.Context, certificateType string, csrPEM []byte) (*Certificate, error) { + req := Resource[certificateAttributes]{Type: "certificates", Attributes: certificateAttributes{CertificateType: certificateType, CSRContent: string(csrPEM)}} + r, err := post[certificateAttributes, certificateAttributes](ctx, c, "/v1/certificates", req) + if err != nil { + return nil, err + } + cert, err := toCertificate(*r) + if err != nil { + return nil, err + } + return &cert, nil +} diff --git a/internal/asc/certificates_test.go b/internal/asc/certificates_test.go new file mode 100644 index 0000000..3fbb9c0 --- /dev/null +++ b/internal/asc/certificates_test.go @@ -0,0 +1,85 @@ +package asc + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestListCertificatesDecodesContent(t *testing.T) { + der := []byte{0x30, 0x03, 0x02, 0x01, 0x01} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/certificates" || r.URL.Query().Get("filter[certificateType]") != "DEVELOPMENT" { + t.Errorf("unexpected request %s %s", r.Method, r.URL) + } + writeJSON(w, 200, map[string]any{"data": []map[string]any{{ + "type": "certificates", "id": "cert-1", + "attributes": map[string]any{ + "certificateContent": base64.StdEncoding.EncodeToString(der), + "displayName": "Jane Doe", + "name": "Apple Development: Jane Doe (ABC123)", + "serialNumber": "1A2B3C", + "certificateType": "DEVELOPMENT", + "platform": "IOS", + "expirationDate": "2027-09-16T10:00:00.000+00:00", + }, + }}}) + })) + defer srv.Close() + certs, err := newTestClient(t, srv).ListCertificates(context.Background(), CertificateTypeDevelopment) + if err != nil { + t.Fatal(err) + } + if len(certs) != 1 { + t.Fatalf("certs = %+v", certs) + } + c := certs[0] + if c.ID != "cert-1" || c.SerialNumber != "1A2B3C" || c.Type != CertificateTypeDevelopment || c.DisplayName != "Jane Doe" || c.ExpirationDate.Year() != 2027 || !bytes.Equal(c.Content, der) { + t.Errorf("cert = %+v", c) + } +} + +func TestListCertificatesRejectsBadContent(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, map[string]any{"data": []map[string]any{{"type": "certificates", "id": "cert-1", "attributes": map[string]any{"certificateContent": "not base64!"}}}}) + })) + defer srv.Close() + _, err := newTestClient(t, srv).ListCertificates(context.Background(), CertificateTypeDistribution) + if err == nil || !strings.Contains(err.Error(), "cert-1") { + t.Errorf("err = %v", err) + } +} + +func TestCreateCertificateSendsCSR(t *testing.T) { + var body map[string]any + csr := []byte("-----BEGIN CERTIFICATE REQUEST-----\nMIIB\n-----END CERTIFICATE REQUEST-----\n") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/v1/certificates" { + t.Errorf("unexpected request %s %s", r.Method, r.URL) + } + _ = json.NewDecoder(r.Body).Decode(&body) + writeJSON(w, 201, map[string]any{"data": map[string]any{"type": "certificates", "id": "cert-2", "attributes": map[string]any{ + "certificateContent": base64.StdEncoding.EncodeToString([]byte("DER")), "certificateType": "DISTRIBUTION", "name": "Apple Distribution: Team (ABC123)", + }}}) + })) + defer srv.Close() + cert, err := newTestClient(t, srv).CreateCertificate(context.Background(), CertificateTypeDistribution, csr) + if err != nil { + t.Fatal(err) + } + if cert.ID != "cert-2" || cert.Type != CertificateTypeDistribution || string(cert.Content) != "DER" { + t.Errorf("cert = %+v", cert) + } + attrs := obj(t, body, "data", "attributes") + if obj(t, body, "data")["type"] != "certificates" || attrs["certificateType"] != "DISTRIBUTION" || attrs["csrContent"] != string(csr) { + t.Errorf("POST body = %v", body) + } + if _, has := attrs["certificateContent"]; has { + t.Errorf("request must not carry certificateContent: %v", attrs) + } +} diff --git a/internal/asc/devices.go b/internal/asc/devices.go new file mode 100644 index 0000000..a3fde66 --- /dev/null +++ b/internal/asc/devices.go @@ -0,0 +1,63 @@ +package asc + +import ( + "context" + "net/url" +) + +// Device statuses. Disabled devices stay registered and count against the +// yearly limit, but cannot be put in a profile. +const ( + DeviceStatusEnabled = "ENABLED" + DeviceStatusDisabled = "DISABLED" +) + +// Device is a device registered with the team. +type Device struct { + ID string + Name string + UDID string + Platform string + Status string + DeviceClass string + Model string +} + +type deviceAttributes struct { + DeviceClass string `json:"deviceClass,omitempty"` + Model string `json:"model,omitempty"` + Name string `json:"name,omitempty"` + Platform string `json:"platform,omitempty"` + Status string `json:"status,omitempty"` + UDID string `json:"udid,omitempty"` +} + +func toDevice(r Resource[deviceAttributes]) Device { + return Device{ID: r.ID, Name: r.Attributes.Name, UDID: r.Attributes.UDID, Platform: r.Attributes.Platform, Status: r.Attributes.Status, DeviceClass: r.Attributes.DeviceClass, Model: r.Attributes.Model} +} + +// ListDevices lists the registered devices of a platform (PlatformIOS), +// enabled and disabled. +func (c *Client) ListDevices(ctx context.Context, platform string) ([]Device, error) { + rs, err := getAll[deviceAttributes](ctx, c, "/v1/devices", url.Values{"filter[platform]": {platform}}) + if err != nil { + return nil, err + } + devices := make([]Device, 0, len(rs)) + for _, r := range rs { + devices = append(devices, toDevice(r)) + } + return devices, nil +} + +// RegisterDevice registers a device by UDID. Apple allows 100 devices per +// product family per membership year and never frees a slot on removal. +func (c *Client) RegisterDevice(ctx context.Context, name, udid, platform string) (*Device, error) { + req := Resource[deviceAttributes]{Type: "devices", Attributes: deviceAttributes{Name: name, UDID: udid, Platform: platform}} + r, err := post[deviceAttributes, deviceAttributes](ctx, c, "/v1/devices", req) + if err != nil { + return nil, err + } + d := toDevice(*r) + return &d, nil +} diff --git a/internal/asc/devices_test.go b/internal/asc/devices_test.go new file mode 100644 index 0000000..649c793 --- /dev/null +++ b/internal/asc/devices_test.go @@ -0,0 +1,52 @@ +package asc + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestListDevices(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/devices" || r.URL.Query().Get("filter[platform]") != "IOS" || r.URL.Query().Get("limit") != "200" { + t.Errorf("unexpected request %s %s", r.Method, r.URL) + } + writeJSON(w, 200, map[string]any{"data": []map[string]any{ + {"type": "devices", "id": "dev-1", "attributes": map[string]any{"name": "Jane's iPhone", "udid": "00008030-000000000000001E", "platform": "IOS", "status": "ENABLED", "deviceClass": "IPHONE", "model": "iPhone 15"}}, + {"type": "devices", "id": "dev-2", "attributes": map[string]any{"name": "Old iPad", "udid": "00008020-000000000000002E", "platform": "IOS", "status": "DISABLED", "deviceClass": "IPAD"}}, + }}) + })) + defer srv.Close() + devices, err := newTestClient(t, srv).ListDevices(context.Background(), PlatformIOS) + if err != nil { + t.Fatal(err) + } + if len(devices) != 2 || devices[0].ID != "dev-1" || devices[0].UDID != "00008030-000000000000001E" || devices[0].Status != DeviceStatusEnabled || devices[0].Model != "iPhone 15" || devices[1].Status != DeviceStatusDisabled || devices[1].DeviceClass != "IPAD" { + t.Errorf("devices = %+v", devices) + } +} + +func TestRegisterDevice(t *testing.T) { + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/v1/devices" { + t.Errorf("unexpected request %s %s", r.Method, r.URL) + } + _ = json.NewDecoder(r.Body).Decode(&body) + writeJSON(w, 201, map[string]any{"data": map[string]any{"type": "devices", "id": "dev-3", "attributes": map[string]any{"name": "iPhone 00001E", "udid": "00008030-000000000000001E", "platform": "IOS", "status": "ENABLED"}}}) + })) + defer srv.Close() + d, err := newTestClient(t, srv).RegisterDevice(context.Background(), "iPhone 00001E", "00008030-000000000000001E", PlatformIOS) + if err != nil { + t.Fatal(err) + } + if d.ID != "dev-3" || d.Status != DeviceStatusEnabled { + t.Errorf("device = %+v", d) + } + attrs := obj(t, body, "data", "attributes") + if obj(t, body, "data")["type"] != "devices" || attrs["name"] != "iPhone 00001E" || attrs["udid"] != "00008030-000000000000001E" || attrs["platform"] != "IOS" { + t.Errorf("POST body = %v", body) + } +} diff --git a/internal/asc/profiles.go b/internal/asc/profiles.go new file mode 100644 index 0000000..f5d5561 --- /dev/null +++ b/internal/asc/profiles.go @@ -0,0 +1,144 @@ +package asc + +import ( + "context" + "encoding/base64" + "fmt" + "net/url" + "time" +) + +// iOS profile types. +const ( + ProfileTypeIOSAppDevelopment = "IOS_APP_DEVELOPMENT" + ProfileTypeIOSAppAdHoc = "IOS_APP_ADHOC" + ProfileTypeIOSAppStore = "IOS_APP_STORE" +) + +// Profile states. A profile turns INVALID when a certificate or device in it +// is revoked, removed or expired; Apple does not repair it, it must be recreated. +const ( + ProfileStateActive = "ACTIVE" + ProfileStateInvalid = "INVALID" +) + +// Profile is a provisioning profile. +type Profile struct { + ID string + Name string + UUID string + Type string + State string + Platform string + CreatedDate time.Time + ExpirationDate time.Time + // Content is the .mobileprovision file. + Content []byte +} + +type profileAttributes struct { + Name string `json:"name,omitempty"` + Platform string `json:"platform,omitempty"` + ProfileContent string `json:"profileContent,omitempty"` + UUID string `json:"uuid,omitempty"` + CreatedDate *time.Time `json:"createdDate,omitempty"` + ProfileState string `json:"profileState,omitempty"` + ProfileType string `json:"profileType,omitempty"` + ExpirationDate *time.Time `json:"expirationDate,omitempty"` +} + +func toProfile(r Resource[profileAttributes]) (Profile, error) { + p := Profile{ + ID: r.ID, + Name: r.Attributes.Name, + UUID: r.Attributes.UUID, + Type: r.Attributes.ProfileType, + State: r.Attributes.ProfileState, + Platform: r.Attributes.Platform, + } + if r.Attributes.CreatedDate != nil { + p.CreatedDate = *r.Attributes.CreatedDate + } + if r.Attributes.ExpirationDate != nil { + p.ExpirationDate = *r.Attributes.ExpirationDate + } + if r.Attributes.ProfileContent != "" { + data, err := base64.StdEncoding.DecodeString(r.Attributes.ProfileContent) + if err != nil { + return p, fmt.Errorf("profile %s: decode profileContent: %w", r.ID, err) + } + p.Content = data + } + return p, nil +} + +// ListProfilesByName lists the profiles with exactly the given name; the +// portal allows duplicates. +func (c *Client) ListProfilesByName(ctx context.Context, name string) ([]Profile, error) { + rs, err := getAll[profileAttributes](ctx, c, "/v1/profiles", url.Values{"filter[name]": {name}}) + if err != nil { + return nil, err + } + profiles := make([]Profile, 0, len(rs)) + for _, r := range rs { + if r.Attributes.Name != name { + continue + } + p, err := toProfile(r) + if err != nil { + return nil, err + } + profiles = append(profiles, p) + } + return profiles, nil +} + +// ProfileCertificateIDs returns the IDs of the certificates in a profile. +func (c *Client) ProfileCertificateIDs(ctx context.Context, profileID string) ([]string, error) { + return c.relatedIDs(ctx, "/v1/profiles/"+profileID+"/relationships/certificates") +} + +// ProfileDeviceIDs returns the IDs of the devices in a profile. +func (c *Client) ProfileDeviceIDs(ctx context.Context, profileID string) ([]string, error) { + return c.relatedIDs(ctx, "/v1/profiles/"+profileID+"/relationships/devices") +} + +// relatedIDs reads a paginated to-many relationship endpoint. +func (c *Client) relatedIDs(ctx context.Context, path string) ([]string, error) { + rs, err := getAll[struct{}](ctx, c, path, nil) + if err != nil { + return nil, err + } + ids := make([]string, 0, len(rs)) + for _, r := range rs { + ids = append(ids, r.ID) + } + return ids, nil +} + +// CreateProfile creates a profile for the App ID with the given certificates +// and devices. deviceIDs must be nil for ProfileTypeIOSAppStore. +func (c *Client) CreateProfile(ctx context.Context, name, profileType, bundleIDResourceID string, certificateIDs, deviceIDs []string) (*Profile, error) { + rels := Relationships{ + "bundleId": ToOne("bundleIds", bundleIDResourceID), + "certificates": ToMany("certificates", certificateIDs), + } + if deviceIDs != nil { + rels["devices"] = ToMany("devices", deviceIDs) + } + req := Resource[profileAttributes]{Type: "profiles", Attributes: profileAttributes{Name: name, ProfileType: profileType}, Relationships: rels} + r, err := post[profileAttributes, profileAttributes](ctx, c, "/v1/profiles", req) + if err != nil { + return nil, err + } + p, err := toProfile(*r) + if err != nil { + return nil, err + } + return &p, nil +} + +// DeleteProfile removes a profile. Certificates and devices are untouched. +func (c *Client) DeleteProfile(ctx context.Context, profileID string) error { + return c.Delete(ctx, "/v1/profiles/"+profileID, nil) +} diff --git a/internal/asc/profiles_test.go b/internal/asc/profiles_test.go new file mode 100644 index 0000000..ad45f77 --- /dev/null +++ b/internal/asc/profiles_test.go @@ -0,0 +1,127 @@ +package asc + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestListProfilesByNameDropsOtherNames(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/profiles" || r.URL.Query().Get("filter[name]") != "Builder development com.example.app" { + t.Errorf("unexpected request %s %s", r.Method, r.URL) + } + writeJSON(w, 200, map[string]any{"data": []map[string]any{ + {"type": "profiles", "id": "prof-1", "attributes": map[string]any{ + "name": "Builder development com.example.app", "platform": "IOS", "uuid": "1111-2222", "profileState": "ACTIVE", "profileType": "IOS_APP_DEVELOPMENT", + "profileContent": base64.StdEncoding.EncodeToString([]byte("plist")), "createdDate": "2026-09-16T10:00:00.000+00:00", "expirationDate": "2027-09-16T10:00:00.000+00:00", + }}, + {"type": "profiles", "id": "prof-2", "attributes": map[string]any{"name": "Builder development com.example.app 2", "profileState": "INVALID"}}, + }}) + })) + defer srv.Close() + profiles, err := newTestClient(t, srv).ListProfilesByName(context.Background(), "Builder development com.example.app") + if err != nil { + t.Fatal(err) + } + if len(profiles) != 1 { + t.Fatalf("profiles = %+v", profiles) + } + p := profiles[0] + if p.ID != "prof-1" || p.UUID != "1111-2222" || p.State != ProfileStateActive || p.Type != ProfileTypeIOSAppDevelopment || string(p.Content) != "plist" || p.ExpirationDate.Year() != 2027 || p.CreatedDate.Year() != 2026 { + t.Errorf("profile = %+v", p) + } +} + +func TestProfileRelationshipIDsFollowPagination(t *testing.T) { + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/v1/profiles/prof-1/relationships/certificates": + writeJSON(w, 200, map[string]any{"data": []map[string]any{{"type": "certificates", "id": "cert-1"}}}) + case r.URL.Path == "/v1/profiles/prof-1/relationships/devices" && r.URL.Query().Get("cursor") == "": + writeJSON(w, 200, map[string]any{ + "data": []map[string]any{{"type": "devices", "id": "dev-1"}}, + "links": map[string]string{"next": srv.URL + "/v1/profiles/prof-1/relationships/devices?limit=200&cursor=n"}, + }) + case r.URL.Path == "/v1/profiles/prof-1/relationships/devices": + writeJSON(w, 200, map[string]any{"data": []map[string]any{{"type": "devices", "id": "dev-2"}}}) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL) + } + })) + defer srv.Close() + c := newTestClient(t, srv) + certs, err := c.ProfileCertificateIDs(context.Background(), "prof-1") + if err != nil || len(certs) != 1 || certs[0] != "cert-1" { + t.Errorf("certs = %v, err = %v", certs, err) + } + devices, err := c.ProfileDeviceIDs(context.Background(), "prof-1") + if err != nil || len(devices) != 2 || devices[0] != "dev-1" || devices[1] != "dev-2" { + t.Errorf("devices = %v, err = %v", devices, err) + } +} + +func TestCreateAndDeleteProfile(t *testing.T) { + var body map[string]any + var deleted string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/v1/profiles": + _ = json.NewDecoder(r.Body).Decode(&body) + writeJSON(w, 201, map[string]any{"data": map[string]any{"type": "profiles", "id": "prof-9", "attributes": map[string]any{ + "name": "Builder ad-hoc com.example.app", "profileType": "IOS_APP_ADHOC", "profileState": "ACTIVE", "uuid": "u-9", "profileContent": base64.StdEncoding.EncodeToString([]byte("plist")), + }}}) + case r.Method == http.MethodDelete && r.URL.Path == "/v1/profiles/prof-1": + deleted = "prof-1" + w.WriteHeader(204) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL) + } + })) + defer srv.Close() + c := newTestClient(t, srv) + ctx := context.Background() + + p, err := c.CreateProfile(ctx, "Builder ad-hoc com.example.app", ProfileTypeIOSAppAdHoc, "bid-1", []string{"cert-1"}, []string{"dev-1", "dev-2"}) + if err != nil { + t.Fatal(err) + } + if p.ID != "prof-9" || p.State != ProfileStateActive || p.UUID != "u-9" || string(p.Content) != "plist" { + t.Errorf("profile = %+v", p) + } + data := obj(t, body, "data") + attrs := obj(t, data, "attributes") + if data["type"] != "profiles" || attrs["name"] != "Builder ad-hoc com.example.app" || attrs["profileType"] != "IOS_APP_ADHOC" { + t.Errorf("POST body = %v", body) + } + if _, has := attrs["profileContent"]; has { + t.Errorf("request must not carry profileContent: %v", attrs) + } + if obj(t, data, "relationships", "bundleId", "data")["id"] != "bid-1" { + t.Errorf("bundleId relationship = %v", obj(t, data, "relationships")) + } + certs := arr(t, data, "relationships", "certificates", "data") + if len(certs) != 1 || obj(t, certs[0])["type"] != "certificates" || obj(t, certs[0])["id"] != "cert-1" { + t.Errorf("certificates relationship = %v", certs) + } + devices := arr(t, data, "relationships", "devices", "data") + if len(devices) != 2 || obj(t, devices[1])["id"] != "dev-2" { + t.Errorf("devices relationship = %v", devices) + } + + // App Store profiles take no devices; the relationship must be absent, not empty. + if _, err := c.CreateProfile(ctx, "Builder app-store com.example.app", ProfileTypeIOSAppStore, "bid-1", []string{"cert-1"}, nil); err != nil { + t.Fatal(err) + } + if _, has := obj(t, body, "data", "relationships")["devices"]; has { + t.Errorf("App Store profile request carries devices: %v", body) + } + + if err := c.DeleteProfile(ctx, "prof-1"); err != nil || deleted != "prof-1" { + t.Errorf("delete: err = %v, deleted = %q", err, deleted) + } +} From e15399f00cb73b9415769b3746e3c4e4bd6fdce7 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:45:28 +0200 Subject: [PATCH 2/7] signing: provision certificates, devices and profiles through the ASC API signing.Auto finds or registers the App ID, reuses a valid certificate only when the matching private key is on this machine (otherwise no .p12 can be built, so a new one is issued; nothing is ever revoked), registers missing devices and puts every enabled iOS device into the profile, and recreates the Builder-managed profile only when it is missing, INVALID, expired, forced, or its certificate or device set changed. Apple's quota refusals for certificates and devices get an explanatory hint. CreateCSR is split out of GenerateKeyAndCSR so a CSR can be made for an existing key, and KeyMatchesCertificate exposes the check BuildP12 does. --- internal/signing/auto.go | 484 +++++++++++++++++++++++++ internal/signing/auto_test.go | 640 ++++++++++++++++++++++++++++++++++ internal/signing/signing.go | 94 +++-- 3 files changed, 1191 insertions(+), 27 deletions(-) create mode 100644 internal/signing/auto.go create mode 100644 internal/signing/auto_test.go diff --git a/internal/signing/auto.go b/internal/signing/auto.go new file mode 100644 index 0000000..785b2bb --- /dev/null +++ b/internal/signing/auto.go @@ -0,0 +1,484 @@ +package signing + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" + "time" + "unicode" + + "github.com/MobAI-App/ios-builder/internal/asc" +) + +// Type is what the signing material is for: which certificate is issued and +// which profile type wraps it. +type Type string + +// Signing types, as accepted by --type. +const ( + TypeDevelopment Type = "development" + TypeAdHoc Type = "ad-hoc" + TypeAppStore Type = "app-store" +) + +// ParseType validates a --type value. +func ParseType(s string) (Type, error) { + switch t := Type(strings.ToLower(strings.TrimSpace(s))); t { + case TypeDevelopment, TypeAdHoc, TypeAppStore: + return t, nil + case "adhoc": + return TypeAdHoc, nil + case "appstore": + return TypeAppStore, nil + default: + return "", fmt.Errorf("--type must be development, ad-hoc or app-store, got %q", s) + } +} + +// NeedsDevices reports whether profiles of this type list the devices the +// app may run on; App Store profiles do not. +func (t Type) NeedsDevices() bool { return t != TypeAppStore } + +func (t Type) certificateType() string { + if t == TypeDevelopment { + return asc.CertificateTypeDevelopment + } + return asc.CertificateTypeDistribution +} + +func (t Type) profileType() string { + switch t { + case TypeAdHoc: + return asc.ProfileTypeIOSAppAdHoc + case TypeAppStore: + return asc.ProfileTypeIOSAppStore + default: + return asc.ProfileTypeIOSAppDevelopment + } +} + +// Device is a device to register, by UDID. +type Device struct { + Name string `json:"name"` + UDID string `json:"udid"` +} + +// File names written to the output directory. +const ( + KeyFileName = "ios-signing.key" + P12FileName = "ios-signing.p12" +) + +// AutoOptions configures Auto. +type AutoOptions struct { + BundleID string + Type Type + // Devices are registered when missing; development and ad-hoc profiles + // then cover every enabled iOS device on the account. + Devices []Device + // KeyPEM is an existing private key. When nil a key is generated and + // written to OutDir/ios-signing.key. + KeyPEM []byte + // CommonName goes into the CSR subject of a new certificate. + CommonName string + // Password protects the .p12. + Password string + // Force issues a new certificate and profile even when valid ones exist. + Force bool + // OutDir receives the key, .p12 and .mobileprovision (default "."). + OutDir string + // Log receives progress; nil is silent. + Log io.Writer + // now is replaced by tests. + now func() time.Time +} + +// BundleIDResult reports the App ID used. +type BundleIDResult struct { + ID string `json:"id"` + Identifier string `json:"identifier"` + Created bool `json:"created"` +} + +// CertificateResult reports the certificate the .p12 holds. +type CertificateResult struct { + ID string `json:"id"` + Name string `json:"name"` + SerialNumber string `json:"serial_number"` + Type string `json:"type"` + ExpirationDate time.Time `json:"expiration_date"` + Created bool `json:"created"` + // ValidOnAccount counts unexpired certificates of this type before the + // run, so a user hitting Apple's limit can see why. + ValidOnAccount int `json:"valid_on_account"` +} + +// DevicesResult reports device registration; empty for App Store. +type DevicesResult struct { + Registered []Device `json:"registered"` + // InProfile counts the enabled devices the profile covers. + InProfile int `json:"in_profile"` +} + +// ProfileResult reports the profile written. +type ProfileResult struct { + ID string `json:"id"` + Name string `json:"name"` + UUID string `json:"uuid"` + Type string `json:"type"` + State string `json:"state"` + ExpirationDate time.Time `json:"expiration_date"` + Created bool `json:"created"` + // Reason says why a profile was created ("missing", "invalid", "expired", + // "certificate changed", "devices changed", "forced"); empty when reused. + Reason string `json:"reason,omitempty"` +} + +// Files lists what Auto wrote. +type Files struct { + // Key is set only when a key was generated. + Key string `json:"key,omitempty"` + P12 string `json:"p12"` + Profile string `json:"profile"` +} + +// AutoResult is what Auto found, created and wrote. +type AutoResult struct { + Type Type `json:"type"` + BundleID BundleIDResult `json:"bundle_id"` + Certificate CertificateResult `json:"certificate"` + Devices DevicesResult `json:"devices"` + Profile ProfileResult `json:"profile"` + Files Files `json:"files"` + // P12 and ProfileContent are the bytes written, for uploading. + P12 []byte `json:"-"` + ProfileContent []byte `json:"-"` +} + +// Auto provisions everything an iOS build needs to sign through the App Store +// Connect API: the App ID, a certificate whose private key is on this +// machine, the devices, and a profile tying them together. It is idempotent: +// a second run reuses what is valid and recreates only what is missing, +// expired, invalid or no longer matches. Nothing is ever revoked. +func Auto(ctx context.Context, client *asc.Client, opts *AutoOptions) (*AutoResult, error) { + if opts.BundleID == "" { + return nil, errors.New("bundle ID is required") + } + if _, err := ParseType(string(opts.Type)); err != nil { + return nil, err + } + if opts.Password == "" { + return nil, errors.New("a .p12 password is required") + } + if opts.OutDir == "" { + opts.OutDir = "." + } + now := opts.now + if now == nil { + now = time.Now + } + res := &AutoResult{Type: opts.Type} + + // 1. Bundle ID + bundle, err := client.BundleIDByIdentifier(ctx, opts.BundleID) + if err != nil { + return res, err + } + if bundle == nil { + logf(opts.Log, "Registering App ID %s...", opts.BundleID) + if bundle, err = client.CreateBundleID(ctx, opts.BundleID, bundleIDName(opts.BundleID), asc.PlatformIOS); err != nil { + return res, fmt.Errorf("register App ID %s: %w", opts.BundleID, err) + } + res.BundleID.Created = true + } else { + logf(opts.Log, "App ID %s is registered (%s)", bundle.Identifier, bundle.Name) + } + res.BundleID.ID, res.BundleID.Identifier = bundle.ID, bundle.Identifier + + // 2. Certificate + keyPEM := opts.KeyPEM + if keyPEM == nil { + if keyPEM, err = generateKey(); err != nil { + return res, err + } + res.Files.Key = filepath.Join(opts.OutDir, KeyFileName) + } + cert, err := ensureCertificate(ctx, client, opts, keyPEM, now(), &res.Certificate) + if err != nil { + return res, err + } + res.P12, err = BuildP12(keyPEM, cert.Content, opts.Password) + if err != nil { + return res, err + } + + // 3. Devices + var deviceIDs []string + if opts.Type.NeedsDevices() { + if deviceIDs, err = ensureDevices(ctx, client, opts, &res.Devices); err != nil { + return res, err + } + } + + // 4. Profile + profile, err := ensureProfile(ctx, client, opts, bundle.ID, cert.ID, deviceIDs, now(), &res.Profile) + if err != nil { + return res, err + } + res.ProfileContent = profile.Content + + // 5. Files + if err := os.MkdirAll(opts.OutDir, 0755); err != nil { + return res, fmt.Errorf("create %s: %w", opts.OutDir, err) + } + if res.Files.Key != "" { + if err := os.WriteFile(res.Files.Key, keyPEM, 0600); err != nil { + return res, fmt.Errorf("write private key: %w", err) + } + } + res.Files.P12 = filepath.Join(opts.OutDir, P12FileName) + if err := os.WriteFile(res.Files.P12, res.P12, 0600); err != nil { + return res, fmt.Errorf("write .p12: %w", err) + } + res.Files.Profile = filepath.Join(opts.OutDir, ProfileFileName(profile.Name)) + if err := os.WriteFile(res.Files.Profile, profile.Content, 0600); err != nil { + return res, fmt.Errorf("write provisioning profile: %w", err) + } + return res, nil +} + +// ProfileName is the portal name of the profile Auto manages for a bundle ID. +func ProfileName(t Type, bundleID string) string { + return fmt.Sprintf("Builder %s %s", t, bundleID) +} + +// ProfileFileName is the .mobileprovision file name for a profile name. +func ProfileFileName(profileName string) string { + return strings.ReplaceAll(profileName, " ", "-") + ".mobileprovision" +} + +// bundleIDName derives the App ID's display name, which the portal restricts +// to letters, digits and spaces. +func bundleIDName(identifier string) string { + mapped := strings.Map(func(r rune) rune { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + return r + } + return ' ' + }, identifier) + return strings.Join(strings.Fields(mapped), " ") +} + +func generateKey() ([]byte, error) { + keyPEM, _, err := GenerateKeyAndCSR("Builder", "") + return keyPEM, err +} + +// ensureCertificate reuses a valid certificate issued for the key, else has +// Apple issue one. Certificates without their key on this machine cannot go +// into a .p12, so they are ignored rather than revoked. +func ensureCertificate(ctx context.Context, client *asc.Client, opts *AutoOptions, keyPEM []byte, now time.Time, out *CertificateResult) (*asc.Certificate, error) { + certType := opts.Type.certificateType() + certs, err := client.ListCertificates(ctx, certType) + if err != nil { + return nil, err + } + var valid []asc.Certificate + for i := range certs { + if certs[i].ExpirationDate.After(now) { + valid = append(valid, certs[i]) + } + } + out.ValidOnAccount = len(valid) + if !opts.Force && opts.KeyPEM != nil { + for i := range valid { + if KeyMatchesCertificate(keyPEM, valid[i].Content) { + logf(opts.Log, "Reusing %s certificate %s (expires %s)", certType, valid[i].Name, valid[i].ExpirationDate.Format("2006-01-02")) + fillCertificate(out, &valid[i], false) + return &valid[i], nil + } + } + logf(opts.Log, "%d valid %s certificate(s) on the account, none issued for the private key", len(valid), certType) + } else if len(valid) > 0 && !opts.Force { + logf(opts.Log, "%d valid %s certificate(s) on the account, but their private keys are not on this machine", len(valid), certType) + } + + commonName := opts.CommonName + if commonName == "" { + commonName = "Builder" + } + csr, err := CreateCSR(keyPEM, commonName, "") + if err != nil { + return nil, err + } + logf(opts.Log, "Requesting a new %s certificate...", certType) + cert, err := client.CreateCertificate(ctx, certType, csr) + if err != nil { + return nil, withLimitHint(err, "Apple limits a team to 2 Apple Development and 3 Apple Distribution certificates. Revoke one you no longer use at https://developer.apple.com/account/resources/certificates/list (Builder never revokes anything), or pass --key with the private key of an existing certificate to reuse it.") + } + if !KeyMatchesCertificate(keyPEM, cert.Content) { + return nil, fmt.Errorf("certificate %s from App Store Connect was not issued for the private key", cert.ID) + } + fillCertificate(out, cert, true) + return cert, nil +} + +func fillCertificate(out *CertificateResult, c *asc.Certificate, created bool) { + out.ID, out.Name, out.SerialNumber, out.Type, out.ExpirationDate, out.Created = c.ID, c.Name, c.SerialNumber, c.Type, c.ExpirationDate, created +} + +// ensureDevices registers the missing devices and returns the IDs of every +// enabled iOS device on the account, sorted, which is what the profile covers. +func ensureDevices(ctx context.Context, client *asc.Client, opts *AutoOptions, out *DevicesResult) ([]string, error) { + devices, err := client.ListDevices(ctx, asc.PlatformIOS) + if err != nil { + return nil, err + } + byUDID := make(map[string]asc.Device, len(devices)) + for _, d := range devices { + byUDID[strings.ToUpper(d.UDID)] = d + } + out.Registered = []Device{} + for _, want := range opts.Devices { + udid := strings.TrimSpace(want.UDID) + if udid == "" { + continue + } + if existing, ok := byUDID[strings.ToUpper(udid)]; ok { + logf(opts.Log, "Device %s is registered as %q (%s)", udid, existing.Name, strings.ToLower(existing.Status)) + continue + } + name := strings.TrimSpace(want.Name) + if name == "" { + name = "iPhone " + udid[max(0, len(udid)-6):] + } + logf(opts.Log, "Registering device %q (%s)...", name, udid) + d, err := client.RegisterDevice(ctx, name, udid, asc.PlatformIOS) + if err != nil { + return nil, withLimitHint(fmt.Errorf("register device %s: %w", udid, err), "Apple allows 100 iOS devices per membership year and frees no slot when one is removed; the count resets when the membership renews. Disable unused devices at https://developer.apple.com/account/resources/devices/list to keep them out of profiles.") + } + byUDID[strings.ToUpper(d.UDID)] = *d + out.Registered = append(out.Registered, Device{Name: d.Name, UDID: d.UDID}) + } + var ids []string + for _, d := range byUDID { + if d.Status == asc.DeviceStatusEnabled { + ids = append(ids, d.ID) + } + } + if len(ids) == 0 { + return nil, fmt.Errorf("no iOS devices are registered on the account and a %s profile needs at least one: pass --device (repeatable) or --devices-from-mobai", opts.Type) + } + slices.Sort(ids) + out.InProfile = len(ids) + logf(opts.Log, "%d enabled device(s) will be in the profile", len(ids)) + return ids, nil +} + +// ensureProfile reuses the Builder-managed profile when it is ACTIVE, +// unexpired and still lists exactly this certificate and these devices; +// otherwise it deletes and recreates it. Same-named duplicates go too. +func ensureProfile(ctx context.Context, client *asc.Client, opts *AutoOptions, bundleResourceID, certID string, deviceIDs []string, now time.Time, out *ProfileResult) (*asc.Profile, error) { + name := ProfileName(opts.Type, opts.BundleID) + profileType := opts.Type.profileType() + existing, err := client.ListProfilesByName(ctx, name) + if err != nil { + return nil, err + } + reason := "missing" + if len(existing) > 0 { + p := &existing[0] + if reason, err = recreateReason(ctx, client, opts, p, certID, deviceIDs, now); err != nil { + return nil, err + } + if reason == "" { + logf(opts.Log, "Reusing profile %q (%s, expires %s)", p.Name, strings.ToLower(p.State), p.ExpirationDate.Format("2006-01-02")) + fillProfile(out, p, false, "") + return p, nil + } + logf(opts.Log, "Recreating profile %q: %s", name, reason) + for i := range existing { + if err := client.DeleteProfile(ctx, existing[i].ID); err != nil { + return nil, fmt.Errorf("delete profile %s: %w", existing[i].ID, err) + } + } + } else { + logf(opts.Log, "Creating profile %q...", name) + } + if !opts.Type.NeedsDevices() { + deviceIDs = nil + } + p, err := client.CreateProfile(ctx, name, profileType, bundleResourceID, []string{certID}, deviceIDs) + if err != nil { + return nil, fmt.Errorf("create profile %q: %w", name, err) + } + if len(p.Content) == 0 { + return nil, fmt.Errorf("profile %s from App Store Connect has no content", p.ID) + } + fillProfile(out, p, true, reason) + return p, nil +} + +// recreateReason says why the profile cannot be reused, or "" when it can. +func recreateReason(ctx context.Context, client *asc.Client, opts *AutoOptions, p *asc.Profile, certID string, deviceIDs []string, now time.Time) (string, error) { + switch { + case opts.Force: + return "forced", nil + case p.State != asc.ProfileStateActive: + return strings.ToLower(p.State), nil + case !p.ExpirationDate.IsZero() && !p.ExpirationDate.After(now): + return "expired", nil + case p.Type != opts.Type.profileType(): + return "type changed", nil + case len(p.Content) == 0: + return "no content", nil + } + certIDs, err := client.ProfileCertificateIDs(ctx, p.ID) + if err != nil { + return "", err + } + if len(certIDs) != 1 || certIDs[0] != certID { + return "certificate changed", nil + } + if opts.Type.NeedsDevices() { + have, err := client.ProfileDeviceIDs(ctx, p.ID) + if err != nil { + return "", err + } + slices.Sort(have) + if !slices.Equal(have, deviceIDs) { + return "devices changed", nil + } + } + return "", nil +} + +func fillProfile(out *ProfileResult, p *asc.Profile, created bool, reason string) { + out.ID, out.Name, out.UUID, out.Type, out.State, out.ExpirationDate, out.Created, out.Reason = p.ID, p.Name, p.UUID, p.Type, p.State, p.ExpirationDate, created, reason +} + +// withLimitHint appends hint when App Store Connect refused for a quota. +func withLimitHint(err error, hint string) error { + var apiErr *asc.Error + if !errors.As(err, &apiErr) { + return err + } + for _, d := range apiErr.Errors { + text := strings.ToLower(d.Title + " " + d.Detail) + if strings.Contains(text, "maximum") || strings.Contains(text, "limit") || strings.Contains(text, "already have") { + return fmt.Errorf("%w\n%s", err, hint) + } + } + return err +} + +func logf(w io.Writer, format string, args ...any) { + if w != nil { + fmt.Fprintf(w, format+"\n", args...) + } +} diff --git a/internal/signing/auto_test.go b/internal/signing/auto_test.go new file mode 100644 index 0000000..3683895 --- /dev/null +++ b/internal/signing/auto_test.go @@ -0,0 +1,640 @@ +package signing + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/MobAI-App/ios-builder/internal/asc" + pkcs12 "software.sslmate.com/src/go-pkcs12" +) + +var testNow = time.Date(2026, 9, 16, 12, 0, 0, 0, time.UTC) + +type certRec struct { + id, typ string + der []byte + exp time.Time +} + +type deviceRec struct{ id, name, udid, status string } + +type profileRec struct { + id, name, typ, state string + certIDs, deviceIDs []string + exp time.Time +} + +// portal is an in-memory Apple Developer portal behind the ASC endpoints Auto uses. +type portal struct { + t *testing.T + srv *httptest.Server + signer *rsa.PrivateKey + mu sync.Mutex + calls []string + seq int + + bundleIDs []string // registered identifiers + certs []certRec + devices []deviceRec + profiles []profileRec + // refuseCertificates / refuseDevices make the POST fail with Apple's quota wording. + refuseCertificates, refuseDevices bool +} + +func newPortal(t *testing.T) *portal { + t.Helper() + signer, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + p := &portal{t: t, signer: signer} + mux := http.NewServeMux() + res := func(typ, id string, attrs map[string]any) map[string]any { + return map[string]any{"type": typ, "id": id, "attributes": attrs} + } + many := func(w http.ResponseWriter, rs ...any) { + if rs == nil { + rs = []any{} + } + writeJSON(w, 200, map[string]any{"data": rs}) + } + refuse := func(w http.ResponseWriter, detail string) { + writeJSON(w, 409, map[string]any{"errors": []map[string]any{{"status": "409", "code": "ENTITY_ERROR.ATTRIBUTE.INVALID", "title": "There is a problem with the request entity", "detail": detail}}}) + } + body := func(r *http.Request) map[string]any { + var b map[string]any + _ = json.NewDecoder(r.Body).Decode(&b) + return b + } + attrs := func(b map[string]any) map[string]any { return obj(p.t, b, "data", "attributes") } + str := func(m map[string]any, key string) string { + s, _ := m[key].(string) + return s + } + linkIDs := func(b map[string]any, rel string) []string { + rels := obj(p.t, b, "data", "relationships") + raw, ok := rels[rel] + if !ok { + return nil + } + var ids []string + for _, l := range arr(p.t, raw, "data") { + ids = append(ids, str(obj(p.t, l), "id")) + } + return ids + } + wrap := func(h func(w http.ResponseWriter, r *http.Request)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { + p.t.Errorf("%s %s without bearer token", r.Method, r.URL.Path) + } + p.mu.Lock() + defer p.mu.Unlock() + p.calls = append(p.calls, r.Method+" "+r.URL.Path) + h(w, r) + } + } + certRes := func(c certRec) map[string]any { + return res("certificates", c.id, map[string]any{"certificateType": c.typ, "name": "Apple " + c.typ + ": Builder", "serialNumber": c.id, "certificateContent": base64.StdEncoding.EncodeToString(c.der), "expirationDate": c.exp.Format(time.RFC3339)}) + } + deviceRes := func(d deviceRec) map[string]any { + return res("devices", d.id, map[string]any{"name": d.name, "udid": d.udid, "platform": "IOS", "status": d.status, "deviceClass": "IPHONE"}) + } + profileRes := func(pr profileRec) map[string]any { + return res("profiles", pr.id, map[string]any{"name": pr.name, "profileType": pr.typ, "profileState": pr.state, "uuid": "uuid-" + pr.id, "platform": "IOS", "profileContent": base64.StdEncoding.EncodeToString([]byte("profile:" + pr.id)), "expirationDate": pr.exp.Format(time.RFC3339)}) + } + + mux.HandleFunc("GET /v1/bundleIds", wrap(func(w http.ResponseWriter, r *http.Request) { + want := r.URL.Query().Get("filter[identifier]") + var rs []any + for _, id := range p.bundleIDs { + if strings.HasPrefix(id, want) { + rs = append(rs, res("bundleIds", "bid-"+id, map[string]any{"identifier": id, "name": bundleIDName(id), "platform": "IOS"})) + } + } + many(w, rs...) + })) + mux.HandleFunc("POST /v1/bundleIds", wrap(func(w http.ResponseWriter, r *http.Request) { + a := attrs(body(r)) + if a["platform"] != "IOS" || a["name"] == "" { + p.t.Errorf("bundleIds POST attributes = %v", a) + } + id := str(a, "identifier") + p.bundleIDs = append(p.bundleIDs, id) + writeJSON(w, 201, map[string]any{"data": res("bundleIds", "bid-"+id, a)}) + })) + mux.HandleFunc("GET /v1/certificates", wrap(func(w http.ResponseWriter, r *http.Request) { + var rs []any + for _, c := range p.certs { + if c.typ == r.URL.Query().Get("filter[certificateType]") { + rs = append(rs, certRes(c)) + } + } + many(w, rs...) + })) + mux.HandleFunc("POST /v1/certificates", wrap(func(w http.ResponseWriter, r *http.Request) { + if p.refuseCertificates { + refuse(w, "You already have a current Development certificate or a pending certificate request; the maximum number of certificates has been reached.") + return + } + a := attrs(body(r)) + block, _ := pem.Decode([]byte(str(a, "csrContent"))) + if block == nil { + p.t.Fatalf("csrContent is not PEM: %v", a["csrContent"]) + } + csr, err := x509.ParseCertificateRequest(block.Bytes) + if err != nil { + p.t.Fatalf("parse CSR: %v", err) + } + if err := csr.CheckSignature(); err != nil { + p.t.Fatalf("CSR signature: %v", err) + } + pub, ok := csr.PublicKey.(*rsa.PublicKey) + if !ok { + p.t.Fatalf("CSR public key is %T, want RSA", csr.PublicKey) + } + c := p.issue(str(a, "certificateType"), pub, testNow.AddDate(1, 0, 0)) + writeJSON(w, 201, map[string]any{"data": certRes(c)}) + })) + mux.HandleFunc("GET /v1/devices", wrap(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("filter[platform]") != "IOS" { + p.t.Errorf("devices query = %v", r.URL.Query()) + } + var rs []any + for _, d := range p.devices { + rs = append(rs, deviceRes(d)) + } + many(w, rs...) + })) + mux.HandleFunc("POST /v1/devices", wrap(func(w http.ResponseWriter, r *http.Request) { + if p.refuseDevices { + refuse(w, "You have reached the maximum number of devices for this membership year.") + return + } + a := attrs(body(r)) + d := deviceRec{id: p.nextID("dev"), name: str(a, "name"), udid: str(a, "udid"), status: "ENABLED"} + p.devices = append(p.devices, d) + writeJSON(w, 201, map[string]any{"data": deviceRes(d)}) + })) + mux.HandleFunc("GET /v1/profiles", wrap(func(w http.ResponseWriter, r *http.Request) { + var rs []any + for i := range p.profiles { + if p.profiles[i].name == r.URL.Query().Get("filter[name]") { + rs = append(rs, profileRes(p.profiles[i])) + } + } + many(w, rs...) + })) + mux.HandleFunc("GET /v1/profiles/{id}/relationships/{rel}", wrap(func(w http.ResponseWriter, r *http.Request) { + for i := range p.profiles { + pr := &p.profiles[i] + if pr.id != r.PathValue("id") { + continue + } + ids, typ := pr.certIDs, "certificates" + if r.PathValue("rel") == "devices" { + ids, typ = pr.deviceIDs, "devices" + } + var rs []any + for _, id := range ids { + rs = append(rs, map[string]any{"type": typ, "id": id}) + } + many(w, rs...) + return + } + writeJSON(w, 404, map[string]any{"errors": []map[string]any{{"code": "NOT_FOUND", "title": "not found"}}}) + })) + mux.HandleFunc("POST /v1/profiles", wrap(func(w http.ResponseWriter, r *http.Request) { + b := body(r) + a := attrs(b) + bundle, ok := obj(p.t, b, "data", "relationships", "bundleId", "data")["id"].(string) + if !ok || !slices.Contains(p.bundleIDs, strings.TrimPrefix(bundle, "bid-")) { + p.t.Errorf("profile POST for unknown bundle ID %q", bundle) + } + pr := profileRec{id: p.nextID("prof"), name: str(a, "name"), typ: str(a, "profileType"), state: "ACTIVE", certIDs: linkIDs(b, "certificates"), deviceIDs: linkIDs(b, "devices"), exp: testNow.AddDate(1, 0, 0)} + if pr.typ == asc.ProfileTypeIOSAppStore && pr.deviceIDs != nil { + p.t.Errorf("App Store profile POST carries devices: %v", pr.deviceIDs) + } + p.profiles = append(p.profiles, pr) + writeJSON(w, 201, map[string]any{"data": profileRes(pr)}) + })) + mux.HandleFunc("DELETE /v1/profiles/{id}", wrap(func(w http.ResponseWriter, r *http.Request) { + p.profiles = slices.DeleteFunc(p.profiles, func(pr profileRec) bool { return pr.id == r.PathValue("id") }) + w.WriteHeader(204) + })) + mux.HandleFunc("/", wrap(func(w http.ResponseWriter, r *http.Request) { + p.t.Errorf("unexpected request %s %s", r.Method, r.URL) + w.WriteHeader(404) + })) + p.srv = httptest.NewServer(mux) + t.Cleanup(p.srv.Close) + return p +} + +func (p *portal) nextID(prefix string) string { + p.seq++ + return fmt.Sprintf("%s-%d", prefix, p.seq) +} + +// issue signs a certificate for pub and records it; callers hold p.mu or run before the server. +func (p *portal) issue(typ string, pub *rsa.PublicKey, exp time.Time) certRec { + c := certRec{id: p.nextID("cert"), typ: typ, der: issueCert(p.t, pub, p.signer), exp: exp} + p.certs = append(p.certs, c) + return c +} + +func (p *portal) client(t *testing.T) *asc.Client { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + der, _ := x509.MarshalPKCS8PrivateKey(key) + creds := asc.Credentials{IssuerID: "iss", KeyID: "kid", PrivateKey: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))} + c, err := asc.NewClient(creds, asc.WithBaseURL(p.srv.URL), asc.WithRetryDelay(time.Millisecond)) + if err != nil { + t.Fatal(err) + } + return c +} + +// count returns how many recorded calls match "METHOD /path". +func (p *portal) count(key string) int { + p.mu.Lock() + defer p.mu.Unlock() + n := 0 + for _, c := range p.calls { + if c == key { + n++ + } + } + return n +} + +func (p *portal) reset() { + p.mu.Lock() + defer p.mu.Unlock() + p.calls = nil +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func obj(t *testing.T, v any, keys ...string) map[string]any { + t.Helper() + for i := 0; ; i++ { + m, ok := v.(map[string]any) + if !ok { + t.Errorf("JSON path %v: %T is not an object", keys[:i], v) + return nil + } + if i == len(keys) { + return m + } + v = m[keys[i]] + } +} + +func arr(t *testing.T, v any, keys ...string) []any { + t.Helper() + if len(keys) > 0 { + v = obj(t, v, keys[:len(keys)-1]...)[keys[len(keys)-1]] + } + a, ok := v.([]any) + if !ok { + t.Errorf("JSON path %v: %T is not an array", keys, v) + } + return a +} + +func devOpts(dir string) *AutoOptions { + return &AutoOptions{ + BundleID: "com.example.app", + Type: TypeDevelopment, + Devices: []Device{{Name: "Jane's iPhone", UDID: "00008030-000000000000001E"}}, + Password: "secret", + OutDir: dir, + now: func() time.Time { return testNow }, + } +} + +func run(t *testing.T, p *portal, opts *AutoOptions) *AutoResult { + t.Helper() + p.reset() + res, err := Auto(context.Background(), p.client(t), opts) + if err != nil { + t.Fatalf("Auto: %v", err) + } + return res +} + +func TestAutoFirstRunCreatesEverything(t *testing.T) { + p := newPortal(t) + dir := t.TempDir() + res := run(t, p, devOpts(dir)) + + if !res.BundleID.Created || res.BundleID.ID != "bid-com.example.app" { + t.Errorf("bundle ID = %+v", res.BundleID) + } + if !res.Certificate.Created || res.Certificate.Type != asc.CertificateTypeDevelopment || res.Certificate.ValidOnAccount != 0 { + t.Errorf("certificate = %+v", res.Certificate) + } + if len(res.Devices.Registered) != 1 || res.Devices.Registered[0].Name != "Jane's iPhone" || res.Devices.InProfile != 1 { + t.Errorf("devices = %+v", res.Devices) + } + if !res.Profile.Created || res.Profile.Reason != "missing" || res.Profile.Name != "Builder development com.example.app" || res.Profile.Type != asc.ProfileTypeIOSAppDevelopment || res.Profile.State != asc.ProfileStateActive { + t.Errorf("profile = %+v", res.Profile) + } + if p.count("DELETE /v1/profiles/prof-3") != 0 || p.count("POST /v1/profiles") != 1 || p.count("POST /v1/certificates") != 1 || p.count("POST /v1/devices") != 1 || p.count("POST /v1/bundleIds") != 1 { + t.Errorf("calls = %v", p.calls) + } + + // Files: key, .p12 and profile in the output directory; the .p12 opens with the password and holds the issued certificate. + if res.Files.Key != filepath.Join(dir, "ios-signing.key") || res.Files.P12 != filepath.Join(dir, "ios-signing.p12") || res.Files.Profile != filepath.Join(dir, "Builder-development-com.example.app.mobileprovision") { + t.Errorf("files = %+v", res.Files) + } + keyPEM, err := os.ReadFile(res.Files.Key) + if err != nil { + t.Fatal(err) + } + p12, err := os.ReadFile(res.Files.P12) + if err != nil { + t.Fatal(err) + } + gotKey, gotCert, err := pkcs12.Decode(p12, "secret") + if err != nil { + t.Fatalf("pkcs12.Decode: %v", err) + } + rsaKey, ok := gotKey.(*rsa.PrivateKey) + if !ok || !KeyMatchesCertificate(keyPEM, gotCert.Raw) || !rsaKey.PublicKey.Equal(gotCert.PublicKey) { + t.Error(".p12 key and certificate do not match the written key") + } + if !slices.Equal(gotCert.Raw, p.certs[0].der) { + t.Error(".p12 holds a different certificate than the portal issued") + } + profile, err := os.ReadFile(res.Files.Profile) + if err != nil || string(profile) != "profile:prof-3" || string(res.ProfileContent) != "profile:prof-3" { + t.Errorf("profile file = %q, err = %v", profile, err) + } +} + +func TestAutoSecondRunReusesEverything(t *testing.T) { + p := newPortal(t) + dir := t.TempDir() + first := run(t, p, devOpts(dir)) + keyPEM, err := os.ReadFile(first.Files.Key) + if err != nil { + t.Fatal(err) + } + + opts := devOpts(dir) + opts.KeyPEM = keyPEM + res := run(t, p, opts) + if res.BundleID.Created || res.Certificate.Created || res.Profile.Created || res.Profile.Reason != "" || len(res.Devices.Registered) != 0 { + t.Errorf("second run created something: %+v", res) + } + if res.Certificate.ID != first.Certificate.ID || res.Profile.ID != first.Profile.ID || res.Certificate.ValidOnAccount != 1 { + t.Errorf("second run = %+v, first = %+v", res, first) + } + if res.Files.Key != "" { + t.Errorf("a supplied key must not be rewritten: %+v", res.Files) + } + for _, call := range p.calls { + if strings.HasPrefix(call, "POST") || strings.HasPrefix(call, "DELETE") { + t.Errorf("second run made %s", call) + } + } + if _, err := os.Stat(res.Files.P12); err != nil { + t.Errorf(".p12 not rewritten: %v", err) + } +} + +func TestAutoRecreatesProfileWhenDevicesChange(t *testing.T) { + p := newPortal(t) + dir := t.TempDir() + first := run(t, p, devOpts(dir)) + keyPEM, _ := os.ReadFile(first.Files.Key) + + opts := devOpts(dir) + opts.KeyPEM = keyPEM + opts.Devices = append(opts.Devices, Device{UDID: "00008110-00000000000000AB"}) + res := run(t, p, opts) + if res.Certificate.Created || !res.Profile.Created || res.Profile.Reason != "devices changed" || res.Devices.InProfile != 2 { + t.Errorf("result = %+v", res) + } + if len(res.Devices.Registered) != 1 || res.Devices.Registered[0].Name != "iPhone 0000AB" { + t.Errorf("registered = %+v (want the default name from the UDID)", res.Devices.Registered) + } + if p.count("DELETE /v1/profiles/"+first.Profile.ID) != 1 || p.count("POST /v1/profiles") != 1 || len(p.profiles) != 1 { + t.Errorf("calls = %v, profiles = %+v", p.calls, p.profiles) + } + if len(p.profiles[0].deviceIDs) != 2 { + t.Errorf("new profile devices = %v", p.profiles[0].deviceIDs) + } +} + +func TestAutoRecreatesInvalidProfile(t *testing.T) { + p := newPortal(t) + dir := t.TempDir() + first := run(t, p, devOpts(dir)) + keyPEM, _ := os.ReadFile(first.Files.Key) + p.profiles[0].state = asc.ProfileStateInvalid + + opts := devOpts(dir) + opts.KeyPEM = keyPEM + res := run(t, p, opts) + if res.Certificate.Created || !res.Profile.Created || res.Profile.Reason != "invalid" || res.Profile.ID == first.Profile.ID { + t.Errorf("result = %+v", res) + } + // An INVALID profile needs no relationship lookups to be condemned. + if p.count("GET /v1/profiles/"+first.Profile.ID+"/relationships/certificates") != 0 { + t.Errorf("calls = %v", p.calls) + } +} + +func TestAutoRecreatesProfileWhenCertificateChanges(t *testing.T) { + p := newPortal(t) + dir := t.TempDir() + first := run(t, p, devOpts(dir)) + + // No key on disk any more: a new certificate is issued and the profile + // follows it; the old certificate stays on the account. + res := run(t, p, devOpts(t.TempDir())) + if !res.Certificate.Created || res.Certificate.ID == first.Certificate.ID || res.Certificate.ValidOnAccount != 1 { + t.Errorf("certificate = %+v", res.Certificate) + } + if !res.Profile.Created || res.Profile.Reason != "certificate changed" { + t.Errorf("profile = %+v", res.Profile) + } + if len(p.certs) != 2 || p.profiles[0].certIDs[0] != res.Certificate.ID { + t.Errorf("certs = %d, profile certs = %v", len(p.certs), p.profiles[0].certIDs) + } +} + +func TestAutoForceIssuesNewCertificateAndProfile(t *testing.T) { + p := newPortal(t) + dir := t.TempDir() + first := run(t, p, devOpts(dir)) + keyPEM, _ := os.ReadFile(first.Files.Key) + + opts := devOpts(dir) + opts.KeyPEM = keyPEM + opts.Force = true + res := run(t, p, opts) + if !res.Certificate.Created || res.Certificate.ID == first.Certificate.ID || !res.Profile.Created || res.Profile.Reason != "forced" { + t.Errorf("result = %+v", res) + } + if len(p.certs) != 2 { + t.Errorf("force must not revoke the old certificate: %d certificates left", len(p.certs)) + } + if !KeyMatchesCertificate(keyPEM, p.certs[1].der) { + t.Error("the new certificate must be issued for the supplied key") + } +} + +func TestAutoAppStoreNeedsNoDevices(t *testing.T) { + p := newPortal(t) + p.bundleIDs = []string{"com.example.app.widget", "com.example.app"} + opts := devOpts(t.TempDir()) + opts.Type = TypeAppStore + opts.Devices = nil + res := run(t, p, opts) + if res.BundleID.Created || res.BundleID.ID != "bid-com.example.app" { + t.Errorf("bundle ID = %+v (must match the exact identifier)", res.BundleID) + } + if res.Certificate.Type != asc.CertificateTypeDistribution || res.Profile.Type != asc.ProfileTypeIOSAppStore || res.Profile.Name != "Builder app-store com.example.app" { + t.Errorf("result = %+v", res) + } + if res.Devices.InProfile != 0 || p.count("GET /v1/devices") != 0 || p.profiles[0].deviceIDs != nil { + t.Errorf("App Store setup touched devices: %+v, calls %v", res.Devices, p.calls) + } +} + +func TestAutoDevelopmentWithoutDevicesFails(t *testing.T) { + p := newPortal(t) + opts := devOpts(t.TempDir()) + opts.Devices = nil + _, err := Auto(context.Background(), p.client(t), opts) + if err == nil || !strings.Contains(err.Error(), "--device") || !strings.Contains(err.Error(), "--devices-from-mobai") { + t.Errorf("err = %v", err) + } + if p.count("POST /v1/profiles") != 0 { + t.Errorf("profile created without devices: %v", p.calls) + } +} + +func TestAutoDisabledDevicesStayOutOfProfile(t *testing.T) { + p := newPortal(t) + p.devices = []deviceRec{ + {id: "dev-old", name: "Old", udid: "00008020-0000000000000001", status: "DISABLED"}, + {id: "dev-ok", name: "Jane's iPhone", udid: "00008030-000000000000001e", status: "ENABLED"}, + } + res := run(t, p, devOpts(t.TempDir())) + if len(res.Devices.Registered) != 0 { + t.Errorf("UDID matching must be case-insensitive: registered %+v", res.Devices.Registered) + } + if res.Devices.InProfile != 1 || !slices.Equal(p.profiles[0].deviceIDs, []string{"dev-ok"}) { + t.Errorf("profile devices = %v", p.profiles[0].deviceIDs) + } +} + +func TestAutoWithSuppliedKeyReusesMatchingCertificate(t *testing.T) { + p := newPortal(t) + keyPEM, _, err := GenerateKeyAndCSR("Jane", "jane@example.com") + if err != nil { + t.Fatal(err) + } + key, _ := parseKey(keyPEM) + p.issue(asc.CertificateTypeDevelopment, &key.PublicKey, testNow.AddDate(0, 6, 0)) + // An expired one for the same key must not be picked. + expired := p.issue(asc.CertificateTypeDevelopment, &key.PublicKey, testNow.AddDate(0, -1, 0)) + + opts := devOpts(t.TempDir()) + opts.KeyPEM = keyPEM + res := run(t, p, opts) + if res.Certificate.Created || res.Certificate.ID != "cert-1" || res.Certificate.ID == expired.id || res.Certificate.ValidOnAccount != 1 { + t.Errorf("certificate = %+v", res.Certificate) + } + if p.count("POST /v1/certificates") != 0 { + t.Errorf("calls = %v", p.calls) + } +} + +func TestAutoCertificateLimitHint(t *testing.T) { + p := newPortal(t) + p.refuseCertificates = true + _, err := Auto(context.Background(), p.client(t), devOpts(t.TempDir())) + if err == nil || !strings.Contains(err.Error(), "maximum number of certificates") || !strings.Contains(err.Error(), "Revoke one") || !strings.Contains(err.Error(), "--key") { + t.Errorf("err = %v", err) + } +} + +func TestAutoDeviceLimitHint(t *testing.T) { + p := newPortal(t) + p.refuseDevices = true + _, err := Auto(context.Background(), p.client(t), devOpts(t.TempDir())) + if err == nil || !strings.Contains(err.Error(), "00008030-000000000000001E") || !strings.Contains(err.Error(), "100 iOS devices") { + t.Errorf("err = %v", err) + } +} + +func TestAutoRejectsBadOptions(t *testing.T) { + p := newPortal(t) + for name, mutate := range map[string]func(*AutoOptions){ + "no bundle ID": func(o *AutoOptions) { o.BundleID = "" }, + "bad type": func(o *AutoOptions) { o.Type = "enterprise" }, + "no password": func(o *AutoOptions) { o.Password = "" }, + } { + opts := devOpts(t.TempDir()) + mutate(opts) + if _, err := Auto(context.Background(), p.client(t), opts); err == nil { + t.Errorf("%s: no error", name) + } + } + if len(p.calls) != 0 { + t.Errorf("invalid options reached the API: %v", p.calls) + } +} + +func TestParseType(t *testing.T) { + for in, want := range map[string]Type{"development": TypeDevelopment, "Ad-Hoc": TypeAdHoc, "adhoc": TypeAdHoc, "app-store": TypeAppStore, "appstore": TypeAppStore} { + got, err := ParseType(in) + if err != nil || got != want { + t.Errorf("ParseType(%q) = %q, %v; want %q", in, got, err, want) + } + } + if _, err := ParseType("enterprise"); err == nil || !strings.Contains(err.Error(), "enterprise") { + t.Errorf("err = %v", err) + } + if TypeAppStore.NeedsDevices() || !TypeAdHoc.NeedsDevices() || !TypeDevelopment.NeedsDevices() { + t.Error("NeedsDevices: only App Store profiles list no devices") + } +} + +func TestBundleIDName(t *testing.T) { + for in, want := range map[string]string{"com.example.app": "com example app", "com.example.my-app_2": "com example my app 2", "App": "App"} { + if got := bundleIDName(in); got != want { + t.Errorf("bundleIDName(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/signing/signing.go b/internal/signing/signing.go index 5e71360..14fccbd 100644 --- a/internal/signing/signing.go +++ b/internal/signing/signing.go @@ -24,45 +24,50 @@ func GenerateKeyAndCSR(commonName, email string) (keyPEM, csrPEM []byte, err err if err != nil { return nil, nil, fmt.Errorf("failed to generate private key: %w", err) } + keyPEM = pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + }) + csrPEM, err = CreateCSR(keyPEM, commonName, email) + if err != nil { + return nil, nil, err + } + return keyPEM, csrPEM, nil +} +// CreateCSR makes a PEM certificate signing request for an existing private +// key (as written by GenerateKeyAndCSR). The email is omitted from the +// subject when empty. +func CreateCSR(keyPEM []byte, commonName, email string) ([]byte, error) { + key, err := parseKey(keyPEM) + if err != nil { + return nil, err + } template := x509.CertificateRequest{ - Subject: pkix.Name{ - CommonName: commonName, - ExtraNames: []pkix.AttributeTypeAndValue{ - // emailAddress (OID 1.2.840.113549.1.9.1), as in Keychain CSRs. - // Forced to IA5String: Go would otherwise encode the '@' as - // UTF8String, which is not the standard encoding for this field. - { - Type: []int{1, 2, 840, 113549, 1, 9, 1}, - Value: asn1.RawValue{Tag: asn1.TagIA5String, Bytes: []byte(email)}, - }, - }, - }, + Subject: pkix.Name{CommonName: commonName}, SignatureAlgorithm: x509.SHA256WithRSA, } + if email != "" { + // emailAddress (OID 1.2.840.113549.1.9.1), as in Keychain CSRs. + // Forced to IA5String: Go would otherwise encode the '@' as + // UTF8String, which is not the standard encoding for this field. + template.Subject.ExtraNames = []pkix.AttributeTypeAndValue{{ + Type: []int{1, 2, 840, 113549, 1, 9, 1}, + Value: asn1.RawValue{Tag: asn1.TagIA5String, Bytes: []byte(email)}, + }} + } csrDER, err := x509.CreateCertificateRequest(rand.Reader, &template, key) if err != nil { - return nil, nil, fmt.Errorf("failed to create CSR: %w", err) + return nil, fmt.Errorf("failed to create CSR: %w", err) } - - keyPEM = pem.EncodeToMemory(&pem.Block{ - Type: "RSA PRIVATE KEY", - Bytes: x509.MarshalPKCS1PrivateKey(key), - }) - csrPEM = pem.EncodeToMemory(&pem.Block{ + return pem.EncodeToMemory(&pem.Block{ Type: "CERTIFICATE REQUEST", Bytes: csrDER, - }) - return keyPEM, csrPEM, nil + }), nil } -// BuildP12 combines a PEM private key (from GenerateKeyAndCSR) with the -// certificate Apple issued for its CSR (DER .cer as downloaded from the -// portal, or PEM) into a password-protected PKCS#12 bundle, the same format -// Keychain Access exports. The legacy encoding is used because that is what -// macOS `security import` expects. -func BuildP12(keyPEM, certData []byte, password string) ([]byte, error) { +func parseKey(keyPEM []byte) (*rsa.PrivateKey, error) { block, _ := pem.Decode(keyPEM) if block == nil { return nil, fmt.Errorf("invalid private key: not PEM encoded") @@ -71,7 +76,10 @@ func BuildP12(keyPEM, certData []byte, password string) ([]byte, error) { if err != nil { return nil, fmt.Errorf("failed to parse private key: %w", err) } + return key, nil +} +func parseCertificate(certData []byte) (*x509.Certificate, error) { certDER := certData if certBlock, _ := pem.Decode(certData); certBlock != nil { certDER = certBlock.Bytes @@ -80,6 +88,38 @@ func BuildP12(keyPEM, certData []byte, password string) ([]byte, error) { if err != nil { return nil, fmt.Errorf("failed to parse certificate (expected the .cer file downloaded from the Apple Developer portal): %w", err) } + return cert, nil +} + +// KeyMatchesCertificate reports whether the certificate (DER or PEM) was +// issued for the private key's public key. +func KeyMatchesCertificate(keyPEM, certData []byte) bool { + key, err := parseKey(keyPEM) + if err != nil { + return false + } + cert, err := parseCertificate(certData) + if err != nil { + return false + } + certKey, ok := cert.PublicKey.(*rsa.PublicKey) + return ok && certKey.Equal(key.Public()) +} + +// BuildP12 combines a PEM private key (from GenerateKeyAndCSR) with the +// certificate Apple issued for its CSR (DER .cer as downloaded from the +// portal, or PEM) into a password-protected PKCS#12 bundle, the same format +// Keychain Access exports. The legacy encoding is used because that is what +// macOS `security import` expects. +func BuildP12(keyPEM, certData []byte, password string) ([]byte, error) { + key, err := parseKey(keyPEM) + if err != nil { + return nil, err + } + cert, err := parseCertificate(certData) + if err != nil { + return nil, err + } certKey, ok := cert.PublicKey.(*rsa.PublicKey) if !ok || !certKey.Equal(key.Public()) { From 1abbeb5a591306bd6c808f2ea98e9965046e3bf3 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:49:45 +0200 Subject: [PATCH 3/7] signing: make setup automatic with the App Store Connect API key builder signing setup without --certificate/--profile now provisions everything through signing.Auto: --bundle-id (else ios.bundleId, else the newest IPA in ./dist, else a prompt on a TTY), --type development|ad-hoc| app-store, --device / --devices-from-mobai, --key or the ios-signing.key a previous run left in --out-dir, --force, --yes, --password and --json. One confirmation shows the plan before anything is created; without a TTY --yes is required and the .p12 password is generated and printed once. GitHub gets the three IOS_* secrets and ios.signing flips as before; Codemagic and Bitrise get the file paths and docs/provider-secrets.md. The resolved bundle ID is saved as ios.bundleId, which init now also fills from PRODUCT_BUNDLE_IDENTIFIER when the Xcode project has exactly one app target. The manual --certificate/--profile path is unchanged. --- cmd/builder/root.go | 42 +++++ cmd/builder/root_test.go | 66 ++++++++ cmd/builder/signing.go | 49 ++++-- cmd/builder/signing_auto.go | 330 ++++++++++++++++++++++++++++++++++++ internal/config/types.go | 1 + 5 files changed, 475 insertions(+), 13 deletions(-) create mode 100644 cmd/builder/root_test.go create mode 100644 cmd/builder/signing_auto.go diff --git a/cmd/builder/root.go b/cmd/builder/root.go index cfda5b9..b70af33 100644 --- a/cmd/builder/root.go +++ b/cmd/builder/root.go @@ -219,6 +219,45 @@ func detectIOSPath() (string, string) { return "", "" } +// bundleIDRe matches PRODUCT_BUNDLE_IDENTIFIER assignments in a project.pbxproj. +var bundleIDRe = regexp.MustCompile(`PRODUCT_BUNDLE_IDENTIFIER\s*=\s*"?([^";\s]+)"?\s*;`) + +// detectBundleID reads the app's bundle identifier from the Xcode project +// under iosPath. Test targets (…Tests) and values built from build settings +// ($(…)) are ignored; anything still ambiguous yields "" so init leaves the +// field for `signing setup` to resolve. +func detectBundleID(iosPath string) string { + if iosPath == "" { + iosPath = "." + } + projects, _ := filepath.Glob(filepath.Join(iosPath, "*.xcodeproj", "project.pbxproj")) + var found []string + for _, path := range projects { + data, err := os.ReadFile(path) + if err != nil { + continue + } + found = append(found, bundleIDsFromPbxproj(string(data))...) + } + if len(found) == 1 { + return found[0] + } + return "" +} + +// bundleIDsFromPbxproj returns the distinct app bundle identifiers in pbxproj text. +func bundleIDsFromPbxproj(text string) []string { + var ids []string + for _, m := range bundleIDRe.FindAllStringSubmatch(text, -1) { + id := m[1] + if strings.Contains(id, "$") || strings.HasSuffix(id, "Tests") || slices.Contains(ids, id) { + continue + } + ids = append(ids, id) + } + return ids +} + func detectGitHubRepo(remoteName string) (owner, repo string, err error) { // Try to get GitHub remote URL from git cmd := exec.Command("git", "remote", "get-url", remoteName) @@ -397,6 +436,9 @@ func runInit(cmd *cobra.Command, args []string) error { cfg.Project, cfg.Platform = projectName, "ios" cfg.GitHub = config.GitHubConfig{Owner: githubOwner, Repo: repoName} cfg.IOS.Path, cfg.IOS.Scheme = iosPath, scheme + if cfg.IOS.BundleID == "" { + cfg.IOS.BundleID = detectBundleID(iosPath) + } if flutterVersion != "" { cfg.Flutter.Version = flutterVersion } diff --git a/cmd/builder/root_test.go b/cmd/builder/root_test.go new file mode 100644 index 0000000..9432815 --- /dev/null +++ b/cmd/builder/root_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +const flutterPbxproj = ` + 97C147061CF9000F007C117D /* Debug */ = { + buildSettings = { + PRODUCT_BUNDLE_IDENTIFIER = com.example.myApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + }; + 97C147071CF9000F007C117D /* Release */ = { + buildSettings = { + PRODUCT_BUNDLE_IDENTIFIER = com.example.myApp; + }; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + buildSettings = { + PRODUCT_BUNDLE_IDENTIFIER = com.example.myApp.RunnerTests; + }; + }; +` + +func TestBundleIDsFromPbxproj(t *testing.T) { + if got := bundleIDsFromPbxproj(flutterPbxproj); len(got) != 1 || got[0] != "com.example.myApp" { + t.Errorf("bundleIDsFromPbxproj = %v, want [com.example.myApp]", got) + } + quoted := `PRODUCT_BUNDLE_IDENTIFIER = "com.example.my-app"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).app";` + if got := bundleIDsFromPbxproj(quoted); len(got) != 1 || got[0] != "com.example.my-app" { + t.Errorf("bundleIDsFromPbxproj(quoted) = %v", got) + } + two := `PRODUCT_BUNDLE_IDENTIFIER = com.example.free; PRODUCT_BUNDLE_IDENTIFIER = com.example.pro;` + if got := bundleIDsFromPbxproj(two); len(got) != 2 { + t.Errorf("bundleIDsFromPbxproj(two apps) = %v", got) + } +} + +func TestDetectBundleID(t *testing.T) { + dir := t.TempDir() + proj := filepath.Join(dir, "ios", "Runner.xcodeproj") + if err := os.MkdirAll(proj, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(proj, "project.pbxproj"), []byte(flutterPbxproj), 0644); err != nil { + t.Fatal(err) + } + if got := detectBundleID(filepath.Join(dir, "ios")); got != "com.example.myApp" { + t.Errorf("detectBundleID = %q", got) + } + if got := detectBundleID(filepath.Join(dir, "missing")); got != "" { + t.Errorf("detectBundleID(missing) = %q, want empty", got) + } + // Two app targets: ambiguous, leave it to signing setup. + two := filepath.Join(dir, "two", "App.xcodeproj") + if err := os.MkdirAll(two, 0755); err != nil { + t.Fatal(err) + } + _ = os.WriteFile(filepath.Join(two, "project.pbxproj"), []byte(`PRODUCT_BUNDLE_IDENTIFIER = com.example.free; PRODUCT_BUNDLE_IDENTIFIER = com.example.pro;`), 0644) + if got := detectBundleID(filepath.Join(dir, "two")); got != "" { + t.Errorf("detectBundleID(two apps) = %q, want empty", got) + } +} diff --git a/cmd/builder/signing.go b/cmd/builder/signing.go index 1c64163..9de5b4b 100644 --- a/cmd/builder/signing.go +++ b/cmd/builder/signing.go @@ -23,23 +23,31 @@ var signingCmd = &cobra.Command{ var signingSetupCmd = &cobra.Command{ Use: "setup", Short: "Set up code signing for iOS builds", - Long: `Uploads your iOS signing certificate and provisioning profile to GitHub Secrets. - -The certificate can be either: + Long: `Sets up code signing for iOS builds and uploads the material to GitHub Secrets. + +Without --certificate/--profile the whole thing is automatic, using the App +Store Connect API key from 'builder auth apple': the App ID is registered if +missing, a certificate is issued for a private key generated here (or --key), +devices are registered (--device, --devices-from-mobai) and a provisioning +profile named "Builder " is created. Running it again is +safe: valid material is reused and only what is missing, expired, invalid or +changed is recreated. Nothing is ever revoked. + + --type development Apple Development certificate, devices required (default) + --type ad-hoc Apple Distribution certificate, devices required + --type app-store Apple Distribution certificate, no devices; TestFlight/App + Store uploads need this and ios.configuration Release + +With --certificate and --profile the files are taken as they are: - A .p12 file (exported from Keychain Access on a Mac) - A .cer file downloaded from the Apple Developer portal, together with the private key from 'builder signing csr' (--key) — the .p12 is then assembled locally, so no Mac is needed at any point -This command will: -- Read your certificate and .mobileprovision provisioning profile -- Base64 encode and encrypt them -- Upload them as GitHub repository secrets: - - IOS_CERTIFICATE - - IOS_CERTIFICATE_PASSWORD - - IOS_PROVISIONING_PROFILE - -After setup, builds will be signed automatically.`, +Either way the command uploads three GitHub repository secrets — +IOS_CERTIFICATE, IOS_CERTIFICATE_PASSWORD, IOS_PROVISIONING_PROFILE — and sets +ios.signing in builder.json. For Codemagic and Bitrise it writes the files and +points at docs/provider-secrets.md instead.`, RunE: runSigningSetup, } @@ -75,7 +83,16 @@ func init() { signingSetupCmd.Flags().StringP("certificate", "c", "", "Path to certificate file (.p12, or .cer from the Apple Developer portal)") signingSetupCmd.Flags().StringP("profile", "p", "", "Path to .mobileprovision file") - signingSetupCmd.Flags().StringP("key", "k", "", "Path to the private key from 'builder signing csr' (required with a .cer)") + signingSetupCmd.Flags().StringP("key", "k", "", "Path to the private key from 'builder signing csr' (required with a .cer; automatic mode reuses it and its certificate)") + signingSetupCmd.Flags().String("bundle-id", "", "App bundle ID (default: ios.bundleId in builder.json, else the newest IPA in ./dist)") + signingSetupCmd.Flags().String("type", string(signing.TypeDevelopment), "Signing type: development, ad-hoc or app-store") + signingSetupCmd.Flags().StringArray("device", nil, "Device UDID to register (repeatable)") + signingSetupCmd.Flags().Bool("devices-from-mobai", false, "Register the physical iOS devices connected to MobAI") + signingSetupCmd.Flags().String("out-dir", ".", "Directory for the private key, .p12 and .mobileprovision") + signingSetupCmd.Flags().String("password", "", "Password to protect the .p12 (prompted; generated with --yes)") + signingSetupCmd.Flags().Bool("force", false, "Issue a new certificate and profile even when valid ones exist") + signingSetupCmd.Flags().BoolP("yes", "y", false, "Skip confirmations") + signingSetupCmd.Flags().Bool("json", false, "Print the result as JSON (progress goes to stderr)") signingCSRCmd.Flags().String("name", "", "Your name (certificate common name)") signingCSRCmd.Flags().String("email", "", "Email address of your Apple Developer account") @@ -235,6 +252,12 @@ func expandPath(path string) string { } func runSigningSetup(cmd *cobra.Command, args []string) error { + if certFlag, _ := cmd.Flags().GetString("certificate"); certFlag == "" { + if profileFlag, _ := cmd.Flags().GetString("profile"); profileFlag == "" { + return runSigningAuto(cmd) + } + } + cfg, err := loadConfig() if err != nil { return err diff --git a/cmd/builder/signing_auto.go b/cmd/builder/signing_auto.go new file mode 100644 index 0000000..51cbfe2 --- /dev/null +++ b/cmd/builder/signing_auto.go @@ -0,0 +1,330 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/MobAI-App/ios-builder/internal/config" + "github.com/MobAI-App/ios-builder/internal/github" + "github.com/MobAI-App/ios-builder/internal/ipa" + "github.com/MobAI-App/ios-builder/internal/mobai" + "github.com/MobAI-App/ios-builder/internal/signing" + "github.com/manifoldco/promptui" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +// providerSecretsDoc explains the dashboard steps for Codemagic and Bitrise. +const providerSecretsDoc = "https://github.com/MobAI-App/ios-builder/blob/main/docs/provider-secrets.md" + +// signingAutoResult is the JSON output of the automatic `signing setup`. +type signingAutoResult struct { + *signing.AutoResult + Provider string `json:"provider"` + SecretsUploaded bool `json:"secrets_uploaded"` + // GeneratedPassword is set when no password was given: it is printed + // exactly once, here. + GeneratedPassword string `json:"generated_password,omitempty"` +} + +func stdinIsTerminal() bool { + return term.IsTerminal(int(os.Stdin.Fd())) +} + +// runSigningAuto is `signing setup` without --certificate/--profile: it +// provisions everything through the App Store Connect API. +func runSigningAuto(cmd *cobra.Command) error { + cfg, err := loadConfig() + if err != nil { + return err + } + typeFlag, _ := cmd.Flags().GetString("type") + typ, err := signing.ParseType(typeFlag) + if err != nil { + return err + } + client, err := getASCClient() + if err != nil { + return err + } + provider, err := cfg.ProviderName("") + if err != nil { + return err + } + var ghClient *github.Client + if provider == "github" { + if ghClient, err = getGitHubClient(); err != nil { + return err + } + } + out := newOutput(cmd) + yes, _ := cmd.Flags().GetBool("yes") + force, _ := cmd.Flags().GetBool("force") + outDir, _ := cmd.Flags().GetString("out-dir") + outDir = expandPath(outDir) + ctx, cancel := commandContext(cmd, false) + defer cancel() + + bundleID, err := resolveSigningBundleID(cmd, cfg, out) + if err != nil { + return err + } + devices, err := signingDevices(ctx, cmd, cfg, typ) + if err != nil { + return err + } + keyPEM, keyPath, err := signingKey(cmd, outDir) + if err != nil { + return err + } + + // The plan, then one confirmation before anything is created. + fmt.Fprintf(out.log, "Bundle ID: %s\n", bundleID) + fmt.Fprintf(out.log, "Type: %s\n", typ) + if typ.NeedsDevices() { + fmt.Fprintf(out.log, "Devices: %s\n", describeDevices(devices)) + } + if keyPath != "" { + fmt.Fprintf(out.log, "Key: %s (reusing its certificate if one is valid)\n", keyPath) + } else { + fmt.Fprintf(out.log, "Key: new, written to %s\n", filepath.Join(outDir, signing.KeyFileName)) + } + fmt.Fprintf(out.log, "Provider: %s\n", provider) + if force { + fmt.Fprintln(out.log, "Force: a new certificate and profile will be issued") + } + fmt.Fprintln(out.log) + if !yes { + if !stdinIsTerminal() { + return errors.New("this creates resources in your Apple Developer account; confirm with --yes when not running in a terminal") + } + if _, err := (&promptui.Prompt{Label: "Continue", IsConfirm: true}).Run(); err != nil { + return errors.New("canceled") + } + } + + password, _ := cmd.Flags().GetString("password") + var generated string + switch { + case password != "": + case yes || !stdinIsTerminal(): + if generated, err = randomPassword(); err != nil { + return err + } + password = generated + default: + if password, err = promptPassword("Password to protect the .p12"); err != nil { + return err + } + } + + res := &signingAutoResult{Provider: provider, GeneratedPassword: generated} + res.AutoResult, err = signing.Auto(ctx, client, &signing.AutoOptions{ + BundleID: bundleID, Type: typ, Devices: devices, KeyPEM: keyPEM, CommonName: cfg.Project, + Password: password, Force: force, OutDir: outDir, Log: out.log, + }) + if err != nil { + return finish(out, cmd, res, err, nil) + } + if ghClient != nil { + fmt.Fprintf(out.log, "\nUploading secrets to %s/%s...\n", cfg.GitHub.Owner, cfg.GitHub.Repo) + if err := uploadSigningSecrets(ctx, ghClient, cfg, out.log, res.P12, password, res.ProfileContent); err != nil { + return finish(out, cmd, res, err, nil) + } + res.SecretsUploaded = true + cfg.IOS.Signing = true + } + if cfg.IOS.BundleID == "" { + cfg.IOS.BundleID = bundleID + } + if err := config.NewManager().Save(cfg); err != nil { + return finish(out, cmd, res, fmt.Errorf("failed to update config: %w", err), nil) + } + fmt.Fprintln(out.log, " Updated: builder.json") + + return finish(out, cmd, res, nil, func() { printSigningSummary(cfg, res) }) +} + +// resolveSigningBundleID takes the flag, then builder.json, then the newest +// IPA in ./dist, then asks (only in a terminal). +func resolveSigningBundleID(cmd *cobra.Command, cfg *config.Config, out output) (string, error) { + if id, _ := cmd.Flags().GetString("bundle-id"); id != "" { + return strings.TrimSpace(id), nil + } + if cfg.IOS.BundleID != "" { + return cfg.IOS.BundleID, nil + } + if path, err := ipa.Newest("dist"); err == nil { + if id := ipa.BundleID(path); id != "" { + fmt.Fprintf(out.log, "Bundle ID %s read from %s\n", id, path) + return id, nil + } + } + if !stdinIsTerminal() || out.json { + return "", errors.New("bundle ID unknown: pass --bundle-id, set ios.bundleId in builder.json, or build once so ./dist has an IPA to read it from") + } + id, err := promptString("App bundle ID (e.g. com.example.app)", "") + if err != nil { + return "", err + } + if id = strings.TrimSpace(id); id == "" { + return "", errors.New("a bundle ID is required") + } + return id, nil +} + +// signingDevices collects --device UDIDs and, with --devices-from-mobai, the +// physical iOS devices MobAI has connected. +func signingDevices(ctx context.Context, cmd *cobra.Command, cfg *config.Config, typ signing.Type) ([]signing.Device, error) { + udids, _ := cmd.Flags().GetStringArray("device") + fromMobAI, _ := cmd.Flags().GetBool("devices-from-mobai") + if !typ.NeedsDevices() && (len(udids) > 0 || fromMobAI) { + return nil, fmt.Errorf("--type %s profiles list no devices; drop --device/--devices-from-mobai", typ) + } + var devices []signing.Device + for _, u := range udids { + devices = append(devices, signing.Device{UDID: strings.TrimSpace(u)}) + } + if !fromMobAI { + return devices, nil + } + url := cfg.MobAI.URL + if url == "" { + url = mobai.DefaultBaseURL + } + connected, err := mobai.NewClient(url).ListDevices(ctx) + if err != nil { + return nil, fmt.Errorf("list MobAI devices: %w (is MobAI running? try builder mobai ping)", err) + } + found := 0 + for _, d := range connected { + if d.Virtual || (d.Platform != "" && !strings.EqualFold(d.Platform, "ios")) { + continue + } + devices = append(devices, signing.Device{Name: d.Name, UDID: d.ID}) + found++ + } + if found == 0 { + return nil, errors.New("MobAI has no physical iOS device connected; plug one in or pass --device ") + } + return devices, nil +} + +// signingKey returns --key, else the key a previous run left in outDir, else +// nil so a key is generated. keyPath is "" when generating. +func signingKey(cmd *cobra.Command, outDir string) (keyPEM []byte, keyPath string, err error) { + keyPath, _ = cmd.Flags().GetString("key") + if keyPath == "" { + candidate := filepath.Join(outDir, signing.KeyFileName) + if _, err := os.Stat(candidate); err != nil { + return nil, "", nil + } + keyPath = candidate + } + keyPath = expandPath(keyPath) + keyPEM, err = os.ReadFile(keyPath) + if err != nil { + return nil, "", fmt.Errorf("failed to read private key %s: %w", keyPath, err) + } + return keyPEM, keyPath, nil +} + +func describeDevices(devices []signing.Device) string { + if len(devices) == 0 { + return "none given; the profile covers the devices already on the account" + } + parts := make([]string, 0, len(devices)) + for _, d := range devices { + if d.Name != "" { + parts = append(parts, fmt.Sprintf("%s (%s)", d.Name, d.UDID)) + } else { + parts = append(parts, d.UDID) + } + } + return strings.Join(parts, ", ") +} + +// randomPassword is 128 bits of randomness as URL-safe base64. +func randomPassword() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generate password: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// uploadSigningSecrets encrypts and stores the three signing secrets. +func uploadSigningSecrets(ctx context.Context, gh *github.Client, cfg *config.Config, log io.Writer, p12 []byte, password string, profile []byte) error { + publicKey, err := gh.GetPublicKey(ctx, cfg.GitHub.Owner, cfg.GitHub.Repo) + if err != nil { + return fmt.Errorf("failed to get repository public key: %w", err) + } + secrets := []struct{ name, value string }{ + {"IOS_CERTIFICATE", base64.StdEncoding.EncodeToString(p12)}, + {"IOS_CERTIFICATE_PASSWORD", password}, + {"IOS_PROVISIONING_PROFILE", base64.StdEncoding.EncodeToString(profile)}, + } + for _, s := range secrets { + encrypted, err := github.EncryptSecret(publicKey.Key, s.value) + if err != nil { + return fmt.Errorf("failed to encrypt %s: %w", s.name, err) + } + if err := gh.CreateOrUpdateSecret(ctx, cfg.GitHub.Owner, cfg.GitHub.Repo, s.name, encrypted, publicKey.KeyID); err != nil { + return fmt.Errorf("failed to upload %s: %w", s.name, err) + } + fmt.Fprintf(log, " Uploaded: %s\n", s.name) + } + return nil +} + +func printSigningSummary(cfg *config.Config, res *signingAutoResult) { + state := func(created bool, reason string) string { + if !created { + return "reused" + } + if reason != "" && reason != "missing" { + return "new (" + reason + ")" + } + return "new" + } + fmt.Println() + fmt.Printf("Bundle ID: %s (%s)\n", res.BundleID.Identifier, state(res.BundleID.Created, "")) + fmt.Printf("Certificate: %s (%s, expires %s)\n", res.Certificate.Name, state(res.Certificate.Created, ""), res.Certificate.ExpirationDate.Format("2006-01-02")) + if res.Type.NeedsDevices() { + fmt.Printf("Devices: %d in the profile, %d registered now\n", res.Devices.InProfile, len(res.Devices.Registered)) + } + fmt.Printf("Profile: %s (%s, %s, expires %s)\n", res.Profile.Name, state(res.Profile.Created, res.Profile.Reason), strings.ToLower(res.Profile.State), res.Profile.ExpirationDate.Format("2006-01-02")) + fmt.Println() + if res.Files.Key != "" { + fmt.Printf("Private key: %s\n", res.Files.Key) + } + fmt.Printf("Certificate: %s\n", res.Files.P12) + fmt.Printf("Profile: %s\n", res.Files.Profile) + if res.GeneratedPassword != "" { + fmt.Printf("Password: %s (generated; shown only now)\n", res.GeneratedPassword) + } + fmt.Println("Keep these out of git (add them to .gitignore); gitignored files are also left out of build snapshots.") + fmt.Println() + if res.SecretsUploaded { + fmt.Printf("Secrets uploaded to %s/%s and ios.signing enabled in builder.json.\n", cfg.GitHub.Owner, cfg.GitHub.Repo) + } else { + fmt.Printf("%s secrets are set in its dashboard, not by Builder. Add:\n", res.Provider) + fmt.Printf(" IOS_CERTIFICATE base64 of %s\n", res.Files.P12) + fmt.Println(" IOS_CERTIFICATE_PASSWORD the .p12 password") + fmt.Printf(" IOS_PROVISIONING_PROFILE base64 of %s\n", res.Files.Profile) + fmt.Printf("then set ios.signing to true in builder.json. Steps: %s\n", providerSecretsDoc) + } + fmt.Println() + fmt.Println("Next: builder ios build") + if res.Type == signing.TypeAppStore { + fmt.Println(`App Store builds need "configuration": "Release" under ios in builder.json; then builder ios upload --wait.`) + } + fmt.Println("Run builder signing setup again any time: it reuses what is valid and renews only what expired or changed.") +} diff --git a/internal/config/types.go b/internal/config/types.go index d67914d..758348d 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -110,6 +110,7 @@ type IOSConfig struct { // Empty means root directory contains the Xcode project Path string `json:"path,omitempty"` Scheme string `json:"scheme,omitempty"` // Xcode scheme to build (auto-detected if empty) + BundleID string `json:"bundleId,omitempty"` // App bundle identifier, for signing setup (detected by init when unambiguous) Signing bool `json:"signing,omitempty"` // Whether code signing is configured Configuration string `json:"configuration,omitempty"` // Build configuration: Debug (faster) or Release (production) } From ab633309f8f04eb95958513da1f410de1b9f912a Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:51:46 +0200 Subject: [PATCH 4/7] docs: automatic signing setup is the primary path README's Code Signing section leads with builder signing setup through the App Store Connect API (bundle ID resolution, certificate reuse rule, device and profile handling, idempotent reruns, --type app-store) and keeps the portal steps as the manual fallback. CLAUDE.md gains the command, flow diagram, module notes, the ios.bundleId field and the still-hardcoded development export method; provider-secrets.md points Codemagic/Bitrise users at automatic setup with --out-dir. --- CLAUDE.md | 44 +++++++++++++++-- README.md | 101 ++++++++++++++++++++++++++++++--------- docs/provider-secrets.md | 18 +++++-- 3 files changed, 135 insertions(+), 28 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d36f8d5..3a7c581 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,8 @@ go install ./cmd/builder ./builder dev flutter --skip-install --bundle-id # Use already installed app ./builder dev rn --skip-install --bundle-id # Use already installed app ./builder auth apple # Save an App Store Connect API key +./builder signing setup --devices-from-mobai # Certificate + devices + profile via the ASC API, secrets to GitHub +./builder signing setup --type app-store --yes --json # Distribution certificate + App Store profile, no prompts ./builder ios upload --wait # Upload dist/*.ipa to App Store Connect, wait for processing ./builder ios submit --testflight --group --notes # TestFlight ./builder ios submit --app-store --release after-approval # App Review @@ -106,6 +108,20 @@ builder dev kmp ─────────► Connects to MobAI ▼ Launches app and streams output (no hot reload) +builder signing setup ───► Bundle ID: --bundle-id → ios.bundleId → dist/*.ipa → prompt + │ + ▼ + App Store Connect API (signing.Auto) + ├─ bundleIds?filter[identifier] → POST bundleIds + ├─ certificates?filter[certificateType] → reuse if the + │ key matches, else CSR → POST certificates → .p12 + ├─ devices?filter[platform]=IOS → POST devices (dev/ad-hoc) + └─ profiles?filter[name] → reuse / DELETE + POST profiles + │ + ▼ + Writes key/.p12/.mobileprovision, uploads the three IOS_* + secrets (GitHub) or prints them (Codemagic/Bitrise) + builder ios upload ──────► Reads bundle ID / version / build number from dist/*.ipa │ ▼ @@ -135,11 +151,12 @@ cmd/builder/ # CLI entrypoint (Cobra) internal/ auth/ # GitHub OAuth device flow + keyring storage (also CI tokens, ASC API key) github/ # GitHub REST API (workflow dispatch, artifacts) - asc/ # App Store Connect API client (JWT, JSON:API, builds, uploads, TestFlight, review) + asc/ # App Store Connect API client (JWT, JSON:API, builds, uploads, TestFlight, review, + # bundle IDs, certificates, devices, profiles) distribute/ # Upload / TestFlight / App Store flows on top of asc ipa/ # Info.plist reading from .ipa archives build/ # Build coordination (snapshot + trigger + poll + download) - signing/ # CSR generation and .p12 assembly (signing without a Mac) + signing/ # CSR generation, .p12 assembly, and Auto (portal-free provisioning on top of asc) snapshot/ # Working-tree snapshot as a throwaway commit on a remote ref workflow/ # Workflow template (embedded) config/ # builder.json management @@ -224,6 +241,23 @@ internal/ chosen group is external and none exists) → add groups. App Store reuses an open `reviewSubmission` (READY_FOR_REVIEW/UNRESOLVED_ISSUES), skips the item when the version is already in it, and rewrites ASC 409/422 with a "complete the metadata" hint. +- **Automatic Signing** (`signing.Auto`, behind `signing setup` without `--certificate`/ + `--profile`): idempotent and never revokes. A certificate is reused only when its private key + is local (`--key`, or the `ios-signing.key` a previous run left in `--out-dir`), since a .p12 + needs the key; otherwise a new one is issued and Apple's quota error (2 Development / + 3 Distribution) gets a hint. Dev/ad-hoc profiles cover every ENABLED iOS device on the + account, not just the ones passed; App Store profiles send no `devices` relationship at + all (an empty one is rejected). Profile membership is read from + `/v1/profiles/{id}/relationships/{certificates,devices}` (paginated), not `include=`, which + caps linkage arrays. The profile `Builder ` is recreated when INVALID, + expired, `--force`, or when the certificate/device set differs; same-named duplicates are + deleted with it. `filter[identifier]` on bundleIds is a prefix match, so the exact identifier + is checked client-side. The manual `--certificate`/`--profile` path in `runSigningSetup` is + untouched; the automatic one lives in `cmd/builder/signing_auto.go`. +- **Export Method Is Still `development`**: `ios-build.yml` and `runner.sh` hardcode + `method = development` in ExportOptions.plist, so an ad-hoc or App Store profile from + `signing setup --type ad-hoc|app-store` signs the archive but the export step needs the + matching method before those IPAs work (roadmap prerequisite under item 1). - **Extension Points**: a future `ios release` (upload + TestFlight, automatic build numbers) composes `distribute.Upload` and `distribute.SubmitTestFlight` and reads `asc.Client.ListBuilds` for the latest build number; the `pkg/` wrappers do not expose `asc` yet. @@ -236,10 +270,14 @@ internal/ "project": "MyApp", "platform": "ios", "github": { "owner": "username", "repo": "my-ios-app" }, - "ios": { "path": "ios", "scheme": "" } + "ios": { "path": "ios", "scheme": "", "bundleId": "com.example.app" } } ``` +`ios.bundleId` is optional: `init` fills it from `PRODUCT_BUNDLE_IDENTIFIER` when the Xcode +project has exactly one app target (test targets and `$(…)` values are skipped), and +`signing setup` saves whatever it resolved. + ## Workflow Features The embedded workflow template (`internal/workflow/templates/ios-build.yml`): diff --git a/README.md b/README.md index b11157a..1b90fc4 100644 --- a/README.md +++ b/README.md @@ -197,10 +197,12 @@ builder mobai install # Install an IPA on the device builder mobai run-debug # Launch an app with the debugger attached builder mobai forward # Forward a device port -# Code signing -builder signing csr # Create a private key + certificate signing request -builder signing p12 # Assemble a .p12 from the key and Apple's certificate -builder signing setup # Upload code signing secrets to GitHub +# Code signing (automatic mode needs builder auth apple) +builder signing setup --devices-from-mobai # Certificate, devices, profile and GitHub secrets, no portal +builder signing setup --type app-store # Apple Distribution certificate + App Store profile +builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision # Upload your own files +builder signing csr # Manual path: create a private key + certificate signing request +builder signing p12 # Manual path: assemble a .p12 from the key and Apple's certificate # TestFlight and App Store (needs builder auth apple) builder ios upload --wait # Upload ./dist/*.ipa to App Store Connect and wait for processing @@ -250,6 +252,7 @@ never prompts, so agents and CI jobs can drive them. |-------|-------------|---------| | `ios.path` | Path to the Xcode project relative to the repo root | detected by `init` | | `ios.scheme` | Xcode scheme to build | auto-detected | +| `ios.bundleId` | App bundle identifier, used by `signing setup` | detected by `init` when the project has one app target; else saved by `signing setup` | | `ios.signing` | Sign the IPA with the uploaded certificate and profile | `false` | | `ios.configuration` | Xcode build configuration. **Builds are `Debug` unless you set `Release`**; Debug is faster and is what the dev commands expect | `Debug` | @@ -278,23 +281,75 @@ mirrored networking. ## Code Signing -For Codemagic and Bitrise, follow the [signing and MobAI secrets guide](docs/provider-secrets.md) -for dashboard instructions, file encoding, and verification. The `signing setup` -command below uploads to GitHub Actions only. - By default, builds are unsigned. Signed builds need a signing certificate and a provisioning profile — and despite what many guides claim, **you do not need a -Mac to create either one**. The `.p12` certificate is normally created through -Keychain Access, but Builder does the same thing itself: it generates the -private key and certificate signing request, and assembles the `.p12` from the -certificate Apple issues. +Mac to create either one**, nor a tour of the Apple Developer portal. With an +App Store Connect API key, `builder signing setup` does the whole thing through +the API; the [manual path](#manual-path-through-the-apple-developer-portal) +below is the fallback when you would rather click, or already have the files. You need a paid [Apple Developer Program](https://developer.apple.com/programs/) -membership — the portal only issues certificates to paid accounts. (Without one, +membership — Apple only issues certificates to paid accounts. (Without one, build unsigned and let [MobAI](https://mobai.run) re-sign on install with a free Apple ID.) -### 1. Create a certificate signing request +### Automatic setup + +```bash +builder auth apple # once: save the App Store Connect API key +builder signing setup --devices-from-mobai # development signing for the devices MobAI sees +``` + +The key needs the **Admin** role (or App Manager plus *Access to Certificates, +Identifiers & Profiles*): Developer-role keys cannot create certificates. +`setup` then: + +1. Registers the **App ID** if the bundle identifier is not on the account yet. + The bundle ID comes from `--bundle-id`, `ios.bundleId` in `builder.json` + (which `init` fills when the Xcode project has a single app target), or the + newest IPA in `./dist/`; in a terminal it asks as a last resort. +2. Issues a **certificate** — Apple Development for `--type development`, Apple + Distribution for `ad-hoc` and `app-store` — for a private key generated on + your machine (`ios-signing.key`, or `--key` to reuse one from `signing csr`). + A valid certificate on the account is reused only when its private key is + here, because that is the only way to build the `.p12`; otherwise a new one + is issued. Nothing is ever revoked: when Apple's limit (2 Development, 3 + Distribution) is hit, the error names it and points at the portal. +3. Registers **devices** from `--device ` (repeatable) and + `--devices-from-mobai` (name and UDID of every physical iOS device MobAI has + connected). Development and ad-hoc profiles cover every enabled iOS device on + the account, so with none given and none registered the command stops and + says so. App Store profiles take no devices. Apple allows 100 devices per + membership year and never frees a slot; that error is passed through too. +4. Creates the **profile** `Builder ` (iOS App Development, + Ad Hoc or App Store). An existing one is reused while it is `ACTIVE`, + unexpired and still lists exactly this certificate and these devices; + otherwise it is deleted and recreated, and the summary says why (`invalid`, + `expired`, `certificate changed`, `devices changed`, `forced`). +5. Writes `ios-signing.key` (when generated), `ios-signing.p12` and + `Builder--.mobileprovision` to `--out-dir` (default `.`), + uploads `IOS_CERTIFICATE`, `IOS_CERTIFICATE_PASSWORD` and + `IOS_PROVISIONING_PROFILE` to GitHub Secrets and sets `ios.signing` to + `true`. For Codemagic and Bitrise it prints the three values to paste + instead, following the [signing and MobAI secrets guide](docs/provider-secrets.md). + +The command shows its plan and asks once before creating anything; `--yes` +skips that (required without a terminal), and then the `.p12` password is +generated and printed once unless `--password` is given. `--json` prints the +result as JSON with progress on stderr. Keep the written files out of git. + +Run it again whenever you like: it reports what it found and recreates only what +is missing, expired, invalid or changed — add a device, re-run, rebuild. +`--force` issues a fresh certificate and profile regardless. For TestFlight use +`--type app-store` and set `ios.configuration` to `Release`. + +### Manual path through the Apple Developer portal + +The `.p12` certificate is normally created through Keychain Access, but Builder +does the same thing itself: it generates the private key and certificate +signing request, and assembles the `.p12` from the certificate Apple issues. + +#### 1. Create a certificate signing request ```bash builder signing csr @@ -305,13 +360,13 @@ directory: `ios-signing.key` (your private key) and `ios-signing.csr`. Keep the key wherever suits you — just don't commit it (add it to `.gitignore`; gitignored files are also excluded from build snapshots). -### 2. Create the certificate +#### 2. Create the certificate 1. Go to [Certificates](https://developer.apple.com/account/resources/certificates/add) on the Apple Developer portal 2. Choose **Apple Development** (installs on registered devices) or **Apple Distribution** (App Store/Ad Hoc) 3. Upload `ios-signing.csr` and download the resulting `.cer` file -### 3. Assemble the .p12 +#### 3. Assemble the .p12 ```bash builder signing p12 --certificate development.cer --key ios-signing.key @@ -322,7 +377,7 @@ password you choose — byte-for-byte the same kind of file Keychain Access exports, and usable anywhere one is: `builder signing setup`, Sideloadly, AltStore, or importing it on a Mac. Keep it, and don't commit it. -### 4. Create a provisioning profile +#### 4. Create a provisioning profile On the portal: @@ -330,13 +385,15 @@ On the portal: 2. **Devices** → register your device's UDID (shown in [MobAI](https://mobai.run) when the device is connected; on Windows, iTunes shows it when you click the serial number on the device page) 3. **Profiles** → create an **iOS App Development** (or Ad Hoc) profile, select your App ID, certificate, and devices, then download the `.mobileprovision` file -### 5. Upload the signing secrets +#### 5. Upload the signing secrets ```bash builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision ``` -This uploads the signing material to GitHub Secrets: +With `--certificate` and `--profile` given, `setup` takes the files as they are +(no App Store Connect key involved) and uploads the signing material to GitHub +Secrets: - `IOS_CERTIFICATE` - Base64-encoded .p12 file - `IOS_CERTIFICATE_PASSWORD` - Certificate password - `IOS_PROVISIONING_PROFILE` - Base64-encoded .mobileprovision file @@ -366,9 +423,9 @@ You need: membership and an app record in App Store Connect (My Apps → +) with your bundle ID - An IPA signed with an **Apple Distribution** certificate and an **App Store** - provisioning profile. `builder signing setup` accepts both, exactly as in the - steps above; pick those types on the portal instead of the development ones. - An IPA signed for development is rejected at upload. + provisioning profile: `builder signing setup --type app-store` creates both, + or pick those types on the portal in the manual path. An IPA signed for + development is rejected at upload. - `"configuration": "Release"` under `ios` in `builder.json`: `ios build` defaults to `Debug`, which is what the dev commands expect, not what you want to ship. diff --git a/docs/provider-secrets.md b/docs/provider-secrets.md index e47a643..00a651f 100644 --- a/docs/provider-secrets.md +++ b/docs/provider-secrets.md @@ -31,8 +31,19 @@ explains selecting the App ID, certificate, and devices. App Store/Ad Hoc export requires a corresponding change to the generated runner's export settings. If you already have the P12 and profile, reuse them. If you have no certificate, -follow Builder's [certificate creation instructions](../README.md#1-create-a-certificate-signing-request). -Run the CSR/P12 commands in a private directory outside your source checkout: +the quickest way is the [automatic setup](../README.md#automatic-setup) with an +App Store Connect API key (`builder auth apple`), pointed at a private directory +outside your source checkout: + +```sh +builder signing setup --devices-from-mobai --out-dir ~/signing +``` + +With `provider` set to Codemagic or Bitrise in `builder.json`, this creates the +certificate, devices and profile through the API, writes `ios-signing.p12` and +the `.mobileprovision` to `~/signing`, and prints the three values to paste +below instead of uploading them. Alternatively follow the +[manual certificate steps](../README.md#1-create-a-certificate-signing-request): ```sh builder signing csr @@ -40,7 +51,8 @@ builder signing csr builder signing p12 --certificate development.cer --key ios-signing.key ``` -The second command prompts for the P12 password. Use that exact password below. +The `p12` command prompts for the P12 password (automatic setup prompts too, or +generates one with `--yes` and prints it once). Use that exact password below. A `.cer` alone is not the value for `IOS_CERTIFICATE`; assemble the P12 first. Keep private keys, P12 files, and encoded copies out of Git and build snapshots. From 620099e2710a5b963b18cad94bd2f353003e6da1 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:58:50 +0200 Subject: [PATCH 5/7] signing: check devices and write the key before requesting a certificate Auto issued the certificate before looking at devices and wrote the private key only after the profile existed. A development run with no device to cover, or any failure between the certificate POST and the file write, left a certificate on the account whose key was gone: Builder never revokes, so it occupied one of the two Development slots for a year. Devices are now resolved first, and a generated key is on disk before the CSR goes to Apple, so a failed run can be retried with the same key and the certificate it may have produced is reused. --- internal/signing/auto.go | 36 ++++++++++++++++++----------------- internal/signing/auto_test.go | 25 +++++++++++++++++++++--- 2 files changed, 41 insertions(+), 20 deletions(-) diff --git a/internal/signing/auto.go b/internal/signing/auto.go index 785b2bb..fde6599 100644 --- a/internal/signing/auto.go +++ b/internal/signing/auto.go @@ -200,13 +200,31 @@ func Auto(ctx context.Context, client *asc.Client, opts *AutoOptions) (*AutoResu } res.BundleID.ID, res.BundleID.Identifier = bundle.ID, bundle.Identifier - // 2. Certificate + // 2. Devices, before anything that counts against a quota: a development + // profile with no device to cover is an error, and it must not cost a + // certificate. + var deviceIDs []string + if opts.Type.NeedsDevices() { + if deviceIDs, err = ensureDevices(ctx, client, opts, &res.Devices); err != nil { + return res, err + } + } + + // 3. Certificate. A generated key is on disk before the CSR goes to + // Apple: a certificate whose key is lost cannot be revoked by Builder and + // occupies one of the team's slots for a year. + if err := os.MkdirAll(opts.OutDir, 0755); err != nil { + return res, fmt.Errorf("create %s: %w", opts.OutDir, err) + } keyPEM := opts.KeyPEM if keyPEM == nil { if keyPEM, err = generateKey(); err != nil { return res, err } res.Files.Key = filepath.Join(opts.OutDir, KeyFileName) + if err := os.WriteFile(res.Files.Key, keyPEM, 0600); err != nil { + return res, fmt.Errorf("write private key: %w", err) + } } cert, err := ensureCertificate(ctx, client, opts, keyPEM, now(), &res.Certificate) if err != nil { @@ -217,14 +235,6 @@ func Auto(ctx context.Context, client *asc.Client, opts *AutoOptions) (*AutoResu return res, err } - // 3. Devices - var deviceIDs []string - if opts.Type.NeedsDevices() { - if deviceIDs, err = ensureDevices(ctx, client, opts, &res.Devices); err != nil { - return res, err - } - } - // 4. Profile profile, err := ensureProfile(ctx, client, opts, bundle.ID, cert.ID, deviceIDs, now(), &res.Profile) if err != nil { @@ -233,14 +243,6 @@ func Auto(ctx context.Context, client *asc.Client, opts *AutoOptions) (*AutoResu res.ProfileContent = profile.Content // 5. Files - if err := os.MkdirAll(opts.OutDir, 0755); err != nil { - return res, fmt.Errorf("create %s: %w", opts.OutDir, err) - } - if res.Files.Key != "" { - if err := os.WriteFile(res.Files.Key, keyPEM, 0600); err != nil { - return res, fmt.Errorf("write private key: %w", err) - } - } res.Files.P12 = filepath.Join(opts.OutDir, P12FileName) if err := os.WriteFile(res.Files.P12, res.P12, 0600); err != nil { return res, fmt.Errorf("write .p12: %w", err) diff --git a/internal/signing/auto_test.go b/internal/signing/auto_test.go index 3683895..159cde8 100644 --- a/internal/signing/auto_test.go +++ b/internal/signing/auto_test.go @@ -538,8 +538,13 @@ func TestAutoDevelopmentWithoutDevicesFails(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "--device") || !strings.Contains(err.Error(), "--devices-from-mobai") { t.Errorf("err = %v", err) } - if p.count("POST /v1/profiles") != 0 { - t.Errorf("profile created without devices: %v", p.calls) + // Devices are checked before the certificate: no device means no key + // written and no certificate slot spent. + if p.count("POST /v1/certificates") != 0 || p.count("POST /v1/profiles") != 0 { + t.Errorf("certificate or profile created without devices: %v", p.calls) + } + if _, err := os.Stat(filepath.Join(opts.OutDir, KeyFileName)); err == nil { + t.Error("a key was written although no certificate was requested") } } @@ -583,10 +588,24 @@ func TestAutoWithSuppliedKeyReusesMatchingCertificate(t *testing.T) { func TestAutoCertificateLimitHint(t *testing.T) { p := newPortal(t) p.refuseCertificates = true - _, err := Auto(context.Background(), p.client(t), devOpts(t.TempDir())) + dir := t.TempDir() + res, err := Auto(context.Background(), p.client(t), devOpts(dir)) if err == nil || !strings.Contains(err.Error(), "maximum number of certificates") || !strings.Contains(err.Error(), "Revoke one") || !strings.Contains(err.Error(), "--key") { t.Errorf("err = %v", err) } + // The key is on disk before the request goes out, so whatever Apple did + // with it, the next run can carry on with the same key. + keyPEM, readErr := os.ReadFile(res.Files.Key) + if readErr != nil || res.Files.Key != filepath.Join(dir, KeyFileName) { + t.Fatalf("key after a refused certificate: %+v, %v", res.Files, readErr) + } + p.refuseCertificates = false + opts := devOpts(dir) + opts.KeyPEM = keyPEM + res = run(t, p, opts) + if !res.Certificate.Created || !KeyMatchesCertificate(keyPEM, p.certs[0].der) || res.Files.Key != "" { + t.Errorf("retry = %+v", res) + } } func TestAutoDeviceLimitHint(t *testing.T) { From 21ea5f8c79867c81eacb647cd5c134f6676f6c63 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:58:50 +0200 Subject: [PATCH 6/7] signing: register only devices whose MobAI ID is a UDID MobAI also lists cloud farm devices (cloud: true) as physical iOS devices; their IDs are farm handles like awsdevicefarm:Apple_iPhone_16:26.0, which --devices-from-mobai would have sent to Apple as UDIDs. Decode the cloud flag, skip those, and require a UDID shape (40 hex, or 8-16 hex) for both MobAI-sourced and --device values so a typo fails here, not as an ASC 409. --- README.md | 5 +++-- cmd/builder/signing_auto.go | 32 ++++++++++++++++++++------- cmd/builder/signing_auto_test.go | 37 ++++++++++++++++++++++++++++++++ internal/mobai/types.go | 1 + 4 files changed, 65 insertions(+), 10 deletions(-) create mode 100644 cmd/builder/signing_auto_test.go diff --git a/README.md b/README.md index 1b90fc4..d8388ce 100644 --- a/README.md +++ b/README.md @@ -317,8 +317,9 @@ Identifiers & Profiles*): Developer-role keys cannot create certificates. Distribution) is hit, the error names it and points at the portal. 3. Registers **devices** from `--device ` (repeatable) and `--devices-from-mobai` (name and UDID of every physical iOS device MobAI has - connected). Development and ad-hoc profiles cover every enabled iOS device on - the account, so with none given and none registered the command stops and + connected; simulators and cloud farm devices are skipped). Development and + ad-hoc profiles cover every enabled iOS device on the account, so with none + given and none registered the command stops and says so. App Store profiles take no devices. Apple allows 100 devices per membership year and never frees a slot; that error is passed through too. 4. Creates the **profile** `Builder ` (iOS App Development, diff --git a/cmd/builder/signing_auto.go b/cmd/builder/signing_auto.go index 51cbfe2..3c7aa6e 100644 --- a/cmd/builder/signing_auto.go +++ b/cmd/builder/signing_auto.go @@ -9,6 +9,7 @@ import ( "io" "os" "path/filepath" + "regexp" "strings" "github.com/MobAI-App/ios-builder/internal/config" @@ -190,7 +191,11 @@ func signingDevices(ctx context.Context, cmd *cobra.Command, cfg *config.Config, } var devices []signing.Device for _, u := range udids { - devices = append(devices, signing.Device{UDID: strings.TrimSpace(u)}) + u = strings.TrimSpace(u) + if !udidRe.MatchString(u) { + return nil, fmt.Errorf("--device %q is not a UDID (40 hex digits, or 8-16 hex digits like 00008030-000A1B2C3D4E5F60)", u) + } + devices = append(devices, signing.Device{UDID: u}) } if !fromMobAI { return devices, nil @@ -203,18 +208,29 @@ func signingDevices(ctx context.Context, cmd *cobra.Command, cfg *config.Config, if err != nil { return nil, fmt.Errorf("list MobAI devices: %w (is MobAI running? try builder mobai ping)", err) } - found := 0 + physical := mobaiSigningDevices(connected) + if len(physical) == 0 { + return nil, errors.New("MobAI has no physical iOS device connected; plug one in or pass --device ") + } + return append(devices, physical...), nil +} + +// udidRe matches an iOS device UDID: 40 hex digits on devices before the +// iPhone XS, 8-16 hex digits since. +var udidRe = regexp.MustCompile(`^(?i)([0-9a-f]{40}|[0-9a-f]{8}-[0-9a-f]{16})$`) + +// mobaiSigningDevices keeps the devices whose MobAI ID is a UDID Apple can +// register: physical iOS devices attached to this or a peer machine. +// Simulators and cloud farm devices (their IDs are farm handles) are skipped. +func mobaiSigningDevices(connected []mobai.Device) []signing.Device { + var devices []signing.Device for _, d := range connected { - if d.Virtual || (d.Platform != "" && !strings.EqualFold(d.Platform, "ios")) { + if d.Virtual || d.Cloud || (d.Platform != "" && !strings.EqualFold(d.Platform, "ios")) || !udidRe.MatchString(d.ID) { continue } devices = append(devices, signing.Device{Name: d.Name, UDID: d.ID}) - found++ - } - if found == 0 { - return nil, errors.New("MobAI has no physical iOS device connected; plug one in or pass --device ") } - return devices, nil + return devices } // signingKey returns --key, else the key a previous run left in outDir, else diff --git a/cmd/builder/signing_auto_test.go b/cmd/builder/signing_auto_test.go new file mode 100644 index 0000000..19e037c --- /dev/null +++ b/cmd/builder/signing_auto_test.go @@ -0,0 +1,37 @@ +package main + +import ( + "testing" + + "github.com/MobAI-App/ios-builder/internal/mobai" +) + +func TestMobaiSigningDevicesKeepsPhysicalIOSOnly(t *testing.T) { + connected := []mobai.Device{ + {ID: "00008030-000A1B2C3D4E5F60", Name: "Jane's iPhone", Platform: "ios"}, + {ID: "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", Name: "Old iPad", Platform: "iOS"}, + {ID: "86906E11-6B70-499D-8257-16C95EE2BAF5", Name: "Simulator", Platform: "ios", Virtual: true}, + {ID: "awsdevicefarm:Apple_iPhone_16:26.0", Name: "Farm iPhone", Platform: "ios", Cloud: true}, + {ID: "R58M12345AB", Name: "Pixel", Platform: "android"}, + {ID: "not-a-udid", Name: "Unknown", Platform: "ios"}, + } + got := mobaiSigningDevices(connected) + if len(got) != 2 || got[0].UDID != "00008030-000A1B2C3D4E5F60" || got[0].Name != "Jane's iPhone" || got[1].UDID != connected[1].ID { + t.Errorf("mobaiSigningDevices = %+v", got) + } +} + +func TestUDIDRe(t *testing.T) { + for udid, want := range map[string]bool{ + "00008030-000A1B2C3D4E5F60": true, + "00008030-000a1b2c3d4e5f60": true, + "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678": true, + "00008030-000A1B2C3D4E5F6": false, + "browserstack:iPhone_14:26": false, + "": false, + } { + if got := udidRe.MatchString(udid); got != want { + t.Errorf("udidRe(%q) = %v, want %v", udid, got, want) + } + } +} diff --git a/internal/mobai/types.go b/internal/mobai/types.go index f26c20a..d910fe5 100644 --- a/internal/mobai/types.go +++ b/internal/mobai/types.go @@ -11,6 +11,7 @@ type Device struct { OSVersion string `json:"osVersion"` BridgeRunning bool `json:"bridgeRunning"` Virtual bool `json:"virtual"` + Cloud bool `json:"cloud"` // lives in a device farm; the ID is a farm handle, not a UDID } // InstallAppRequest is the request body for installing an app From d4293b7fb90bcc883a3b1196ea4094e0344f5cfe Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:58:51 +0200 Subject: [PATCH 7/7] docs: export method must follow the profile type The note described the hardcoded development export method as current; PR #17 derives it from the profile, so say what must hold and point there. --- CLAUDE.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3a7c581..a9f2348 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -254,10 +254,11 @@ internal/ deleted with it. `filter[identifier]` on bundleIds is a prefix match, so the exact identifier is checked client-side. The manual `--certificate`/`--profile` path in `runSigningSetup` is untouched; the automatic one lives in `cmd/builder/signing_auto.go`. -- **Export Method Is Still `development`**: `ios-build.yml` and `runner.sh` hardcode - `method = development` in ExportOptions.plist, so an ad-hoc or App Store profile from - `signing setup --type ad-hoc|app-store` signs the archive but the export step needs the - matching method before those IPAs work (roadmap prerequisite under item 1). +- **Export Method Follows The Profile**: the `method` in ExportOptions.plist must match the + uploaded profile's type (`development`, `ad-hoc`, `app-store`), or xcodebuild refuses the + export. `signing setup --type ad-hoc|app-store` only produces the material; deriving the + method from the profile in `ios-build.yml` and `runner.sh` is PR #17, so those IPAs work + once both are merged. - **Extension Points**: a future `ios release` (upload + TestFlight, automatic build numbers) composes `distribute.Upload` and `distribute.SubmitTestFlight` and reads `asc.Client.ListBuilds` for the latest build number; the `pkg/` wrappers do not expose `asc` yet.