diff --git a/CLAUDE.md b/CLAUDE.md index d36f8d5..a9f2348 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,24 @@ 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 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. @@ -236,10 +271,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..d8388ce 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,76 @@ 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; 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, + 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 +361,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 +378,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 +386,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 +424,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/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..3c7aa6e --- /dev/null +++ b/cmd/builder/signing_auto.go @@ -0,0 +1,346 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "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 { + 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 + } + 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) + } + 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.Cloud || (d.Platform != "" && !strings.EqualFold(d.Platform, "ios")) || !udidRe.MatchString(d.ID) { + continue + } + devices = append(devices, signing.Device{Name: d.Name, UDID: d.ID}) + } + return devices +} + +// 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/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/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. 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) + } +} 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) } 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 diff --git a/internal/signing/auto.go b/internal/signing/auto.go new file mode 100644 index 0000000..fde6599 --- /dev/null +++ b/internal/signing/auto.go @@ -0,0 +1,486 @@ +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. 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 { + return res, err + } + res.P12, err = BuildP12(keyPEM, cert.Content, opts.Password) + if 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 + 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..159cde8 --- /dev/null +++ b/internal/signing/auto_test.go @@ -0,0 +1,659 @@ +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) + } + // 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") + } +} + +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 + 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) { + 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()) {