بهروزرسانی: ۶ آگوست ۲۰۲۶
امضای کد در .NET MAUI فرآیندی است که با استفاده از گواهیهای رمزنگاریشده، هویت توسعهدهنده را به بستهی اپلیکیشن (.ipa برای iOS و .aab برای اندروید) پیوند میزند تا App Store و Google Play اپ را معتبر بشناسند و روی دستگاه کاربر اجرا شود. راستش را بخواهید، بیشتر تیمهایی که تازه از Xamarin به MAUI مهاجرت کردهاند این بخش را جدی نمیگیرند و بعد در روز انتشار با خطاهای No matching provisioning profile یا Keystore was tampered with غافلگیر میشوند. من خودم دو سال پیش، شب قبل از یک انتشار حیاتی، ساعت ۲ بامداد دنبال Alias گمشده در Slack یک همکار سابق میگشتم؛ از آن شب به بعد یاد گرفتم امضای کد را قبل از خط اول UI پیکربندی کنم. در این راهنما گامبهگام هر چیزی که برای امضای امن، تکرارپذیر و خودکار نیاز دارید را پوشش میدهم.
چرا امضای کد در .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 به این ترکیب نیاز دارید:
- یک Apple Developer Program فعال (سالی ۹۹ دلار).
- یک App ID با شناسه Bundle یکتا و Capabilityهای دقیق اپ (Push Notifications، Sign in with Apple و…).
- یک iOS Distribution Certificate (نوع Apple Distribution که هم برای Ad Hoc و هم App Store کار میکند).
- یک 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 Key | App 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 در چهار قدم:
- یک مخزن Git خصوصی مثلاً
maui-signing-certsبسازید. - در ریشه پروژه یا در مسیر
fastlane/فایلMatchfileاضافه کنید. - یک App Store Connect API Key از پنل Users and Access → Integrations بسازید (فایل
.p8). - دستور
fastlane match appstoreرا اجرا کنید تا گواهی توزیع و پروفایل App Store ساخته و در Git ذخیره شود.
# fastlane/Matchfile
git_url("git@github.com:mobiletechlead/maui-signing-certs.git")
storage_mode("git")
type("appstore") # or "development", "adhoc"
app_identifier(["com.mobiletechlead.sampleapp"])
username("release@mobiletechlead.com")
# 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: کلمه عبور رمزنگاری مخزن matchMATCH_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 اجرا کنید تا در روز انتشار آرام بمانید:
- مخزن Git خصوصی برای Fastlane match ساخته شود و دسترسی فقط برای Release Managers باشد.
- رمز
MATCH_PASSWORDدر یک Password Manager (1Password، Bitwarden) با دسترسی گروهی نگهداری شود، نه در Slack. - App Store Connect API Key از نوع App Manager (نه Admin) ساخته شود و به کاربر مجزای
ci-releaseمتصل باشد. - Upload Key اندروید در همان مخزن رمزنگاریشده یا در یک Secret Manager (AWS Secrets Manager، Azure Key Vault) نگهداری شود.
- هر ۹۰ روز یک بار Rotate کردن
MATCH_PASSWORDوANDROID_KEY_PASSWORDدر تقویم تیم ثبت شود. - Backup رمزنگاریشدهی Keystore اندروید در دو محل جغرافیایی جداگانه نگهداری شود (S3 Cross-Region یا Google Cloud Storage با Object Versioning).
- Runbook «چه کنیم اگر گواهی expire شود» در Wiki تیم نوشته شود.
- پس از هر انتشار موفق، لاگهای امضا از 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 نگهداریاش را حل میکند.


