Skip to content

iOS local build: find-identity -v in findIdentitiesByTeamId falsely fails on macOS Tahoe with Distribution certificate ... hasn't been imported successfully #3678

@kearnsm293-afk

Description

@kearnsm293-afk

Summary

eas build --profile development --platform ios --local fails at the Prepare credentials step on macOS Tahoe (26.x) with Distribution certificate <fingerprint> hasn't been imported successfully, even though the certificate and private key were imported into the build keychain successfully. The failure is in a presence-check that uses security find-identity -v against an ephemeral build keychain that — by design — does not contain the full Apple trust chain required for -v's "validity" judgment.

Symptom

✖ Prepare credentials
Error: Distribution certificate with fingerprint <FINGERPRINT> hasn't been imported successfully
    at Keychain.ensureCertificateImported (.../@expo/build-tools/dist/ios/credentials/keychain.js:57:19)

The cert+key are in fact imported. Verified: running the same import_certificate fastlane action manually against the same .p12 produces 1 identity imported. and the fingerprint shows up in security find-identity (without -v) in the build keychain, annotated (CSSMERR_TP_NOT_TRUSTED).

Reproduction

  1. macOS Tahoe (26.x), Xcode 26.x, fastlane 2.x, eas-cli 18.8+ (any version that pulls eas-cli-local-build-plugin >= 18.8.0 with @expo/build-tools >= 18.8.0).
  2. Project configured for an iOS development build (real-device, not simulator).
  3. npx eas-cli build --profile development --platform ios --local.
  4. EAS downloads the dist .p12 and provisioning profile from Expo's server (or reads from credentials.json — same downstream path), creates a fresh ephemeral keychain at os.tmpdir()/eas-build-<uuid>.keychain, imports the cert+key into it, and then immediately invokes findIdentitiesByTeamId to verify the import. That call fails.

Root cause

Keychain.findIdentitiesByTeamId (packages/build-tools/src/ios/credentials/keychain.ts#L100-L107):

private async findIdentitiesByTeamId(teamId: string): Promise<string> {
  const { output } = await spawn(
    'security',
    ['find-identity', '-v', '-s', `(${teamId})`, this.keychainPath],
    { stdio: 'pipe' }
  );
  return output.join('');
}

The -v flag means "valid identities only". security's validity judgment requires the full trust chain (dist cert → Apple WWDR Intermediate → Apple Root CA) to resolve from the keychain(s) in the search list. The build keychain created in Keychain.create() only holds the dist cert + private key. Apple Root CA lives in /Library/Keychains/System.keychain and Apple WWDR G3 lives in the user's login keychain.

Critically: passing System.keychain (or any other keychain) as a positional argument to security find-identity does not cause it to aggregate trust resolution across keychains. Trust aggregation across keychains only happens for the user's session-wide search list (security list-keychains -s) — which Keychain.create() deliberately does not modify (and shouldn't, because that change is session-wide and would surprise the user).

So on a fresh ephemeral keychain holding only the cert+key, find-identity -v -s "(<teamId>)" <buildKeychainPath> returns 0 identities, ensureCertificateImported throws, and the build dies — even though the cert+key are present and the build would actually succeed if allowed to proceed (because gym/codesign later perform their own trust resolution via Security.framework, which does aggregate across keychains).

This is fundamentally a misuse of find-identity -v as a presence check. -v's "valid" predicate depends on the whole trust environment, not just whether the cert+key are in the named keychain.

Proposed fix

Drop the -v flag. The remaining flags (-s "(${teamId})" <buildKeychainPath>) still constrain the match to the right team and keychain, so the includes(fingerprint) check in ensureCertificateImported correctly answers "is the cert+key pair present in this keychain?" The cert will appear in the output annotated (CSSMERR_TP_NOT_TRUSTED), but that's expected and ignored — codesign resolves trust correctly downstream via Security.framework.

  private async findIdentitiesByTeamId(teamId: string): Promise<string> {
    const { output } = await spawn(
      'security',
-     ['find-identity', '-v', '-s', `(${teamId})`, this.keychainPath],
+     ['find-identity', '-s', `(${teamId})`, this.keychainPath],
      {
        stdio: 'pipe',
      }
    );
    return output.join('');
  }

This is consistent with what the surrounding code is actually trying to do (verify import succeeded), and matches what works in practice on every macOS version I've tested (a dropped--v find-identity reliably reports the imported cert+key, and the build subsequently succeeds end-to-end).

Alternative fix (less preferred)

Inject Apple Root CA + WWDR G3 into the build keychain at creation time. This would let -v continue to make sense semantically. Trade-off: requires bundling/fetching the Apple intermediates and adds a second security call per build. The drop--v fix has none of that overhead, doesn't change observable behavior (codesign still resolves trust), and matches the intent of the call (presence check, not validity check).

Workaround (currently in use)

Manually edit ~/.npm/_npx/<hash>/node_modules/@expo/build-tools/dist/ios/credentials/keychain.js to drop the -v. Fragile — wiped on npx clear-cache, on eas-cli upgrade (different eas-cli-local-build-plugin version → new npx hash → fresh download), and on fresh-Mac setup. Re-applying it programmatically requires a small idempotent shell script that globs the npx cache; we run such a script from our repo's scripts/ directory after every cache invalidation event.

credentialsSource: \"local\" in eas.json does not work around this bug — credentialsSource only changes where eas-cli sources the .p12 (Expo's server vs. local credentials.json); the same manager.jskeychain.js code path runs downstream regardless. We discovered this the hard way after planning a credentials.json migration; the only way forward is dropping -v upstream.

Environment

  • macOS 26.0 (Tahoe)
  • Xcode 26.x
  • Node 24.15.0
  • eas-cli 18.x (verified on 18.8.0 and 18.11.0)
  • eas-cli-local-build-plugin 18.8.0 / @expo/build-tools 18.8.0
  • fastlane 2.x

Related code

Happy to send a PR for the one-line fix if it's welcome.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions