راهنمای امضای کد در .NET MAUI: گواهی iOS، Keystore اندروید و Fastlane match در ۲۰۲۶

راهنمای عملی امضای کد در .NET MAUI برای iOS و اندروید: گواهی توزیع، پروفایل Provisioning، Keystore، Play App Signing و Fastlane match با نمونه GitHub Actions.

راهنمای امضای کد .NET MAUI ۲۰۲۶

به‌روزرسانی: ۶ آگوست ۲۰۲۶

امضای کد در .NET MAUI فرآیندی است که با استفاده از گواهی‌های رمزنگاری‌شده، هویت توسعه‌دهنده را به بسته‌ی اپلیکیشن (.ipa برای iOS و .aab برای اندروید) پیوند می‌زند تا App Store و Google Play اپ را معتبر بشناسند و روی دستگاه کاربر اجرا شود. راستش را بخواهید، بیشتر تیم‌هایی که تازه از Xamarin به MAUI مهاجرت کرده‌اند این بخش را جدی نمی‌گیرند و بعد در روز انتشار با خطاهای No matching provisioning profile یا Keystore was tampered with غافلگیر می‌شوند. من خودم دو سال پیش، شب قبل از یک انتشار حیاتی، ساعت ۲ بامداد دنبال Alias گمشده در Slack یک همکار سابق می‌گشتم؛ از آن شب به بعد یاد گرفتم امضای کد را قبل از خط اول UI پیکربندی کنم. در این راهنما گام‌به‌گام هر چیزی که برای امضای امن، تکرارپذیر و خودکار نیاز دارید را پوشش می‌دهم.

  • امضای iOS به سه چیز نیاز دارد: گواهی توزیع (Distribution Certificate)، پروفایل Provisioning و App ID ثبت‌شده در Apple Developer.
  • امضای اندروید مدرن دو کلید جداگانه دارد: Upload Key (شما نگه می‌دارید) و App Signing Key (Google Play نگه می‌دارد و توسط Play App Signing مدیریت می‌شود).
  • Fastlane match استاندارد صنعت برای همگام‌سازی امن گواهی‌های iOS در یک تیم و در CI/CD است و از Git خصوصی، S3 یا Google Cloud Storage به‌عنوان بک‌اند رمزنگاری‌شده استفاده می‌کند.
  • در پروژه .csproj با پراپرتی‌های <CodesignProvision>، <CodesignKey> برای iOS و <AndroidSigningKeyStore>، <AndroidSigningKeyAlias> برای اندروید امضا پیکربندی می‌شود.
  • برای انتشار خودکار به App Store Connect باید یک App Store Connect API Key (فایل .p8) بسازید؛ در GitHub Actions این کلید و کل چرخه‌ی امضا از طریق Secrets تزریق می‌شود.
  • گم‌شدن Keystore اندروید در گذشته فاجعه بود، ولی با فعال بودن Play App Signing می‌توانید Upload Key را ریست کنید (به شرطی که از قبل درست پیکربندی کرده باشید).

چرا امضای کد در .NET MAUI پیچیده‌تر از حد انتظار است؟

یک اپلیکیشن Native iOS یا اندروید معمولاً با یک IDE (Xcode یا Android Studio) امضا می‌شود که خودش تمام مراحل را انتزاع می‌کند. در .NET MAUI اما هدف ما ساخت یک پروژه است که برای چند پلتفرم خروجی می‌دهد؛ یعنی زنجیره‌ی امضا باید هم روی macOS برای iOS و Mac Catalyst و هم روی هر ماشینی برای اندروید و ویندوز اجرا شود. این یعنی امضا نه در UI بلکه در MSBuild اتفاق می‌افتد و هر خطای پیکربندی، در لاگ‌های طولانی dotnet publish پنهان می‌شود.

در پروژه‌ای که پارسال ادیت می‌کردم دیدم گواهی توسعه‌دهنده روی لپ‌تاپ یک نفر بود، Keystore اندروید در Dropbox شخصی مدیر فنی سابق مانده بود و هیچ‌کس رمز Alias را نمی‌دانست. سه دام اصلی که در .NET MAUI بیشتر از Xamarin دیده می‌شود:

  • اجباری‌شدن AAB روی Google Play: از آگوست ۲۰۲۱ اپ‌های جدید باید Android App Bundle باشند و این یعنی Play App Signing به‌طور پیش‌فرض فعال است. الگوی قدیمی «یک Keystore بساز و برای همیشه نگه‌دار» دیگر بهترین روش نیست.
  • NativeAOT و امضا: در .NET 9/10 و MAUI مدرن، حالت AOT روی iOS/Mac Catalyst باینری‌های بزرگ‌تری می‌سازد که فرآیند codesign روی آن‌ها کندتر است. اگر تایم‌اوت CI کوتاه باشد، امضا در نیمه‌راه قطع می‌شود.
  • Mac Catalyst و Notarization: اگر می‌خواهید همان MAUI را روی Mac App Store یا خارج از فروشگاه توزیع کنید، به گواهی Developer ID و مرحله‌ی جداگانه‌ی Notarization اپل نیاز دارید که ربطی به گواهی iOS ندارد.

پیش از هرچیز پیشنهاد می‌کنم اگر خط لوله‌ی انتشار خودکاری ندارید، ابتدا مقاله‌ی پیاده‌سازی CI/CD برای .NET MAUI با GitHub Actions را مرور کنید تا زمینه‌ی این راهنما شکل بگیرد.

امضای iOS: گواهی توزیع، پروفایل Provisioning و App ID

امضای iOS یک هرم سه‌طبقه است. در پایه، App ID قرار دارد که همان Bundle Identifier اپلیکیشن (مثلاً com.mobiletechlead.sampleapp) در پنل Apple Developer ثبت‌شده است. روی آن، گواهی توزیع (Distribution Certificate) قرار می‌گیرد که یک زوج کلید عمومی/خصوصی است و در Keychain مک ذخیره می‌شود. در نهایت، Profile Provisioning این دو را به هم گره می‌زند و مشخص می‌کند این گواهی مجاز است این App ID را با کدام Entitlements امضا کند.

برای انتشار در App Store به این ترکیب نیاز دارید:

  1. یک Apple Developer Program فعال (سالی ۹۹ دلار).
  2. یک App ID با شناسه Bundle یکتا و Capabilityهای دقیق اپ (Push Notifications، Sign in with Apple و…).
  3. یک iOS Distribution Certificate (نوع Apple Distribution که هم برای Ad Hoc و هم App Store کار می‌کند).
  4. یک App Store Provisioning Profile که به App ID و Distribution Certificate متصل است.

ساخت گواهی و پروفایل به‌صورت دستی مشکل ندارد، ولی نگه‌داری و توزیع آن‌ها بین اعضای تیم و در CI کابوس است. اپل فقط سه گواهی توزیع فعال در هر حساب می‌پذیرد و اگر یکی expire شود باید همه‌ی پروفایل‌های Provisioning را دوباره تولید کنید. راهنمای رسمی این چرخه در مستندات Apple Developer درباره گواهی‌ها در دسترس است.

امضای اندروید: Keystore، Upload Key و Google Play App Signing

در اندروید، امضای کد ذاتاً ساده‌تر است. شما یک فایل .keystore (فرمت JKS یا مدرن‌تر PKCS12) با یک یا چند Alias می‌سازید و APK/AAB خود را با آن امضا می‌کنید. اما از سال ۲۰۲۱ که Google Play انتشار AAB را اجباری کرد، معماری کلید کاملاً عوض شد. حالا دو کلید داریم:

ویژگیUpload KeyApp Signing Key
مالکتیم توسعهGoogle Play
محل ذخیرهKeystore محلی + Secret در CIسرورهای امن Google
در صورت گم‌شدنقابل ریست از طریق پنل Play Consoleغیرقابل ریست (بازیابی از پشتیبان تنها راه است)
استفادهامضای AAB برای آپلودامضای APK نهایی که به کاربران می‌رسد
الگوریتم پیشنهادیRSA 2048 (حداقل)RSA 4096 یا EC P-256

برای ساخت Upload Key از دستور استاندارد keytool استفاده می‌کنیم:

keytool -genkeypair -v \
  -keystore upload-keystore.jks \
  -keyalg RSA -keysize 2048 -validity 10000 \
  -alias upload \
  -storetype PKCS12 \
  -storepass "$KEYSTORE_PASSWORD" \
  -keypass "$KEY_PASSWORD" \
  -dname "CN=MobileTechLead, OU=Mobile, O=MobileTechLead, L=Tehran, C=IR"

سپس در Play Console وارد بخش Setup → App integrity → App signing شوید و گزینه‌ی Let Google manage and protect your app signing key را انتخاب کنید. Google یک App Signing Key جدید برایتان تولید می‌کند و شما فقط Upload Key بالا را نگه می‌دارید. جزئیات بیشتر در مستندات رسمی Google Play App Signing آمده است.

مدیریت خودکار امضای iOS با Fastlane match در .NET MAUI

Fastlane match یک ابزار Ruby است که گواهی‌ها و پروفایل‌های iOS را رمزنگاری کرده و در یک مخزن Git خصوصی (یا S3/GCS) ذخیره می‌کند. وقتی توسعه‌دهنده جدیدی به تیم اضافه می‌شود، فقط با یک دستور fastlane match development --readonly همه چیز روی مک او نصب می‌شود. خلاصه اینکه پایان دوران «چه کسی گواهی روی مک اوست؟» است.

راه‌اندازی برای یک پروژه .NET MAUI در چهار قدم:

  1. یک مخزن Git خصوصی مثلاً maui-signing-certs بسازید.
  2. در ریشه پروژه یا در مسیر fastlane/ فایل Matchfile اضافه کنید.
  3. یک App Store Connect API Key از پنل Users and Access → Integrations بسازید (فایل .p8).
  4. دستور fastlane match appstore را اجرا کنید تا گواهی توزیع و پروفایل App Store ساخته و در Git ذخیره شود.
# fastlane/Matchfile
git_url("[email protected]:mobiletechlead/maui-signing-certs.git")
storage_mode("git")

type("appstore") # or "development", "adhoc"
app_identifier(["com.mobiletechlead.sampleapp"])
username("[email protected]")

# Use App Store Connect API for CI (no 2FA prompt)
api_key_path("./fastlane/AuthKey.json")

و در فایل Fastfile:

# fastlane/Fastfile
default_platform(:ios)

platform :ios do
  desc "Sync App Store signing assets"
  lane :sync_signing do
    setup_ci if is_ci
    match(
      type: "appstore",
      readonly: is_ci, # never create new certs from CI
      keychain_name: "fastlane_tmp_keychain",
      keychain_password: ENV["MATCH_KEYCHAIN_PASSWORD"]
    )
  end
end

روی مک توسعه‌دهنده اجرای fastlane sync_signing کافی است. در CI باید متغیر MATCH_PASSWORD (کلمه عبور رمزگشایی مخزن)، MATCH_KEYCHAIN_PASSWORD و فایل AuthKey.json از طریق Secrets تزریق شود.

پیکربندی امضا در فایل csproj پروژه .NET MAUI

MAUI امضا را از طریق پراپرتی‌های MSBuild کنترل می‌کند. من همیشه یک PropertyGroup شرطی می‌سازم که فقط برای Configuration=Release فعال شود تا امضای اصلی سهواً در Debug اجرا نشود.

<!-- MobileTechLead.SampleApp.csproj -->
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-ios|AnyCPU'">
  <CodesignKey>Apple Distribution: MobileTechLead LLC (ABCDE12345)</CodesignKey>
  <CodesignProvision>match AppStore com.mobiletechlead.sampleapp</CodesignProvision>
  <ArchiveOnBuild>true</ArchiveOnBuild>
  <RuntimeIdentifier>ios-arm64</RuntimeIdentifier>
</PropertyGroup>

<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)'=='Release|net10.0-android'">
  <AndroidPackageFormat>aab</AndroidPackageFormat>
  <AndroidKeyStore>true</AndroidKeyStore>
  <AndroidSigningKeyStore>$(KeystorePath)</AndroidSigningKeyStore>
  <AndroidSigningKeyAlias>upload</AndroidSigningKeyAlias>
  <AndroidSigningKeyPass>env:ANDROID_KEY_PASSWORD</AndroidSigningKeyPass>
  <AndroidSigningStorePass>env:ANDROID_STORE_PASSWORD</AndroidSigningStorePass>
</PropertyGroup>

چند نکته که خیلی از تیم‌ها رویش می‌سوزند:

  • مقدار CodesignProvision باید دقیقاً نام پروفایل باشد نه UUID آن. Fastlane match پروفایل‌ها را با الگوی match AppStore <bundle-id> نام‌گذاری می‌کند.
  • پیشوند env: در AndroidSigningKeyPass باعث می‌شود MSBuild رمز عبور را از متغیر محیطی بخواند و در لاگ‌ها چاپ نکند.
  • اگر <ArchiveOnBuild>true</ArchiveOnBuild> ست نشود، خروجی iOS به‌جای .ipa یک پوشه‌ی .app امضانشده خواهد بود. (این یکی را حداقل سه بار در تیم‌های مختلف دیده‌ام.)

برای درک بهتر ساختار csproj و پیکربندی‌های چندپلتفرمی می‌توانید به راهنمای ما درباره‌ی مهاجرت Xamarin.Forms به .NET MAUI رجوع کنید که تفاوت‌های ساختار پروژه را توضیح می‌دهد.

یکپارچه‌سازی امضا در CI/CD با GitHub Actions

در CI هدف این است که هیچ گواهی، Keystore یا رمز عبوری در مخزن یا لاگ‌ها ظاهر نشود. من از الگوی زیر در GitHub Actions استفاده می‌کنم که هم iOS و هم اندروید را پوشش می‌دهد.

تنظیم Secrets در GitHub

در تنظیمات مخزن این Secretها را اضافه کنید:

  • MATCH_PASSWORD: کلمه عبور رمزنگاری مخزن match
  • MATCH_KEYCHAIN_PASSWORD: یک رشته تصادفی که در ابتدای هر job برای Keychain موقت ساخته می‌شود
  • APP_STORE_CONNECT_API_KEY_JSON: محتوای فایل AuthKey.json (Base64)
  • ANDROID_KEYSTORE_BASE64: Keystore به‌صورت Base64 (base64 -i upload.keystore)
  • ANDROID_KEY_PASSWORD و ANDROID_STORE_PASSWORD

نمونه Workflow کامل

# .github/workflows/release.yml
name: Release MAUI Apps

on:
  push:
    tags: ['v*']

jobs:
  build-ios:
    runs-on: macos-15
    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET 10
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '10.0.x'

      - name: Install MAUI workload
        run: dotnet workload install maui

      - name: Restore Fastlane
        run: bundle install
        working-directory: ./fastlane

      - name: Decode ASC API Key
        run: |
          echo "${{ secrets.APP_STORE_CONNECT_API_KEY_JSON }}" \
            | base64 --decode > fastlane/AuthKey.json

      - name: Sync signing assets
        env:
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          MATCH_KEYCHAIN_PASSWORD: ${{ secrets.MATCH_KEYCHAIN_PASSWORD }}
        run: bundle exec fastlane sync_signing
        working-directory: ./fastlane

      - name: Build & Archive IPA
        run: |
          dotnet publish src/MobileTechLead.SampleApp \
            -f net10.0-ios -c Release \
            -p:ArchiveOnBuild=true \
            -p:RuntimeIdentifier=ios-arm64

      - name: Upload to TestFlight
        env:
          ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
          ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
        run: bundle exec fastlane pilot upload \
          --ipa artifacts/SampleApp.ipa \
          --api_key_path fastlane/AuthKey.json

  build-android:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET 10
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '10.0.x'

      - name: Install MAUI workload
        run: dotnet workload install maui-android

      - name: Restore Keystore
        env:
          KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
        run: echo "$KEYSTORE_B64" | base64 --decode > $HOME/upload.keystore

      - name: Build signed AAB
        env:
          ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
          ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
        run: |
          dotnet publish src/MobileTechLead.SampleApp \
            -f net10.0-android -c Release \
            -p:AndroidPackageFormat=aab \
            -p:KeystorePath=$HOME/upload.keystore

      - name: Upload to Play Console (Internal Track)
        uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
          packageName: com.mobiletechlead.sampleapp
          releaseFiles: '**/*.aab'
          track: internal

خطاهای رایج امضای کد و راه‌حل‌ها

در چند سال گذشته با ده‌ها خطای امضا در پروژه‌های MAUI و Xamarin برخورد کرده‌ام. رایج‌ترین‌ها و راه‌حلشان:

خطای «No installed provisioning profiles match»

در ۹۰٪ موارد یعنی Bundle ID پروژه با App ID در Provisioning همخوانی ندارد یا پروفایل expire شده. با fastlane match nuke appstore و بازتولید حل می‌شود. اگر روی CI هستید مطمئن شوید Keychain موقت ساخته شده و باز است (تابع setup_ci در Fastlane این کار را می‌کند).

خطای «Keystore was tampered with»

معمولاً یعنی رمز عبور Store یا Key اشتباه است، یا فایل Keystore هنگام Base64/decode خراب شده. برای اطمینان روی CI با keytool -list -v -keystore $HOME/upload.keystore Keystore را قبل از build تست کنید. من همیشه این را قبل از هر job امضا اجرا می‌کنم؛ هزینه‌اش هیچ است و نیم ساعت دیباگ ذخیره می‌کند.

خطای «ITMS-90189 Redundant Binary Upload»

یعنی نسخه‌ی CFBundleShortVersionString و CFBundleVersion با نسخه‌ی موجود در App Store Connect یکی است. در MAUI با پراپرتی‌های <ApplicationDisplayVersion> و <ApplicationVersion> مدیریت می‌شود. در CI بهتر است شماره‌ی Build را از github.run_number تزریق کنید.

خطای «Signature not aligned» در آپلود Play

معمولاً نتیجه‌ی استفاده از v1 Signing تنها است. مطمئن شوید <AndroidUseAapt2>true</AndroidUseAapt2> فعال است و <AndroidSigningV2> و <AndroidSigningV3> به‌طور پیش‌فرض روشن هستند.

چک‌لیست امنیتی امضای کد برای تیم‌های تولیدی

این چک‌لیست را در روز اول هر پروژه‌ی MAUI اجرا کنید تا در روز انتشار آرام بمانید:

  1. مخزن Git خصوصی برای Fastlane match ساخته شود و دسترسی فقط برای Release Managers باشد.
  2. رمز MATCH_PASSWORD در یک Password Manager (1Password، Bitwarden) با دسترسی گروهی نگه‌داری شود، نه در Slack.
  3. App Store Connect API Key از نوع App Manager (نه Admin) ساخته شود و به کاربر مجزای ci-release متصل باشد.
  4. Upload Key اندروید در همان مخزن رمزنگاری‌شده یا در یک Secret Manager (AWS Secrets Manager، Azure Key Vault) نگه‌داری شود.
  5. هر ۹۰ روز یک بار Rotate کردن MATCH_PASSWORD و ANDROID_KEY_PASSWORD در تقویم تیم ثبت شود.
  6. Backup رمزنگاری‌شده‌ی Keystore اندروید در دو محل جغرافیایی جداگانه نگه‌داری شود (S3 Cross-Region یا Google Cloud Storage با Object Versioning).
  7. Runbook «چه کنیم اگر گواهی expire شود» در Wiki تیم نوشته شود.
  8. پس از هر انتشار موفق، لاگ‌های امضا از CI حذف شوند تا مسیرها و نام Alias افشا نشوند.

برای پایش سلامت انتشار پس از استقرار، حتماً یک ابزار گزارش کرش راه بیندازید. راهنمای ما درباره‌ی گزارش‌گیری Crash با Sentry پس از بازنشستگی App Center این مرحله را پوشش می‌دهد.

پرسش‌های متداول

آیا برای امضای اپ iOS در .NET MAUI حتماً باید Mac داشته باشم؟

بله. زنجیره‌ی codesign، Security.framework و xcrun فقط روی macOS در دسترس است. اما نیازی نیست همه‌ی توسعه‌دهنده‌ها Mac داشته باشند؛ می‌توانید از یک macOS Runner در CI (مثلاً macos-15 در GitHub Actions یا Mac mini شخصی با self-hosted runner) استفاده کنید و توسعه‌دهنده‌ی ویندوز فقط تغییرات را push کند.

تفاوت Upload Key و App Signing Key در Google Play چیست؟

Upload Key کلیدی است که شما با آن AAB را برای آپلود امضا می‌کنید و در Keystore خودتان نگه می‌دارید. App Signing Key کلید نهایی است که Google Play با آن APKهای تحویلی به کاربران را دوباره امضا می‌کند و در سرورهای امن Google ذخیره می‌شود. اگر Upload Key گم شود می‌توانید آن را از پنل Play Console ریست کنید، اما App Signing Key غیرقابل بازیابی است.

اگر Keystore اندروید را از دست بدهم چه اتفاقی می‌افتد؟

اگر Play App Signing را فعال کرده‌اید، فقط Upload Key را از دست داده‌اید و می‌توانید از طریق App integrity → Request upload key reset در Play Console یک کلید جدید ثبت کنید. اگر Play App Signing فعال نبوده و اپ قدیمی است، دیگر نمی‌توانید به‌روزرسانی منتشر کنید و باید اپ را با Bundle ID جدید بازنشر کنید که یعنی از دست دادن همه‌ی نصب‌ها و رتبه‌ها.

وقتی گواهی توزیع iOS منقضی می‌شود چه باید کرد؟

گواهی منقضی‌شده روی اپ‌های منتشرشده در App Store تأثیری ندارد و کاربران فعلی بدون مشکل ادامه می‌دهند. اما برای انتشار نسخه‌ی جدید باید گواهی تازه بسازید و همه‌ی Provisioning Profileها را دوباره تولید کنید. با Fastlane match این کار با fastlane match nuke distribution و سپس fastlane match appstore در چند دقیقه تمام می‌شود.

آیا Fastlane match با App Store Connect API Key کار می‌کند یا هنوز به Apple ID و 2FA نیاز دارد؟

از Fastlane 2.196 به بعد، match به‌طور کامل با App Store Connect API Key کار می‌کند و دیگر نیازی به Apple ID، رمز اپلیکیشن یا Session Token نیست. کافی است در Matchfile مسیر api_key_path را به فایل JSON حاوی Key ID، Issuer ID و محتوای .p8 بدهید. این روش برای CI الزامی است چون 2FA در محیط headless کار نمی‌کند.

آیا می‌توانم بدون Fastlane match، گواهی‌ها را به‌صورت دستی در GitHub Actions تزریق کنم؟

بله. می‌توانید فایل .p12 گواهی و .mobileprovision پروفایل را Base64 کرده و در Secrets ذخیره کنید، سپس در Workflow با security import و cp در Keychain و مسیر ~/Library/MobileDevice/Provisioning Profiles/ قرار دهید. ولی با اضافه‌شدن هر توسعه‌دهنده یا هر بار Rotate گواهی، این روش خسته‌کننده می‌شود و match نگه‌داری‌اش را حل می‌کند.

Sofia Rodriguez
درباره نویسنده Sofia Rodriguez

Mobile DevOps engineer focused on the unglamorous stuff: build pipelines, signing, store releases, and the tooling that keeps teams shipping.