ساخت اپلیکیشن اندروئید (سوم)

بخش ششم — ارتباط لحظه‌ای


درس چهل‌وچهارم

WebSocket

اصلاً WebSocket چرا به وجود اومد؟

فرض کن داخل قیمت‌بان صفحه قیمت یک گوشی رو باز کردی. حالا قیمت اون گوشی روی سرور تغییر می‌کنه. سؤال اینجاست که برنامه از کجا باید بفهمه این اتفاق افتاده؟

اولین راه‌حلی که معمولاً به ذهن می‌رسه اینه که برنامه هر چند ثانیه یک بار از سرور بپرسه:

«قیمت عوض شده؟»

اگر جواب منفی بود، پنج ثانیه بعد دوباره همین سؤال رو بپرسه. به این روش Polling میگن.

مشکل Polling اینه که بیشتر درخواست‌ها هیچ نتیجه‌ای ندارن. فرض کن قیمت یک کالا فقط روزی دو بار تغییر می‌کنه، اما برنامه هر پنج ثانیه یک بار درخواست می‌فرسته. یعنی در طول یک روز، هزاران درخواست فقط برای شنیدن یک جواب ساده ارسال میشن:

«نه، هنوز هیچ تغییری نکرده.»

این کار هم اینترنت کاربر رو مصرف می‌کنه، هم سرور رو بیهوده درگیر می‌کنه و هم باتری گوشی رو بیشتر مصرف می‌کنه. برای حل همین مشکل، WebSocket به وجود اومد.


WebSocket دقیقاً چیه؟

WebSocket یک روش ارتباطیه که اجازه میده یک ارتباط دائمی بین برنامه و سرور برقرار بمونه.

در ارتباط‌های معمولی HTTP، هر درخواست یک چرخه کامل داره. برنامه درخواست می‌فرسته، سرور جواب میده و ارتباط تموم میشه. اگر دوباره اطلاعات بخوای، باید دوباره یک ارتباط جدید ساخته بشه.

اما در WebSocket داستان فرق می‌کنه. برنامه فقط یک بار به سرور وصل میشه و این اتصال باز می‌مونه. از اون لحظه به بعد، هر کدوم از دو طرف هر وقت اطلاعاتی داشته باشن، می‌تونن همون لحظه برای طرف مقابل ارسالش کنن، بدون اینکه دوباره اتصال جدیدی ساخته بشه.

به همین خاطر به WebSocket میگن یک ارتباط دوطرفه و دائمی.


تفاوت HTTP و WebSocket

فرض کن دو نفر با هم نامه‌نگاری می‌کنن.

در HTTP هر بار که یکی از اون‌ها می‌خواد چیزی بگه، باید یک نامه جدید بنویسه، ارسالش کنه، منتظر جواب بمونه و بعد ارتباط تموم میشه.

اما WebSocket بیشتر شبیه یک تماس تلفنیه. تماس فقط یک بار برقرار میشه و بعد هر دو نفر هر زمان بخوان می‌تونن صحبت کنن. لازم نیست برای هر جمله دوباره تماس جدیدی برقرار بشه.

همین تفاوت باعث میشه WebSocket برای اطلاعاتی که دائماً تغییر می‌کنن، خیلی سریع‌تر و بهینه‌تر باشه.


WebSocket چه زمانی به درد می‌خوره؟

WebSocket بیشتر در برنامه‌های Real-time استفاده میشه؛ یعنی جاهایی که اطلاعات باید تقریباً همان لحظه به کاربر برسن.

مثلاً:

  • پیام‌رسان‌ها
  • بازی‌های آنلاین
  • سامانه‌های معاملاتی بورس
  • قیمت لحظه‌ای ارز و طلا
  • داشبوردهای مانیتورینگ
  • ویرایش همزمان اسناد
  • سیستم‌های چت پشتیبانی

وجه مشترک همه این برنامه‌ها اینه که منتظر نمی‌مونن کاربر درخواست جدید بفرسته؛ خود سرور هر زمان اتفاقی بیفته، اطلاعات رو ارسال می‌کنه.


آیا قیمت‌بان به WebSocket احتیاج داره؟

در بیشتر حالت‌ها، نه.

شاید در نگاه اول فکر کنی چون قیمت کالاها تغییر می‌کنن، پس حتماً باید از WebSocket استفاده کنیم. اما اگر کمی دقیق‌تر نگاه کنیم، می‌بینیم چنین کاری اصلاً منطقی نیست.

فرض کن صد هزار کاربر همزمان برنامه قیمت‌بان رو نصب کردن. اگر برای هر کاربر یک WebSocket دائماً باز نگه داریم، یعنی صد هزار اتصال دائمی روی سرور وجود داره؛ حتی زمانی که کاربر فقط گوشی رو داخل جیبش گذاشته و اصلاً برنامه رو باز نکرده.

در پروژه‌ای مثل قیمت‌بان ، راه‌حل بهتر اینه که وقتی قیمت مهمی تغییر کرد، سرور فقط از طریق Firebase Cloud Messaging (FCM) یک اعلان برای کاربر بفرسته. این روش هم مصرف باتری کمتری داره، هم منابع سرور رو کمتر درگیر می‌کنه و هم دقیقاً برای چنین سناریویی طراحی شده.

البته اگر روزی داخل قیمت‌بان بخوای یک صفحه «قیمت لحظه‌ای ارز» یا «حراج زنده» اضافه کنی که هر چند ثانیه تغییر می‌کنه، آن وقت WebSocket می‌تونه انتخاب مناسبی باشه.


ارتباط WebSocket چطوری برقرار میشه؟

برخلاف HTTP که با http:// یا https:// شروع میشه، WebSocket از پروتکل‌های خودش استفاده می‌کنه.

ws://example.com/socket

اگر ارتباط رمزنگاری‌شده باشه، از wss:// استفاده می‌کنیم.

wss://example.com/socket

وقتی برنامه به این آدرس وصل میشه، ابتدا یک درخواست HTTP مخصوص ارسال میشه. اگر سرور هم WebSocket رو پشتیبانی کنه، ارتباط از HTTP به WebSocket تبدیل میشه و از اون لحظه به بعد، هر دو طرف می‌تونن هر زمان خواستن داده ارسال کنن.


ارتباط WebSocket همیشه باز می‌مونه؟

تا زمانی که یکی از دو طرف ارتباط رو نبنده یا اینترنت قطع نشه، بله.

البته این به معنی نیست که همیشه داده ردوبدل میشه. ممکنه چند دقیقه هیچ پیامی ارسال نشه، اما اتصال همچنان برقرار باشه.

به همین دلیل خیلی از سرورها هر چند وقت یک بار پیام‌های کوچکی به اسم Ping ارسال می‌کنن تا مطمئن بشن ارتباط هنوز برقرار است. اگر طرف مقابل جواب Pong نده، سرور متوجه میشه اتصال قطع شده و اون رو می‌بنده.


یک نمونه ساده از اتصال WebSocket

در اندروید معمولاً از کتابخانه OkHttp برای WebSocket هم استفاده میشه.

ابتدا یک Client می‌سازیم.

val client = OkHttpClient()

بعد درخواست اتصال رو ایجاد می‌کنیم.

val request = Request.Builder()
    .url("wss://example.com/socket")
    .build()

در نهایت اتصال برقرار میشه.

client.newWebSocket(
    request,
    socketListener
)

از این لحظه به بعد، هر زمان سرور پیامی ارسال کنه، Listener اون پیام رو دریافت می‌کنه.

دقت کن که هدف این درس یاد گرفتن مفهوم WebSocketـه، نه جزئیات کامل پیاده‌سازی. در پروژه‌های واقعی معمولاً مدیریت اتصال، Reconnect، احراز هویت و مدیریت خطاها هم به این ساختار اضافه میشن.


اگر اینترنت قطع بشه چی؟

یکی از مهم‌ترین تفاوت‌های WebSocket با HTTP اینه که اتصال دائمیه. بنابراین اگر اینترنت قطع بشه، ارتباط هم از بین میره.

در این حالت برنامه باید متوجه قطع شدن اتصال بشه و هر زمان اینترنت دوباره برقرار شد، اتصال جدیدی ایجاد کنه. این فرآیند رو معمولاً Reconnect میگن.

البته نباید بلافاصله و پشت سر هم تلاش به اتصال مجدد انجام بشه، چون اگر سرور واقعاً از دسترس خارج شده باشه، فقط منابع دستگاه و سرور هدر میره. معمولاً بین هر تلاش چند ثانیه فاصله گذاشته میشه و اگر چند بار پشت سر هم شکست خورد، فاصله زمانی بیشتر و بیشتر میشه. به این روش Exponential Backoff میگن.


WebSocket همیشه بهترین انتخاب نیست

بعضی برنامه‌نویس‌ها بعد از آشنا شدن با WebSocket فکر می‌کنن برای هر پروژه‌ای باید ازش استفاده کنن، چون لحظه‌ای و سریعه. اما این نگاه اشتباهه. نگه داشتن هزاران اتصال دائمی روی سرور هزینه داره. هم حافظه بیشتری مصرف میشه، هم مدیریت اتصال‌ها سخت‌تره و هم زیرساخت قوی‌تری لازم داره.

اگر اطلاعات فقط هر چند ساعت یک بار تغییر می‌کنن، استفاده از WebSocket بیشتر شبیه اینه که برای صحبت کردن با همسایه، تمام شبانه‌روز تلفن رو باز نگه داری؛ در حالی که یک تماس کوتاه موقع نیاز کاملاً کافی بود. همیشه باید ببینی واقعاً به ارتباط لحظه‌ای احتیاج داری یا نه.


WebSocket یا REST API؟

خیلی‌ها این دو رو رقیب هم می‌دونن، در حالی که معمولاً کنار هم استفاده میشن. فرض کن قیمت‌بان باز میشه. برنامه ابتدا با REST API لیست کالاها رو دریافت می‌کنه، چون این اطلاعات حجم نسبتاً زیادی دارن و فقط یک بار لازم هست دانلود بشن.

حالا اگر بخوای همون صفحه قیمت‌ها به صورت لحظه‌ای تغییر کنه، WebSocket برقرار میشه و فقط تغییرات جدید ارسال میشن. یعنی REST بیشتر برای دریافت و ارسال درخواست‌های معمولی استفاده میشه، اما WebSocket برای تغییرات لحظه‌ای و ارتباط دائمی.


معمولاً چه اشتباه‌هایی تو WebSocket پیش میاد؟

یکی از اشتباه‌های رایج اینه که اتصال WebSocket بعد از بسته شدن صفحه همچنان باز بمونه. اگر صفحه‌ای که از اطلاعات لحظه‌ای استفاده می‌کنه بسته شده، معمولاً دیگه دلیلی برای باز نگه داشتن اون اتصال وجود نداره.

اشتباه بعدی اینه که برای هر نوع اطلاعاتی از WebSocket استفاده بشه. اگر داده‌ها به ندرت تغییر می‌کنن، REST API یا حتی WorkManager انتخاب بهتری هستن. بعضی توسعه‌دهنده‌ها هم مدیریت قطع شدن اتصال رو فراموش می‌کنن. در دنیای واقعی اینترنت همیشه پایدار نیست و برنامه باید برای قطع و وصل شدن‌های موقت آماده باشه.

در نهایت، گاهی تمام منطق برنامه داخل Listener مربوط به WebSocket نوشته میشه. بهتره Listener فقط پیام‌ها رو دریافت کنه و پردازش اصلی داخل Repository یا Use Case انجام بشه تا معماری پروژه تمیز باقی بمونه.


جمع‌بندی

WebSocket یک روش ارتباطی دوطرفه و دائمی بین برنامه و سروره که برای سیستم‌های Real-time طراحی شده. برخلاف HTTP که برای هر درخواست یک ارتباط جدید ایجاد می‌کنه، WebSocket فقط یک بار اتصال رو برقرار می‌کنه و بعد هر دو طرف هر زمان لازم باشه، می‌تونن اطلاعات رو برای هم ارسال کنن.

در پروژه‌ای مثل قیمت‌بان، استفاده از WebSocket در بیشتر بخش‌ها ضرورتی نداره و ابزارهایی مثل REST API، WorkManager و Firebase Cloud Messaging انتخاب‌های مناسب‌تری هستن. اما اگر روزی بخوای بخشی از برنامه اطلاعاتی رو به صورت لحظه‌ای نمایش بده، مثل قیمت لحظه‌ای ارز یا یک حراج زنده، اون وقت WebSocket می‌تونه بهترین گزینه باشه. مهم‌ترین نکته اینه که WebSocket یک ابزار قدرتمنده، اما فقط زمانی ارزش استفاده داره که واقعاً به ارتباط لحظه‌ای و دائمی نیاز داشته باشی.


درس چهل‌وپنجم

Socket.IO

اصلاً Socket.IO چیه؟

در درس قبل با WebSocket آشنا شدیم و دیدیم که چطور میشه یک ارتباط دائمی بین برنامه و سرور برقرار کرد. اما اگر WebSocket وجود داره، پس Socket.IO برای چیه؟ آیا این دو یکی هستن؟ جواب کوتاه اینه که نه.

خیلی از برنامه‌نویس‌ها اول کار فکر می‌کنن Socket.IO فقط یک اسم دیگه برای WebSocketـه، در حالی که این دو دقیقاً یک چیز نیستن. WebSocket یک پروتکل استاندارد برای برقراری ارتباطه، اما Socket.IO یک کتابخونه است که روی این ارتباط امکانات بیشتری اضافه می‌کنه. یعنی Socket.IO از WebSocket استفاده می‌کنه، اما فقط به WebSocket محدود نیست و قابلیت‌های دیگه‌ای هم در اختیار برنامه‌نویس قرار میده.


چرا Socket.IO به وجود اومد؟

وقتی WebSocket معرفی شد، همه مرورگرها و سرورها از اون به خوبی پشتیبانی نمی‌کردن. بعضی مرورگرها WebSocket نداشتن، بعضی شبکه‌ها اجازه برقراری اتصال WebSocket رو نمی‌دادن و گاهی هم ارتباط ناگهان قطع می‌شد و برنامه‌نویس باید همه این مشکلات رو خودش مدیریت می‌کرد.

Socket.IO برای ساده‌تر کردن همین مسائل ساخته شد. هدفش این بود که برنامه‌نویس به جای درگیر شدن با جزئیات ارتباط، فقط روی ارسال و دریافت پیام‌ها تمرکز کنه. اگر WebSocket در دسترس بود از اون استفاده می‌کنه و اگر نبود، در بعضی شرایط می‌تونه از روش‌های جایگزین استفاده کنه تا ارتباط همچنان برقرار بمونه. به همین خاطر خیلی‌ها به Socket.IO میگن یک لایه راحت‌تر روی ارتباط‌های Real-time.


Socket.IO با WebSocket چه فرقی داره؟

شاید بهترین تشبیه این باشه که WebSocket مثل یک موتور خامه، اما Socket.IO یک ماشین کامله که روی اون موتور ساخته شده. خود WebSocket فقط ارتباط رو برقرار می‌کنه. اما Socket.IO امکاناتی مثل مدیریت اتصال مجدد، ارسال Event، گروه‌بندی کاربران، تشخیص قطع شدن ارتباط و چند قابلیت دیگه رو هم آماده در اختیارت قرار میده.

یعنی اگر بخوای فقط یک ارتباط ساده برقرار کنی، WebSocket به تنهایی کافیه. اما اگر پروژه بزرگ‌تر باشه و بخوای خیلی از قابلیت‌های جانبی رو بدون نوشتن کد اضافی داشته باشی، Socket.IO می‌تونه انتخاب راحت‌تری باشه.


Socket.IO فقط با Socket.IO کار می‌کنه

یکی از مهم‌ترین نکته‌هایی که خیلی از تازه‌کارها نمی‌دونن اینه که کلاینت Socket.IO نمی‌تونه به یک WebSocket معمولی وصل بشه. برعکسش هم درسته.

اگر روی سرور از Socket.IO استفاده کردی، سمت اندروید هم باید از Socket.IO استفاده کنی. اگر سرور فقط WebSocket استاندارد ارائه میده، باید از WebSocket Client استفاده کنی. دلیلش اینه که Socket.IO علاوه بر WebSocket، پروتکل مخصوص خودش رو هم برای مدیریت Eventها و ارتباط استفاده می‌کنه. بنابراین این دو مستقیماً جای همدیگه نیستن.


Event یعنی چی؟

یکی از جذاب‌ترین ویژگی‌های Socket.IO اینه که همه ارتباط‌ها بر اساس Event انجام میشن. فرض کن داخل قیمت‌بان کاربر یک کالا رو به علاقه‌مندی‌ها اضافه کرده. به جای اینکه فقط یک پیام خام ارسال کنیم، می‌تونیم بگیم:

favorite_added

یا وقتی قیمت یک کالا تغییر کرد:

price_changed

یا وقتی پیام جدید رسید:

new_message

یعنی هر پیام یک اسم مشخص داره و گیرنده دقیقاً می‌دونه این داده مربوط به چه اتفاقیه. این مدل باعث میشه پروژه‌های بزرگ خیلی خواناتر بشن.


یک مثال واقعی

فرض کن داخل قیمت‌بان بخشی برای گفت‌وگوی آنلاین با پشتیبانی اضافه کردی. وقتی کاربر پیام جدیدی می‌فرسته، سرور می‌تونه Event زیر رو ارسال کنه:

new_message

کلاینت هم فقط به همین Event گوش میده. وقتی این Event دریافت شد، پیام جدید داخل صفحه چت نمایش داده میشه. اگر بعداً Event دیگری مثل:

typing

ارسال بشه، برنامه متوجه میشه که اپراتور در حال تایپ کردنه. همه این اتفاق‌ها روی همان اتصال دائمی انجام میشن.


اتصال مجدد خودکار

یکی از قابلیت‌هایی که باعث محبوبیت Socket.IO شده، Reconnect خودکاره. فرض کن اینترنت کاربر چند ثانیه قطع میشه. اگر از WebSocket خام استفاده کرده باشی، معمولاً باید خودت تشخیص بدی ارتباط قطع شده و دوباره اون رو برقرار کنی.

اما Socket.IO این کار رو به صورت پیش‌فرض انجام میده. هر زمان ارتباط از بین بره، خودش چند بار تلاش می‌کنه دوباره به سرور وصل بشه. این ویژگی باعث میشه برنامه‌نویس کد کمتری بنویسه.


Room یعنی چی؟

یکی دیگه از امکانات جالب Socket.IO مفهوم Room هست. فرض کن داخل قیمت‌بان هزار نفر آنلاین هستن، اما فقط پنجاه نفر صفحه یک محصول خاص رو باز کردن. اگر قیمت همون محصول تغییر کنه، لازم نیست پیام برای هر هزار نفر ارسال بشه. کافیه اون پنجاه نفر داخل یک Room باشن و سرور فقط برای اعضای همون Room پیام بفرسته. این قابلیت در برنامه‌های چت، بازی‌های آنلاین و سیستم‌های اعلان لحظه‌ای خیلی کاربرد داره.


Namespace چیه؟

گاهی یک سرور چند نوع ارتباط مختلف داره. مثلاً:

  • چت
  • اعلان‌ها
  • مانیتورینگ
  • پشتیبانی

Socket.IO اجازه میده این ارتباط‌ها از هم جدا بشن. به این بخش‌بندی Namespace میگن. در نتیجه لازم نیست همه پیام‌ها داخل یک کانال ردوبدل بشن.


استفاده از Socket.IO در اندروید

ابتدا کتابخونه Socket.IO به پروژه اضافه میشه. بعد اتصال به سرور ایجاد میشه.

val socket =
    IO.socket("https://example.com")

بعد ارتباط برقرار میشه.

socket.connect()

و حالا می‌تونیم منتظر Eventهای مختلف باشیم.

socket.on("price_changed") {

    // دریافت اطلاعات جدید

}

همون‌طور که می‌بینی، مدل برنامه‌نویسی بیشتر بر پایه Eventهاست تا ارسال و دریافت مستقیم پیام.


آیا قیمت‌بان به Socket.IO احتیاج داره؟

تقریباً خیر. اگر فقط می‌خوای کاهش قیمت یک کالا به کاربر اطلاع داده بشه، Firebase Cloud Messaging انتخاب بسیار مناسب‌تریه. اگر هم کاربر صفحه قیمت لحظه‌ای ارز یا طلا رو باز کرده، یک WebSocket معمولی هم می‌تونه کاملاً کافی باشه.

Socket.IO زمانی ارزش بیشتری پیدا می‌کنه که پروژه تعداد زیادی Event مختلف، کاربران آنلاین، گروه‌های مختلف یا ارتباط‌های پیچیده Real-time داشته باشه. برای مثال، اگر روزی داخل قیمت‌بان یک سیستم گفت‌وگوی آنلاین بین مشتری و فروشنده اضافه بشه، اون وقت Socket.IO می‌تونه انتخاب خیلی خوبی باشه.


معمولاً چه اشتباه‌هایی تو Socket.IO پیش میاد؟

یکی از اشتباه‌های رایج اینه که Socket.IO و WebSocket با هم اشتباه گرفته بشن. خیلی‌ها فکر می‌کنن هر کلاینت WebSocket می‌تونه به سرور Socket.IO وصل بشه یا برعکس، در حالی که این دو پروتکل ارتباطی متفاوتی دارن.

اشتباه بعدی اینه که بعد از بسته شدن صفحه، اتصال Socket.IO همچنان باز بمونه. اگر دیگه نیازی به دریافت اطلاعات لحظه‌ای نیست، بهتره ارتباط هم بسته بشه تا منابع دستگاه و سرور بیهوده مصرف نشن. بعضی توسعه‌دهنده‌ها هم از Socket.IO برای هر نوع ارتباطی استفاده می‌کنن، حتی زمانی که یک درخواست ساده HTTP کاملاً کافی بوده. این کار فقط پیچیدگی پروژه رو بیشتر می‌کنه و مزیت خاصی هم ایجاد نمی‌کنه.


جمع‌بندی

Socket.IO یک کتابخونه برای ساخت ارتباط‌های Real-timeه که امکانات بیشتری نسبت به WebSocket خام در اختیار برنامه‌نویس قرار میده. قابلیت‌هایی مثل اتصال مجدد خودکار، ارتباط مبتنی بر Event، Roomها و Namespaceها باعث میشن ساخت سیستم‌های بلادرنگ پیچیده خیلی ساده‌تر بشه.

با این حال، Socket.IO همیشه بهترین انتخاب نیست. برای پروژه‌ای مثل قیمت‌بان، در بیشتر بخش‌ها REST API، Firebase Cloud Messaging یا حتی WebSocket ساده کاملاً کافی هستن. Socket.IO زمانی ارزش واقعی خودش رو نشون میده که پروژه ارتباط‌های لحظه‌ای پیچیده، کاربران آنلاین زیاد و رویدادهای متنوع داشته باشه. یک برنامه‌نویس حرفه‌ای قبل از انتخاب ابزار، اول نیاز پروژه رو بررسی می‌کنه و بعد سراغ مناسب‌ترین فناوری میره، نه اینکه صرفاً چون یک ابزار امکانات بیشتری داره، از همون استفاده کنه.


درس چهل‌وششم

Live Update

اصلاً Live Update چیه؟

تا اینجا چند روش مختلف برای اطلاع‌رسانی به کاربر یاد گرفتیم. Notification برای زمانی بود که می‌خواستیم یک اتفاق رو به کاربر خبر بدیم. Badge فقط یادآوری می‌کرد که اتفاق جدیدی وجود داره. Foreground Service هم برای انجام کارهای طولانی بود که کاربر باید از اجرای اون‌ها باخبر می‌شد.

اما یک سؤال مهم پیش میاد. فرض کن یک عملیات چند دقیقه طول می‌کشه؛ مثلاً کاربر یک فایل بزرگ رو آپلود کرده یا یک سفر اینترنتی رو رزرو کرده و وضعیت سفارش لحظه‌به‌لحظه تغییر می‌کنه. آیا در چنین شرایطی باید هر بار یک Notification جدید ارسال کنیم؟ جواب معمولاً نه است.

اگر برای هر تغییر کوچک یک Notification جدید نمایش بدیم، خیلی زود نوار اعلان‌های کاربر پر از پیام‌های تکراری میشه. در چنین سناریوهایی چیزی که لازم داریم، به‌روزرسانی یک اعلان موجوده، نه ساختن اعلان‌های جدید. به این ایده، Live Update گفته میشه.


Live Update دقیقاً چیه؟

Live Update یعنی به جای اینکه برای هر تغییر یک اعلان جدید بسازی، همان اعلان قبلی را مرتب به‌روزرسانی کنی. فرض کن کاربر داخل قیمت‌بان یک خروجی اکسل از هزاران کالا درخواست کرده. تولید این فایل ممکنه چند دقیقه طول بکشه. اگر برای هر مرحله یک Notification جدید نمایش داده بشه، نتیجه چیزی شبیه این خواهد بود:

  • در حال آماده‌سازی فایل…
  • ۲۰ درصد انجام شد…
  • ۴۰ درصد انجام شد…
  • ۶۰ درصد انجام شد…
  • فایل آماده شد…

این تجربه اصلاً خوب نیست. در عوض، فقط یک Notification نمایش داده میشه و متن یا نوار پیشرفت همون اعلان تغییر می‌کنه. از دید کاربر، همه‌چیز مرتب‌تر و حرفه‌ای‌تر به نظر میاد.


Live Update با Notification چه فرقی داره؟

خیلی‌ها فکر می‌کنن Live Update یک نوع جدید از Notification هست، در حالی که این‌طور نیست. Live Update در واقع یک الگوی استفاده از Notification محسوب میشه. یعنی اعلان از بین نمیره و دوباره ساخته نمیشه، بلکه همون اعلان قبلی با اطلاعات جدید به‌روزرسانی میشه. مثلاً ابتدا نوشته:

در حال آپلود فایل…

چند ثانیه بعد همان Notification تغییر می‌کنه و میشه:

۵۰ درصد آپلود شد…

و در پایان تبدیل میشه به:

آپلود با موفقیت انجام شد.

کاربر فقط یک اعلان می‌بینه که محتوای اون تغییر می‌کنه.


چه جاهایی Live Update کاربرد داره؟

هر جایی که یک عملیات چند مرحله‌ای یا زمان‌بر وجود داشته باشه، Live Update می‌تونه انتخاب مناسبی باشه. مثلاً وقتی یک فایل بزرگ دانلود یا آپلود میشه، وقتی مسیر سفر آنلاین در حال تغییر وضعیته، وقتی یک سفارش در چند مرحله آماده میشه یا زمانی که یک عملیات طولانی در حال اجراست، به‌روزرسانی یک اعلان معمولاً تجربه خیلی بهتری نسبت به ارسال چندین اعلان جداگانه ایجاد می‌کنه.

وجه مشترک همه این مثال‌ها اینه که کاربر می‌خواد وضعیت همان عملیات رو دنبال کنه، نه اینکه هر چند لحظه یک اعلان جدید دریافت کنه.


آیا قیمت‌بان به Live Update احتیاج داره؟

در نسخه فعلی برنامه، احتمالاً نه. بیشتر اتفاق‌های قیمت‌بان لحظه‌ای هستن؛ مثلاً کاهش قیمت یک کالا یا اضافه شدن یک محصول جدید. برای چنین رویدادهایی یک Notification ساده کاملاً کافیه.

اما فرض کن بعداً قابلیتی اضافه کنیم که کاربر بتونه از هزاران کالا خروجی PDF یا Excel بگیره. اگر آماده شدن فایل دو یا سه دقیقه طول بکشه، Live Update می‌تونه انتخاب خیلی خوبی باشه. برنامه فقط یک Notification نمایش میده و هر بار که پیشرفت عملیات بیشتر میشه، همون اعلان به‌روزرسانی میشه.


چطوری یک Notification رو به‌روزرسانی کنیم؟

نکته جالب اینجاست که برای به‌روزرسانی Notification لازم نیست Notification جدیدی بسازیم. کافیه دوباره Notification رو با همان Notification ID ارسال کنیم.

notificationManager.notify(
    DOWNLOAD_NOTIFICATION_ID,
    updatedNotification
)

چون شناسه اعلان تغییر نکرده، اندروید اعلان قبلی رو حذف نمی‌کنه؛ فقط محتوای اون رو به‌روزرسانی می‌کنه. این یکی از ساده‌ترین و در عین حال پرکاربردترین تکنیک‌های Notification در اندرویده.


نمایش نوار پیشرفت

یکی از رایج‌ترین کاربردهای Live Update، نمایش Progress Bar هست. مثلاً وقتی ۴۰ درصد عملیات انجام شده:

builder.setProgress(
    100,
    40,
    false
)

و وقتی عملیات تموم شد:

builder.setProgress(
    0,
    0,
    false
)

بعد هم Notification با متن جدید دوباره ارسال میشه. کاربر احساس می‌کنه عملیات واقعاً در حال پیشرفته و لازم نیست هر چند ثانیه برنامه رو باز کنه.


هر چند وقت یک بار باید اعلان رو به‌روزرسانی کنیم؟

یکی از اشتباه‌های رایج اینه که برای هر یک درصد تغییر، Notification دوباره به‌روزرسانی بشه. فرض کن دانلود یک فایل از صفر تا صد درصد پیش میره. اگر صد بار Notification تغییر کنه، هم منابع بیشتری مصرف میشه و هم هیچ ارزش خاصی برای کاربر ایجاد نمی‌کنه. بهتره فقط زمانی اعلان به‌روزرسانی بشه که تغییر واقعاً قابل توجه باشه؛ مثلاً هر ده درصد یا وقتی مرحله مهمی از عملیات تموم شده باشه. هدف Live Update نمایش هر تغییر کوچیک نیست؛ هدف اینه که کاربر احساس کنه از وضعیت کلی عملیات باخبره.


Live Update چه فرقی با Foreground Service داره؟

گاهی این دو با هم اشتباه گرفته میشن. Foreground Service برای زمانی استفاده میشه که یک عملیات طولانی واقعاً در حال اجراست و اندروید باید اجازه بده اون عملیات ادامه پیدا کنه. در چنین شرایطی معمولاً Notification هم وجود داره.

اما Live Update فقط درباره به‌روزرسانی اطلاعات داخل همان Notification صحبت می‌کنه. خیلی وقت‌ها این دو کنار هم استفاده میشن. مثلاً یک Foreground Service مسئول آپلود فایل بزرگه و همزمان Notification مربوط به اون سرویس به صورت Live Update پیشرفت آپلود رو نمایش میده. اما از نظر مفهومی، این دو یکی نیستن.


معمولاً چه اشتباه‌هایی تو Live Update پیش میاد؟

یکی از اشتباه‌های رایج اینه که به جای به‌روزرسانی اعلان قبلی، هر بار یک Notification جدید ساخته بشه. نتیجه این کار شلوغ شدن نوار اعلان‌هاست. اشتباه بعدی اینه که عملیات تموم بشه، اما Notification همچنان وضعیت «در حال انجام» رو نشون بده. وقتی کار پایان پیدا کرد، یا باید متن اعلان به نتیجه نهایی تغییر کنه یا اگر دیگر ارزشی برای کاربر نداره، اعلان حذف بشه.

بعضی برنامه‌ها هم آن‌قدر زیاد Notification رو به‌روزرسانی می‌کنن که تغییرات برای کاربر آزاردهنده میشه. بهتره فقط تغییرات مهم نمایش داده بشن.


جمع‌بندی

Live Update یک قابلیت جدا از Notification نیست، بلکه یک روش هوشمندانه برای استفاده از Notificationهاست. به جای اینکه برای هر تغییر یک اعلان جدید ساخته بشه، همان اعلان قبلی به‌روزرسانی میشه و کاربر روند پیشرفت یک عملیات رو در یک جای ثابت دنبال می‌کنه.

در پروژه‌ای مثل قیمت‌بان شاید امروز کاربرد زیادی برای Live Update وجود نداشته باشه، اما اگر در آینده عملیات‌های زمان‌بری مثل تولید گزارش، خروجی گرفتن از اطلاعات یا آپلود فایل به برنامه اضافه بشن، این روش تجربه کاربری بسیار بهتری نسبت به ارسال چندین Notification جداگانه ایجاد می‌کنه. مهم‌ترین نکته اینه که Live Update برای نمایش روند یک عملیات طراحی شده، نه برای اطلاع‌رسانی درباره چند اتفاق مستقل.


درس چهل‌وهفتم

آنلاین و آفلاین بودن کاربران

اصلاً از کجا می‌فهمیم یک کاربر آنلاینه؟

فرض کن وارد تلگرام میشی و زیر اسم یکی از مخاطب‌ها نوشته:

Online

یا وارد واتساپ میشی و می‌بینی نوشته:

Last seen today at 10:35

شاید در نگاه اول فکر کنی سیستم‌عامل اندروید خودش این اطلاعات رو در اختیار برنامه‌ها میذاره، اما اصلاً این‌طور نیست. اندروید هیچ APIای نداره که بگه فلان کاربر آنلاینه یا آفلاینه. در واقع این اطلاعات کاملاً توسط سرور خود برنامه مدیریت میشن.

یعنی وقتی تلگرام میگه یک نفر آنلاینه، در حقیقت این سرور تلگرامه که تشخیص داده اون کاربر در حال حاضر به سرور متصله و این وضعیت رو به بقیه کاربران اطلاع میده. بنابراین آنلاین یا آفلاین بودن کاربران، بیشتر از اینکه یک قابلیت اندرویدی باشه، یک مفهوم در طراحی سیستم‌های سمت سروره.


اصلاً آنلاین بودن یعنی چی؟

این سؤال شاید ساده به نظر برسه، اما جوابش اون‌قدرها هم واضح نیست. فرض کن کاربر برنامه رو باز کرده و داخل صفحه اصلی قیمت‌بان قرار داره. طبیعتاً میشه گفت آنلاینه. اما اگر گوشی رو قفل کنه چی؟ اگر برنامه هنوز باز باشه ولی صفحه خاموش باشه چی؟ اگر اینترنت برای ده ثانیه قطع بشه چی؟ اگر برنامه در پس‌زمینه باشه چی؟

همه این سؤال‌ها نشون میدن که «آنلاین بودن» یک تعریف فنی ثابت نداره. هر پروژه باید خودش تصمیم بگیره دقیقاً چه زمانی یک کاربر آنلاین محسوب میشه.


سرور از کجا می‌فهمه کاربر هنوز آنلاینه؟

فرض کن قیمت‌بان از WebSocket استفاده می‌کنه. تا زمانی که ارتباط WebSocket برقرار باشه، سرور تقریباً مطمئنه که کاربر آنلاینه. اما اگر ارتباط قطع بشه، سرور متوجه میشه اتصال از بین رفته و وضعیت کاربر رو به آفلاین تغییر میده. اگر هم پروژه از WebSocket استفاده نکنه، معمولاً هر بار که برنامه یک درخواست HTTP برای سرور می‌فرسته، زمان آخرین فعالیت کاربر ثبت میشه. در این حالت شاید سرور تصمیم بگیره اگر مثلاً پنج دقیقه هیچ درخواستی از یک کاربر دریافت نکرد، اون رو آفلاین در نظر بگیره. بنابراین آنلاین بودن همیشه به معنی باز بودن برنامه نیست؛ بیشتر به این معنیه که سرور هنوز ارتباط مؤثری با کاربر داره.


آخرین زمان فعالیت (Last Seen)

خیلی از برنامه‌ها به جای اینکه فقط وضعیت آنلاین یا آفلاین رو نشون بدن، آخرین زمان حضور کاربر رو هم ذخیره می‌کنن. مثلاً:

  • آخرین بازدید ۲ دقیقه پیش
  • آخرین بازدید امروز ساعت ۱۴:۲۰
  • آخرین بازدید دیروز

این اطلاعات معمولاً خیلی مفیدتر از یک وضعیت ساده «Offline» هستن، چون کاربر بهتر متوجه میشه آخرین فعالیت طرف مقابل چه زمانی بوده. از نظر فنی هم پیاده‌سازی اون ساده است. هر بار که کاربر فعالیت مهمی انجام میده، سرور فقط زمان فعلی رو داخل دیتابیس ذخیره می‌کنه. اگر ارتباط قطع شد، همون زمان به عنوان Last Seen باقی می‌مونه.


آیا قیمت‌بان اصلاً به وضعیت آنلاین کاربران احتیاج داره؟

در نسخه فعلی برنامه، تقریباً نه. قیمت‌بان یک فروشگاه یا پیام‌رسان نیست که کاربران بخوان وضعیت حضور همدیگه رو ببینن. بنابراین نمایش Online یا Offline هیچ ارزش خاصی برای کاربر ایجاد نمی‌کنه.

اما فرض کن در آینده یک بخش گفت‌وگوی آنلاین بین مشتری و فروشنده اضافه کنیم. در اون صورت خیلی مفید میشه اگر مشتری بدونه فروشنده الآن آنلاینه یا آخرین بار چه زمانی داخل برنامه بوده. یا اگر یک سیستم پشتیبانی آنلاین داشته باشیم، نمایش وضعیت آنلاین اپراتورها می‌تونه تجربه کاربری خیلی بهتری ایجاد کنه.


اگر اینترنت ناگهان قطع بشه چی؟

یکی از چالش‌های مهم اینه که همیشه فرصت خداحافظی وجود نداره. فرض کن کاربر داخل برنامه است و ناگهان اینترنت قطع میشه یا باتری گوشی تموم میشه. در این حالت برنامه اصلاً فرصت نمی‌کنه به سرور بگه:

«من دارم خارج میشم.»
پس سرور از کجا باید بفهمه کاربر رفته؟

راه‌حل اینه که سرور فقط به پیام «خروج» اعتماد نکنه. معمولاً اگر ارتباط WebSocket قطع بشه یا تا مدت مشخصی هیچ فعالیتی از کاربر دیده نشه، سرور خودش وضعیت اون کاربر رو به آفلاین تغییر میده. به همین دلیل در سیستم‌های حرفه‌ای، قطع شدن ارتباط از خود پیام خروج مهم‌تره.


Heartbeat یعنی چی؟

فرض کن دو نفر با بی‌سیم با هم در ارتباط هستن. اگر چند دقیقه هیچ صدایی از طرف مقابل نشنوی، از کجا بفهمی هنوز پشت دستگاه هست یا ارتباط قطع شده؟ در سیستم‌های آنلاین هم دقیقاً همین مشکل وجود داره.

برای حل این مسئله، خیلی از سرورها هر چند ثانیه یک پیام خیلی کوچک بین خودشون و کلاینت ردوبدل می‌کنن. به این پیام‌های دوره‌ای Heartbeat میگن. تا وقتی Heartbeat دریافت میشه، سرور می‌دونه ارتباط هنوز زنده است. اما اگر چند بار پشت سر هم این پیام‌ها نرسن، نتیجه می‌گیره که اتصال از بین رفته و کاربر رو آفلاین اعلام می‌کنه.

دقت کن که Heartbeat فقط برای بررسی سلامت ارتباطه و معمولاً هیچ اطلاعات مهمی داخلش ردوبدل نمیشه.


آیا همیشه باید وضعیت آنلاین دقیق باشه؟

نه. خیلی از برنامه‌ها عمداً وضعیت آنلاین رو با کمی تأخیر نمایش میدن. مثلاً ممکنه کاربر پنج ثانیه پیش از برنامه خارج شده باشه، اما هنوز Online دیده بشه. این کار باعث میشه نوسان‌های کوتاه اینترنت یا قطع و وصل شدن‌های لحظه‌ای، مرتب وضعیت کاربر رو تغییر ندن.

گاهی هم این تأخیر به دلایل مربوط به حریم خصوصی ایجاد میشه. بعضی برنامه‌ها اصلاً وضعیت آنلاین رو لحظه‌ای نمایش نمیدن تا اطلاعات دقیق‌تری از رفتار کاربران فاش نشه.


وضعیت آنلاین رو کجا ذخیره کنیم؟

یکی از اشتباه‌های رایج اینه که وضعیت آنلاین فقط داخل حافظه گوشی نگهداری بشه. این روش هیچ فایده‌ای نداره، چون بقیه کاربران باید بدونن یک نفر آنلاینه یا نه. بنابراین این اطلاعات باید روی سرور نگهداری بشه.

معمولاً برای هر کاربر اطلاعاتی شبیه این وجود داره:

  • آیا الآن آنلاین است؟
  • آخرین زمان فعالیت چه موقع بوده؟
  • آخرین Heartbeat چه زمانی دریافت شده؟

وقتی کاربر وارد برنامه میشه یا ارتباطش برقرار میشه، سرور این اطلاعات رو به‌روزرسانی می‌کنه.


معمولاً چه اشتباه‌هایی تو مدیریت کاربران آنلاین پیش میاد؟

یکی از اشتباه‌های رایج اینه که فقط هنگام خروج برنامه وضعیت کاربر به آفلاین تغییر داده بشه. در دنیای واقعی خیلی وقت‌ها برنامه اصلاً فرصت ارسال پیام خروج رو پیدا نمی‌کنه و سرور باید خودش قطع شدن ارتباط رو تشخیص بده.

اشتباه بعدی اینه که وضعیت آنلاین فقط بر اساس باز بودن Activity تعیین بشه. ممکنه Activity بسته شده باشه، اما ارتباط WebSocket هنوز برقرار باشه، یا برعکس Activity باز باشه ولی اینترنت قطع شده باشه. بنابراین وضعیت آنلاین باید بر اساس ارتباط واقعی با سرور تعیین بشه، نه صرفاً وضعیت رابط کاربری.

بعضی توسعه‌دهنده‌ها هم وضعیت آنلاین رو بیش از حد دقیق نمایش میدن و با هر قطع و وصل شدن کوتاه اینترنت، کاربر بین Online و Offline جابه‌جا میشه. این رفتار هم تجربه کاربری خوبی ایجاد نمی‌کنه و هم باعث ارسال پیام‌های اضافی به همه کاربران میشه.


جمع‌بندی

آنلاین یا آفلاین بودن کاربران یک قابلیت اندرویدی نیست، بلکه بخشی از طراحی سیستم‌های سمت سروره. سرور باید بر اساس ارتباطی که با هر کاربر داره، تشخیص بده آیا اون کاربر هنوز متصله یا نه و در صورت نیاز، آخرین زمان فعالیتش رو هم ذخیره کنه.

در پروژه‌ای مثل GheimatBan احتمالاً تا زمانی که قابلیت‌هایی مثل گفت‌وگوی آنلاین یا پشتیبانی زنده اضافه نشده باشن، نیازی به مدیریت وضعیت آنلاین کاربران وجود نداره. اما اگر روزی چنین امکاناتی به برنامه اضافه بشن، یکی از مهم‌ترین نکته‌ها اینه که وضعیت آنلاین رو بر اساس اتصال واقعی به سرور مدیریت کنی، نه صرفاً بر اساس باز یا بسته بودن برنامه. این تفاوت کوچک، یکی از چیزهاییه که سیستم‌های حرفه‌ای رو از پیاده‌سازی‌های ساده جدا می‌کنه.


درس چهل‌وهشتم

نمایش وضعیت اتصال

اصلاً چرا باید وضعیت اتصال رو داخل برنامه نمایش بدیم؟

فرض کن کاربر داخل قیمت‌بان وارد صفحه قیمت یک کالا شده و روی دکمه «به‌روزرسانی قیمت» میزنه. اینترنت چند لحظه قبل قطع شده، اما برنامه هیچ پیامی نمایش نمیده و دکمه هم ظاهراً واکنشی نداره. کاربر نمی‌دونه درخواست در حال انجامه، سرور جواب نمیده، اینترنت خودش قطع شده یا برنامه هنگ کرده. ممکنه چند بار پشت سر هم روی دکمه بزنه، صفحه رو ببنده یا حتی برنامه رو خراب تصور کنه. نمایش وضعیت اتصال دقیقاً برای جلوگیری از همین سردرگمیه. برنامه باید به کاربر بگه که در حال حاضر به اینترنت دسترسی نداره و بعضی اطلاعات ممکنه قدیمی باشن. البته این پیام نباید به شکل یک Dialog بزرگ و آزاردهنده ظاهر بشه؛ معمولاً یک نوار باریک بالای صفحه یا یک پیام کوچک داخل رابط کاربری کافی است.

در این درس فقط درباره ظاهر پیام حرف نمی‌زنیم، بلکه یک سیستم واقعی می‌سازیم که تغییرات شبکه رو از اندروید دریافت می‌کنه، وضعیت رو داخل StateFlow قرار میده و رابط کاربری Compose رو به صورت خودکار به‌روزرسانی می‌کنه. نتیجه کار اینه که وقتی اینترنت قطع میشه، یک نوار قرمز بالای صفحه ظاهر میشه و وقتی اتصال دوباره برمی‌گرده، پیام «اتصال برقرار شد» نمایش داده میشه. برای این کار از ConnectivityManager، کلاس NetworkCallback و قابلیت‌های NetworkCapabilities استفاده می‌کنیم. اندروید با NetworkCallback تغییرات شبکه رو به برنامه اطلاع میده و برای متوقف کردن این دریافت هم باید Callback رو با unregisterNetworkCallback آزاد کنیم.

وصل بودن به Wi-Fi با داشتن اینترنت یکی نیست

قبل از نوشتن کد باید یک تفاوت مهم رو بفهمیم. ممکنه گوشی به Wi-Fi وصل باشه، اما اینترنت واقعی نداشته باشه. مثلاً مودم روشنه ولی خط اینترنتش قطع شده، یا کاربر به Wi-Fi یک هتل وصل شده اما هنوز صفحه ورود شبکه رو تکمیل نکرده. بنابراین نباید فقط نوع شبکه رو بررسی کنیم و با دیدن Wi-Fi یا اینترنت همراه بگیم کاربر آنلاینه. داخل NetworkCapabilities قابلیتی به نام NET_CAPABILITY_INTERNET وجود داره که میگه این شبکه اصولاً برای دسترسی به اینترنت پیکربندی شده، اما همین قابلیت هم تضمین نمی‌کنه اینترنت واقعاً کار می‌کنه.

قابلیت مهم‌تر NET_CAPABILITY_VALIDATED است. وقتی این مقدار وجود داشته باشه، یعنی سیستم‌عامل اتصال شبکه به اینترنت عمومی رو بررسی کرده و اون رو معتبر تشخیص داده. پس برای نمایش وضعیت اتصال بهتره هر دو قابلیت INTERNET و VALIDATED رو بررسی کنیم. با این حال حتی VALIDATED هم به معنی سالم بودن سرور خود قیمت‌بان نیست؛ ممکنه اینترنت کار کنه اما API پروژه از دسترس خارج شده باشه. پس وضعیت اتصال اینترنت و وضعیت سرور دو مفهوم جدا هستن و بعداً درباره مدیریت خطاهای API هم صحبت می‌کنیم.

اول مجوز بررسی شبکه رو اضافه می‌کنیم

برای اینکه برنامه بتونه وضعیت شبکه رو بررسی کنه، باید مجوز ACCESS_NETWORK_STATE رو داخل فایل AndroidManifest.xml قرار بدیم. این مجوز از نوع معمولیه و برخلاف مجوزهایی مثل دوربین یا موقعیت مکانی، لازم نیست هنگام اجرای برنامه از کاربر اجازه بگیریم. فقط کافیه اون رو بیرون از تگ application بنویسیم.

<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

    <application
        android:name=".GheimatBanApplication"
        android:theme="@style/Theme.GheimatBan">

        <!-- Activities -->

    </application>

</manifest>

بدون این مجوز، دسترسی به بعضی متدهای ConnectivityManager ممکن نیست. دقت کن که برای تشخیص وضعیت اتصال لازم نیست مجوز INTERNET رو جایگزین این مورد کنی؛ INTERNET اجازه ارسال درخواست شبکه رو میده، اما ACCESS_NETWORK_STATE اجازه بررسی وضعیت شبکه رو فراهم می‌کنه. در یک برنامه متصل به سرور معمولاً هر دو مجوز وجود دارن.

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

وضعیت اتصال رو با چند حالت مشخص تعریف می‌کنیم

می‌تونیم وضعیت رو فقط با یک مقدار Boolean نگه داریم؛ یعنی true برای آنلاین و false برای آفلاین. این روش در پروژه‌های خیلی ساده جواب میده، اما یک مشکل داره: وقتی برنامه تازه اجرا شده و هنوز وضعیت شبکه رو تشخیص نداده، نمی‌دونیم باید مقدار اولیه رو آنلاین در نظر بگیریم یا آفلاین. اگه مقدار اولیه رو اشتباه انتخاب کنیم، ممکنه برای کسری از ثانیه پیام نادرستی روی صفحه دیده بشه. برای همین بهتره به جای Boolean یک مدل چندحالته بسازیم.

enum class ConnectionStatus {
    CHECKING,
    AVAILABLE,
    UNAVAILABLE
}

حالت CHECKING یعنی هنوز در حال بررسی اتصال هستیم. حالت AVAILABLE یعنی شبکه معتبر و دارای دسترسی اینترنت تشخیص داده شده و UNAVAILABLE یعنی در حال حاضر چنین اتصالی وجود نداره. بعداً اگه بخوایم وضعیت‌های دقیق‌تری مثل «اینترنت برقرار ولی سرور قطع است» یا «در حال اتصال مجدد» داشته باشیم، می‌تونیم همین مدل رو گسترش بدیم.

کلاس اصلی بررسی اتصال رو می‌سازیم

حالا یک کلاس به نام ConnectionObserver می‌سازیم. وظیفه این کلاس اینه که تغییرات شبکه رو دریافت کنه و آخرین وضعیت رو داخل یک StateFlow قرار بده. دلیل استفاده از StateFlow اینه که همیشه آخرین مقدار رو نگه می‌داره و هر صفحه‌ای که به اون گوش بده، به محض تغییر وضعیت به‌روزرسانی میشه. این کلاس نباید به یک Activity خاص وابسته باشه، چون وضعیت اتصال یک اطلاعات عمومی برای کل برنامه است.

package com.example.gheimatban.network

import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

class ConnectionObserver(context: Context) {

    private val connectivityManager =
        context.applicationContext.getSystemService(
            Context.CONNECTIVITY_SERVICE
        ) as ConnectivityManager

    private val _status = MutableStateFlow(ConnectionStatus.CHECKING)
    val status: StateFlow<ConnectionStatus> = _status.asStateFlow()

    private var isRegistered = false

    private val networkCallback =
        object : ConnectivityManager.NetworkCallback() {

            override fun onCapabilitiesChanged(
                network: Network,
                networkCapabilities: NetworkCapabilities
            ) {
                _status.value =
                    if (networkCapabilities.hasInternetAccess()) {
                        ConnectionStatus.AVAILABLE
                    } else {
                        ConnectionStatus.UNAVAILABLE
                    }
            }

            override fun onLost(network: Network) {
                _status.value = ConnectionStatus.UNAVAILABLE
            }

            override fun onUnavailable() {
                _status.value = ConnectionStatus.UNAVAILABLE
            }
        }

    fun start() {
        if (isRegistered) return

        updateCurrentStatus()
        connectivityManager.registerDefaultNetworkCallback(networkCallback)
        isRegistered = true
    }

    fun stop() {
        if (!isRegistered) return

        connectivityManager.unregisterNetworkCallback(networkCallback)
        isRegistered = false
    }

    private fun updateCurrentStatus() {
        val activeNetwork = connectivityManager.activeNetwork

        if (activeNetwork == null) {
            _status.value = ConnectionStatus.UNAVAILABLE
            return
        }

        val capabilities =
            connectivityManager.getNetworkCapabilities(activeNetwork)

        _status.value =
            if (capabilities?.hasInternetAccess() == true) {
                ConnectionStatus.AVAILABLE
            } else {
                ConnectionStatus.UNAVAILABLE
            }
    }

    private fun NetworkCapabilities.hasInternetAccess(): Boolean {
        val hasInternet =
            hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)

        val isValidated =
            hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)

        return hasInternet && isValidated
    }
}

داخل start() اول وضعیت فعلی رو بررسی می‌کنیم، چون نمی‌خوایم منتظر بمونیم تا یک تغییر شبکه اتفاق بیفته. بعد registerDefaultNetworkCallback رو صدا می‌زنیم تا از تغییرات شبکه پیش‌فرض برنامه باخبر بشیم. وقتی قابلیت‌های شبکه تغییر می‌کنن، متد onCapabilitiesChanged اجرا میشه و ما INTERNET و VALIDATED رو بررسی می‌کنیم. وقتی شبکه از دست میره، onLost اجرا میشه و وضعیت رو آفلاین قرار میدیم. داخل Callback بهتره وضعیت رو از آرگومان‌های خود Callback بخونیم و دوباره متدهای هم‌زمان ConnectivityManager رو صدا نزنیم، چون مستندات اندروید درباره احتمال ایجاد Race Condition در این کار هشدار داده.

متغیر isRegistered هم جلوی ثبت چندباره Callback رو می‌گیره. اگه یک Callback رو بارها ثبت کنیم ولی آزاد نکنیم، هم احتمال نشت منابع وجود داره و هم برنامه ممکنه چند بار یک تغییر واحد رو دریافت کنه. خود اندروید هم تعداد درخواست‌ها و Callbackهای باز هر برنامه رو محدود می‌کنه، بنابراین مدیریت درست ثبت و حذف اون ضروریه.

این کلاس رو در سطح کل برنامه نگه می‌داریم

حالا باید تصمیم بگیریم ConnectionObserver کجا ساخته بشه. اگه اون رو داخل هر صفحه بسازیم، هر صفحه یک Callback مستقل ثبت می‌کنه و دوباره به همون مشکل تکرار و ناهماهنگی می‌رسیم. برای نسخه ساده پروژه، می‌تونیم این کلاس رو داخل Application بسازیم تا فقط یک نمونه در کل قیمت‌بان وجود داشته باشه. در پروژه‌های بزرگ‌تر معمولاً همین وابستگی رو با Hilt یا یک ابزار Dependency Injection در اختیار ViewModelها قرار میدیم.

package com.example.gheimatban

import android.app.Application
import com.example.gheimatban.network.ConnectionObserver

class GheimatBanApplication : Application() {

    lateinit var connectionObserver: ConnectionObserver
        private set

    override fun onCreate() {
        super.onCreate()

        connectionObserver = ConnectionObserver(this)
        connectionObserver.start()
    }
}

نام این کلاس رو قبلاً داخل Manifest در ویژگی android:name قرار دادیم. با اجرای برنامه، Application ساخته میشه و دریافت تغییرات اتصال شروع میشه. چون این Observer برای کل عمر پردازش برنامه استفاده میشه، لازم نیست اون رو با بسته شدن هر صفحه متوقف کنیم. اما اگه Observer رو داخل Activity، Fragment یا یک کامپوننت کوتاه‌عمر می‌ساختیم، حتماً باید در زمان مناسب stop رو هم صدا می‌زدیم تا Callback آزاد بشه.

وضعیت رو از طریق ViewModel به رابط کاربری می‌رسونیم

حالا یک ViewModel می‌سازیم که وضعیت اتصال رو در اختیار صفحه اصلی قرار بده. ViewModel نباید خودش هر بار ConnectivityManager جدیدی بسازه؛ فقط باید به همان Observer مرکزی متصل بشه. برای ساده نگه داشتن کد، نمونه ConnectionObserver رو از Application دریافت می‌کنیم.

package com.example.gheimatban.ui.home

import androidx.lifecycle.ViewModel
import com.example.gheimatban.network.ConnectionObserver
import com.example.gheimatban.network.ConnectionStatus
import kotlinx.coroutines.flow.StateFlow

class HomeViewModel(
    connectionObserver: ConnectionObserver
) : ViewModel() {

    val connectionStatus: StateFlow<ConnectionStatus> =
        connectionObserver.status
}

چون ViewModel ورودی داره، برای ساختنش یک Factory هم لازم داریم.

package com.example.gheimatban.ui.home

import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.example.gheimatban.network.ConnectionObserver

class HomeViewModelFactory(
    private val connectionObserver: ConnectionObserver
) : ViewModelProvider.Factory {

    override fun <T : ViewModel> create(
        modelClass: Class<T>
    ): T {
        if (modelClass.isAssignableFrom(HomeViewModel::class.java)) {
            @Suppress("UNCHECKED_CAST")
            return HomeViewModel(connectionObserver) as T
        }

        throw IllegalArgumentException(
            "Unknown ViewModel class: ${modelClass.name}"
        )
    }
}

این Factory فقط برای اینه که وابستگی ConnectionObserver رو هنگام ساخت ViewModel وارد کنیم. بعداً وقتی Hilt رو یاد بگیریم، بخش زیادی از این کد به صورت خودکار مدیریت میشه، اما فعلاً خوبه سازوکار واقعی ورود وابستگی به ViewModel رو ببینیم.

نوار وضعیت اتصال رو در Compose می‌سازیم

حالا به بخش قابل مشاهده درس می‌رسیم. یک Composable به نام ConnectionBanner می‌سازیم که براساس وضعیت اتصال تصمیم می‌گیره چه چیزی نمایش بده. در حالت UNAVAILABLE یک نوار هشدار نمایش داده میشه. در حالت CHECKING یک پیام کوتاه برای بررسی اتصال می‌بینیم و در حالت AVAILABLE چیزی نمایش نمیدیم تا رابط کاربری بی‌دلیل شلوغ نشه.

package com.example.gheimatban.ui.components

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.gheimatban.network.ConnectionStatus

@Composable
fun ConnectionBanner(
    status: ConnectionStatus,
    modifier: Modifier = Modifier
) {
    when (status) {
        ConnectionStatus.CHECKING -> {
            Box(
                modifier = modifier
                    .fillMaxWidth()
                    .background(
                        MaterialTheme.colorScheme.secondaryContainer
                    )
                    .padding(
                        horizontal = 16.dp,
                        vertical = 8.dp
                    ),
                contentAlignment = Alignment.Center
            ) {
                Text(
                    text = "در حال بررسی اتصال اینترنت..."
                )
            }
        }

        ConnectionStatus.UNAVAILABLE -> {
            Box(
                modifier = modifier
                    .fillMaxWidth()
                    .background(
                        MaterialTheme.colorScheme.errorContainer
                    )
                    .padding(
                        horizontal = 16.dp,
                        vertical = 8.dp
                    ),
                contentAlignment = Alignment.Center
            ) {
                Text(
                    text = "اینترنت قطع است؛ اطلاعات ممکن است به‌روز نباشند.",
                    color = MaterialTheme.colorScheme.onErrorContainer
                )
            }
        }

        ConnectionStatus.AVAILABLE -> Unit
    }
}

به جای اینکه رنگ‌ها رو مستقیم با کد هگز بنویسیم، از رنگ‌های Theme استفاده کردیم تا Banner با حالت روشن و تاریک برنامه هماهنگ بمونه. متن پیام هم فقط نمیگه «اینترنت قطع است»، بلکه اثر این اتفاق رو برای کاربر توضیح میده؛ یعنی ممکنه قیمت‌هایی که می‌بینه آخرین اطلاعات سرور نباشن. این نوع پیام برای قیمت‌بان مفیدتر از یک هشدار فنی و مبهمه.

وضعیت رو داخل صفحه نمایش میدیم

داخل MainActivity یا صفحه اصلی، ViewModel رو می‌سازیم و مقدار StateFlow رو با collectAsStateWithLifecycle دریافت می‌کنیم. این متد مقدار Flow رو متناسب با Lifecycle صفحه جمع‌آوری می‌کنه و وقتی صفحه در وضعیت فعال قرار نداره، جمع‌آوری رو متوقف می‌کنه. هر بار مقدار Flow تغییر کنه، Compose هم بخش‌هایی از رابط کاربری که از اون مقدار استفاده کردن دوباره می‌سازه.

package com.example.gheimatban

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.gheimatban.ui.components.ConnectionBanner
import com.example.gheimatban.ui.home.HomeViewModel
import com.example.gheimatban.ui.home.HomeViewModelFactory

class MainActivity : ComponentActivity() {

    private val viewModel: HomeViewModel by viewModels {
        val app = application as GheimatBanApplication

        HomeViewModelFactory(
            connectionObserver = app.connectionObserver
        )
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            val connectionStatus by
                viewModel.connectionStatus.collectAsStateWithLifecycle()

            Column {
                ConnectionBanner(
                    status = connectionStatus
                )

                Text(
                    text = "لیست کالاهای GheimatBan"
                )

                // محتوای اصلی صفحه
            }
        }
    }
}

حالا اگه هنگام اجرای برنامه اینترنت رو خاموش کنی، مقدار StateFlow تغییر می‌کنه، Compose متوجه تغییر میشه و Banner رو نمایش میده. وقتی اینترنت دوباره برقرار بشه، مقدار به AVAILABLE تغییر می‌کنه و Banner ناپدید میشه. هیچ نیازی به Refresh دستی صفحه، بررسی مداوم با Timer یا اجرای یک حلقه بی‌پایان نداریم؛ سیستم‌عامل تغییر شبکه رو به برنامه خبر میده.

قطع بودن اینترنت نباید همه‌چیز رو نابود کنه

نمایش Banner فقط نیمی از کاره. برنامه خوب نباید به محض قطع شدن اینترنت کل صفحه رو خالی کنه. فرض کن کاربر قبلاً قیمت چند کالا رو دریافت کرده و این اطلاعات داخل Room ذخیره شدن. وقتی اینترنت قطع میشه، بهتره همون اطلاعات قبلی همچنان نمایش داده بشن و فقط Banner توضیح بده که ممکنه قیمت‌ها به‌روز نباشن. این رویکرد به طراحی Offline-first نزدیکه؛ یعنی رابط کاربری تا جای ممکن از دیتابیس محلی تغذیه میشه و اینترنت بیشتر برای همگام‌سازی اطلاعات جدید استفاده میشه.

همین موضوع درباره عملیات کاربر هم صدق می‌کنه. مثلاً اگه کاربر در حالت آفلاین کالایی رو به علاقه‌مندی‌ها اضافه کرد، می‌تونیم این تغییر رو محلی ذخیره کنیم و بعد از برگشت اتصال با سرور هماهنگ کنیم. البته همه عملیات‌ها قابل انجام در حالت آفلاین نیستن. مثلاً دریافت قیمت لحظه‌ای بدون سرور ممکن نیست، بنابراین دکمه «به‌روزرسانی قیمت» باید خطای واضحی نمایش بده یا تا زمان برگشت اتصال غیرفعال بشه. نکته اصلی اینه که وضعیت اتصال نباید فقط یک متن تزئینی باشه؛ رفتار واقعی صفحه هم باید با اون هماهنگ بشه.

برای نمونه، می‌تونیم دکمه دریافت قیمت جدید رو براساس وضعیت اتصال کنترل کنیم.

Button(
    onClick = {
        viewModel.refreshPrices()
    },
    enabled = connectionStatus == ConnectionStatus.AVAILABLE
) {
    Text(
        text = if (
            connectionStatus == ConnectionStatus.AVAILABLE
        ) {
            "به‌روزرسانی قیمت‌ها"
        } else {
            "اتصال اینترنت برقرار نیست"
        }
    )
}

با این کار کاربر نمی‌تونه در حالت آفلاین درخواست بی‌نتیجه‌ای ارسال کنه. با این حال نباید تمام دکمه‌های برنامه رو صرفاً به خاطر قطع اینترنت غیرفعال کنیم؛ فقط عملیات‌هایی باید محدود بشن که واقعاً به ارتباط لحظه‌ای با سرور نیاز دارن.

آیا این کد سالم بودن سرور رو هم تشخیص میده؟

نه. این سیستم فقط میگه دستگاه به اینترنت عمومی دسترسی معتبر داره. ممکنه وضعیت AVAILABLE باشه اما سرور قیمت‌بان خطای ۵۰۰ بده، برای تعمیرات خاموش شده باشه یا دامنه پروژه در دسترس نباشه. بنابراین خطاهای شبکه رو باید در لایه Repository هم مدیریت کنیم. مثلاً اگه درخواست Retrofit به خاطر Timeout یا خطای سرور شکست خورد، نباید فوراً نتیجه بگیریم اینترنت کاربر قطعه. بهتره پیام متناسب با نوع خطا نمایش بدیم: «ارتباط اینترنت برقرار نیست»، «سرور موقتاً پاسخ نمیدهد» یا «دریافت اطلاعات با خطا روبه‌رو شد».

همچنین لازم نیست برای اطمینان از اتصال، هر چند ثانیه یک درخواست آزمایشی به گوگل یا سایت دیگری بفرستیم. این کار هم باتری و اینترنت مصرف می‌کنه و هم ممکنه نتیجه گمراه‌کننده بده؛ چون در دسترس بودن یک سایت خارجی چیزی درباره سالم بودن API خود پروژه ثابت نمی‌کنه. ConnectivityManager برای نمایش وضعیت عمومی اتصال مناسبه و نتیجه درخواست واقعی API هم وضعیت سرویس خودمون رو مشخص می‌کنه.

جمع‌بندی

برای نمایش وضعیت اتصال در قیمت‌بان یک ConnectionObserver ساختیم که از ConnectivityManager.NetworkCallback استفاده می‌کنه. این کلاس با بررسی NET_CAPABILITY_INTERNET و NET_CAPABILITY_VALIDATED تشخیص میده شبکه فعلی دسترسی معتبر به اینترنت داره یا نه و نتیجه رو داخل StateFlow قرار میده. بعد ViewModel این وضعیت رو به رابط کاربری رسوند و Compose با collectAsStateWithLifecycle تغییرات رو دریافت کرد. در نهایت یک Banner ساختیم که هنگام قطع شدن اینترنت ظاهر میشه و به کاربر توضیح میده اطلاعات ممکنه به‌روز نباشن.

اما مهم‌ترین نکته این درس فقط ساختن یک نوار هشدار نبود. وضعیت اتصال باید روی رفتار واقعی برنامه اثر بذاره؛ اطلاعات قبلی تا جای ممکن باقی بمونن، عملیات وابسته به اینترنت کنترل بشن و خطای اینترنت با خطای سرور اشتباه گرفته نشه. وقتی این بخش درست طراحی بشه، کاربر حتی هنگام قطع و وصل شدن شبکه هم احساس می‌کنه برنامه شرایط رو درک کرده و کنترل اوضاع رو از دست نداده.


بخش هفتم — امنیت

درس چهل‌ونهم

امنیت API

اصلاً چرا API برنامه باید امن باشه؟

فرض کن کاربر داخل قیمت‌بان وارد حسابش میشه، کالاهای موردعلاقه‌اش رو می‌بینه و برای بعضی محصولات هشدار کاهش قیمت می‌سازه. تمام این اطلاعات از طریق API بین برنامه و سرور جابه‌جا میشن. حالا اگه این ارتباط درست محافظت نشده باشه، ممکنه یک نفر اطلاعات کاربران رو بخونه، درخواست‌ها رو تغییر بده، خودش رو جای کاربر دیگری جا بزنه یا هزاران درخواست جعلی به سرور بفرسته. بنابراین امنیت API فقط این نیست که آدرس سرور رو مخفی کنیم یا یک رشته عجیب به نام API Key داخل برنامه بذاریم. امنیت واقعی از چند لایه تشکیل میشه: ارتباط باید رمزنگاری‌شده باشه، کاربر باید احراز هویت بشه، سرور باید برای هر درخواست مجوز دسترسی رو بررسی کنه، توکن‌ها باید درست نگهداری بشن و رفتارهای مشکوک هم محدود بشن.

یک نکته خیلی مهم اینه که برنامه اندروید روی گوشی کاربر اجرا میشه و کنترل کامل اون گوشی دست ما نیست. کاربر می‌تونه فایل APK رو استخراج کنه، کدها رو مهندسی معکوس کنه، درخواست‌های شبکه رو بررسی کنه یا حتی نسخه دست‌کاری‌شده برنامه رو اجرا کنه. پس نباید هیچ‌وقت امنیت سرور رو بر این فرض بنا کنیم که «درخواست حتماً از اپلیکیشن اصلی ما ارسال شده». مستندات امنیتی اندروید هم تأکید می‌کنن که یک کلید ثابت داخل اپ موبایل می‌تونه از طریق مهندسی معکوس استخراج بشه و برای دسترسی غیرمجاز به API مورد استفاده قرار بگیره.

HTTPS اولین لایه امنیته، نه کل امنیت

اولین کاری که برای امنیت ارتباط انجام میدیم، استفاده از HTTPS به جای HTTP است. وقتی از HTTP ساده استفاده می‌کنیم، داده‌ها به شکل رمزنگاری‌نشده در شبکه حرکت می‌کنن. یعنی اگه کاربر به یک Wi-Fi عمومی و ناامن وصل شده باشه، شخص دیگری در همون شبکه ممکنه اطلاعاتی مثل توکن، نام کاربری یا پاسخ‌های API رو مشاهده یا دست‌کاری کنه. HTTPS با استفاده از TLS ارتباط بین برنامه و سرور رو رمزنگاری می‌کنه و همچنین کمک می‌کنه برنامه مطمئن بشه با سرور واقعی ارتباط گرفته، نه یک سرور جعلی در وسط مسیر.

با این حال HTTPS به تنهایی کافی نیست. HTTPS فقط مسیر ارتباط رو امن می‌کنه؛ اما مشخص نمی‌کنه کاربر اجازه دیدن کدوم اطلاعات رو داره. اگه API به هر کسی که یک شناسه کالا یا کاربر می‌فرسته اطلاعات رو تحویل بده، همچنان مشکل امنیتی داریم. به همین دلیل HTTPS رو باید اولین دیوار دفاعی بدونیم، نه تنها دیوار. در قیمت‌بان تمام آدرس‌های Retrofit باید با https:// شروع بشن و در نسخه نهایی برنامه هیچ Endpoint عمومی‌ای نباید با HTTP کار کنه.

می‌تونیم اندروید رو طوری تنظیم کنیم که حتی اگه یک برنامه‌نویس اشتباهی آدرس HTTP وارد کرد، برنامه اجازه برقراری اون ارتباط رو نده. برای این کار داخل پوشه res/xml فایلی به نام network_security_config.xml می‌سازیم.

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <base-config cleartextTrafficPermitted="false" />
</network-security-config>

بعد این فایل رو داخل AndroidManifest.xml به برنامه معرفی می‌کنیم.

<application
    android:name=".GheimatBanApplication"
    android:networkSecurityConfig="@xml/network_security_config"
    android:theme="@style/Theme.GheimatBan">

    <!-- Activities -->

</application>

ویژگی cleartextTrafficPermitted="false" یعنی ارتباط رمزنگاری‌نشده نباید مجاز باشه. اندروید از Network Security Configuration برای تنظیم مواردی مثل جلوگیری از Cleartext Traffic، تعیین گواهی‌های مورداعتماد و تنظیمات مربوط به امنیت TLS استفاده می‌کنه. از اندروید ۹ به بعد هم Cleartext Traffic برای برنامه‌هایی که API سطح ۲۸ یا بالاتر رو هدف قرار میدن، به صورت پیش‌فرض غیرفعاله؛ اما تعریف صریح این فایل همچنان باعث میشه سیاست امنیتی پروژه واضح و قابل‌کنترل باشه.

مخفی کردن آدرس API امنیت محسوب نمیشه

گاهی برنامه‌نویس آدرس API رو داخل BuildConfig یا فایل local.properties قرار میده و تصور می‌کنه حالا آدرس سرور محرمانه شده. استفاده از این فایل‌ها برای جدا کردن تنظیمات محیط توسعه، آزمایش و انتشار کار خوبیه، اما چیزی رو از داخل نسخه نهایی APK مخفی نمی‌کنه. وقتی برنامه کامپایل میشه، آدرس موردنیازش باید به شکلی داخل فایل نهایی وجود داشته باشه و یک مهاجم می‌تونه اون رو پیدا کنه. حتی اگه آدرس رو به چند تکه تقسیم کنیم یا با روش‌های ساده رمزگذاری کنیم، باز هم برنامه در نهایت باید اون رو بازسازی کنه و بنابراین قابل استخراج خواهد بود.

مثلاً می‌تونیم Base URL رو برای نظم پروژه داخل BuildConfig قرار بدیم، اما نباید این کار رو یک اقدام امنیتی بدونیم.

android {
    buildTypes {
        debug {
            buildConfigField(
                "String",
                "API_BASE_URL",
                "\"https://dev-api.gheimatban.example/\""
            )
        }

        release {
            buildConfigField(
                "String",
                "API_BASE_URL",
                "\"https://api.gheimatban.example/\""
            )
        }
    }
}

بعد Retrofit از همین مقدار استفاده می‌کنه.

val retrofit = Retrofit.Builder()
    .baseUrl(BuildConfig.API_BASE_URL)
    .addConverterFactory(
        GsonConverterFactory.create()
    )
    .build()

این جداسازی کمک می‌کنه نسخه آزمایشی به سرور توسعه وصل بشه و نسخه انتشار به سرور اصلی، اما مهاجم همچنان می‌تونه آدرس هر دو سرور رو از برنامه استخراج کنه. امنیت باید روی سرور اجرا بشه، نه با پنهان کردن Endpoint.

API Key ثابت رو نباید رمز ورود سرور بدونیم

فرض کن برای تمام درخواست‌های قیمت‌بان یک کلید ثابت داخل برنامه قرار بدیم:

const val API_KEY = "gheimatban-super-secret-key"

بعد برنامه این کلید رو همراه همه درخواست‌ها بفرسته:

X-Api-Key: gheimatban-super-secret-key

این روش شاید جلوی درخواست‌های کاملاً تصادفی رو بگیره، اما احراز هویت امنی ایجاد نمی‌کنه. چون هر کسی که APK رو بررسی کنه یا درخواست‌های برنامه رو در یک محیط دست‌کاری‌شده ببیند، می‌تونه کلید رو استخراج کنه و مستقیماً با ابزارهایی مثل Postman یا اسکریپت شخصی خودش به API درخواست بفرسته. کلیدی که بین تمام نصب‌های برنامه مشترکه، بعد از افشا شدن عملاً برای همه کاربران و همه دستگاه‌ها لو رفته و تعویضش هم نیازمند انتشار نسخه جدید برنامه است.

کلیدهای ثابت ممکنه برای شناسایی پروژه، اعمال سهمیه روی سرویس‌های عمومی یا محدود کردن یک API کم‌خطر استفاده بشن، اما نباید اجازه دسترسی به اطلاعات حساس کاربران رو صادر کنن. اندروید هم استفاده از کلید ثابت برای دسترسی به APIهای حساس رو ناامن می‌دونه، چون این کلید می‌تونه از اپ استخراج یا هنگام ارتباط رهگیری بشه.

پس اگه قیمت‌بان از یک سرویس پولی خارجی استفاده کنه که Secret Key واقعی داره، اون کلید نباید داخل اپ قرار بگیره. برنامه باید درخواست رو به سرور خود قیمت‌بان بفرسته و سرور ما با Secret Key خودش به سرویس خارجی متصل بشه.

یعنی مسیر امن این شکلیه:

برنامه GheimatBan ← سرور GheimatBan ← سرویس خارجی

نه این شکلی:

برنامه GheimatBan ← سرویس خارجی با Secret Key دائمی

کاربر باید توکن اختصاصی خودش رو داشته باشه

بعد از ورود موفق کاربر، سرور معمولاً یک Access Token صادر می‌کنه. این توکن نماینده نشست کاربره و در درخواست‌های بعدی فرستاده میشه تا سرور بدونه درخواست متعلق به چه کسی است. برخلاف API Key ثابت، Access Token برای هر کاربر یا هر Session جداست و معمولاً زمان انقضای کوتاهی داره. در نتیجه اگه یک توکن افشا بشه، آسیب اون محدودتر از افشای کلید مشترک کل برنامه خواهد بود.

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

data class LoginResponse(
    val accessToken: String,
    val refreshToken: String,
    val expiresIn: Long
)

بعد Access Token باید در هدر Authorization درخواست‌ها ارسال بشه.

Authorization: Bearer ACCESS_TOKEN

برای اینکه مجبور نباشیم در تمام متدهای Retrofit این هدر رو دستی اضافه کنیم، یک Interceptor برای OkHttp می‌سازیم.

class AuthInterceptor(
    private val tokenProvider: TokenProvider
) : Interceptor {

    override fun intercept(
        chain: Interceptor.Chain
    ): Response {
        val token = tokenProvider.getAccessToken()

        val requestBuilder =
            chain.request()
                .newBuilder()

        if (!token.isNullOrBlank()) {
            requestBuilder.header(
                "Authorization",
                "Bearer $token"
            )
        }

        return chain.proceed(
            requestBuilder.build()
        )
    }
}

رابط TokenProvider رو هم خیلی ساده تعریف می‌کنیم تا Interceptor مستقیم به SharedPreferences یا دیتابیس وابسته نباشه.

interface TokenProvider {

    fun getAccessToken(): String?

    fun saveTokens(
        accessToken: String,
        refreshToken: String
    )

    fun clearTokens()
}

بعد Interceptor رو به OkHttp اضافه می‌کنیم و Retrofit رو با همون Client می‌سازیم.

val okHttpClient =
    OkHttpClient.Builder()
        .addInterceptor(
            AuthInterceptor(tokenProvider)
        )
        .build()

val retrofit =
    Retrofit.Builder()
        .baseUrl(BuildConfig.API_BASE_URL)
        .client(okHttpClient)
        .addConverterFactory(
            GsonConverterFactory.create()
        )
        .build()

از این لحظه به بعد، هر درخواستی که با این Client فرستاده بشه، در صورت وجود توکن هدر احراز هویت رو دریافت می‌کنه. البته Endpointهای ورود و ثبت‌نام قبل از ورود کاربر توکنی ندارن و Interceptor هم چون مقدار Token خالیه، هیچ هدر اضافه‌ای براشون نمی‌فرسته.

توکن رو کجا نگهداری کنیم؟

ذخیره کردن Access Token به شکل متن ساده داخل فایل، Log، متغیر عمومی یا سورس‌کد کار درستی نیست. توکن یک داده حساسه، چون هر کسی که به اون دسترسی پیدا کنه ممکنه تا زمان انقضای توکن خودش رو جای کاربر جا بزنه. برای نگهداری بهتر، می‌تونیم کلیدی رمزنگاری داخل Android Keystore بسازیم و با اون توکن رو رمز کنیم. مزیت Keystore اینه که خود کلید رمزنگاری به شکل معمول از برنامه قابل استخراج نیست و عملیات رمزنگاری با کنترل سیستم انجام میشه.

یک پیاده‌سازی کامل رمزنگاری توکن نسبتاً طولانیه، اما ساختار ساده اون به این شکله:

private const val KEY_ALIAS =
    "gheimatban_token_key"

private const val ANDROID_KEY_STORE =
    "AndroidKeyStore"

fun getOrCreateSecretKey(): SecretKey {
    val keyStore =
        KeyStore.getInstance(
            ANDROID_KEY_STORE
        ).apply {
            load(null)
        }

    val existingKey =
        keyStore.getKey(
            KEY_ALIAS,
            null
        ) as? SecretKey

    if (existingKey != null) {
        return existingKey
    }

    val keyGenerator =
        KeyGenerator.getInstance(
            KeyProperties.KEY_ALGORITHM_AES,
            ANDROID_KEY_STORE
        )

    val keySpec =
        KeyGenParameterSpec.Builder(
            KEY_ALIAS,
            KeyProperties.PURPOSE_ENCRYPT or
                KeyProperties.PURPOSE_DECRYPT
        )
            .setBlockModes(
                KeyProperties.BLOCK_MODE_GCM
            )
            .setEncryptionPaddings(
                KeyProperties.ENCRYPTION_PADDING_NONE
            )
            .build()

    keyGenerator.init(keySpec)

    return keyGenerator.generateKey()
}

این کد یک کلید AES داخل Android Keystore می‌سازه. بعد می‌تونیم توکن رو با الگوریتم AES/GCM/NoPadding رمز کنیم و فقط متن رمز‌شده و IV رو داخل SharedPreferences یا DataStore نگه داریم. مستندات اندروید برای رمزنگاری متقارن استفاده از AES در حالت‌هایی مثل GCM رو توصیه می‌کنن و برای محافظت بهتر از کلید هم Android Keystore رو پیشنهاد میدن.

با این حال باید واقع‌بین باشیم: هیچ ذخیره‌سازی‌ای روی یک دستگاه کاملاً تحت‌کنترل مهاجم، امنیت مطلق ایجاد نمی‌کنه. Keystore استخراج مستقیم کلید رو سخت‌تر می‌کنه، اما اگه دستگاه Root شده باشه یا خود فرایند برنامه تحت کنترل قرار بگیره، مهاجم ممکنه از برنامه برای استفاده از توکن کمک بگیره. بنابراین امنیت توکن باید با انقضای کوتاه، امکان لغو سمت سرور و بررسی رفتار مشکوک تکمیل بشه.

Refresh Token چرا وجود داره؟

اگه Access Token برای همیشه معتبر باشه، دزدیده شدن اون می‌تونه دسترسی طولانی‌مدت ایجاد کنه. برای همین Access Tokenها معمولاً عمر کوتاه‌تری دارن. وقتی منقضی میشن، برنامه با استفاده از Refresh Token از سرور یک Access Token جدید می‌گیره. Refresh Token حساس‌تره و باید با دقت بیشتری نگهداری بشه، چون می‌تونه نشست جدید ایجاد کنه.

روند کلی این شکلیه:

کاربر وارد میشه ← Access Token و Refresh Token دریافت می‌کنه ← Access Token در درخواست‌ها استفاده میشه ← سرور خطای 401 میده ← برنامه با Refresh Token توکن تازه می‌گیره ← درخواست قبلی دوباره اجرا میشه

در OkHttp می‌تونیم برای واکنش به خطای 401 Unauthorized از Authenticator استفاده کنیم.

class TokenAuthenticator(
    private val tokenProvider: TokenProvider,
    private val authApi: AuthApi
) : Authenticator {

    override fun authenticate(
        route: Route?,
        response: Response
    ): Request? {
        if (responseCount(response) >= 2) {
            return null
        }

        val refreshToken =
            tokenProvider.getRefreshToken()
                ?: return null

        val refreshResponse =
            authApi.refreshTokenBlocking(
                RefreshTokenRequest(
                    refreshToken = refreshToken
                )
            ).execute()

        if (!refreshResponse.isSuccessful) {
            tokenProvider.clearTokens()
            return null
        }

        val newTokens =
            refreshResponse.body()
                ?: return null

        tokenProvider.saveTokens(
            accessToken =
                newTokens.accessToken,
            refreshToken =
                newTokens.refreshToken
        )

        return response.request
            .newBuilder()
            .header(
                "Authorization",
                "Bearer ${newTokens.accessToken}"
            )
            .build()
    }

    private fun responseCount(
        response: Response
    ): Int {
        var count = 1
        var current =
            response.priorResponse

        while (current != null) {
            count++
            current = current.priorResponse
        }

        return count
    }
}

بعد Authenticator رو هم به Client اضافه می‌کنیم.

val okHttpClient =
    OkHttpClient.Builder()
        .addInterceptor(
            AuthInterceptor(tokenProvider)
        )
        .authenticator(
            TokenAuthenticator(
                tokenProvider = tokenProvider,
                authApi = authApi
            )
        )
        .build()

بررسی تعداد پاسخ‌ها جلوی حلقه بی‌نهایت رو می‌گیره. اگه Refresh Token هم نامعتبر یا منقضی شده باشه، برنامه نباید دائم درخواست رو تکرار کنه؛ باید توکن‌ها رو پاک کنه و کاربر رو دوباره به صفحه ورود بفرسته. در یک پروژه واقعی باید هم‌زمانی چند درخواست 401 رو هم مدیریت کنیم، چون ممکنه چند درخواست هم‌زمان برای Refresh Token اقدام کنن. معمولاً با Mutex یا یک سازوکار مرکزی اجازه میدیم فقط یک Refresh انجام بشه و بقیه درخواست‌ها منتظر نتیجه اون بمونن.

احراز هویت با مجوز دسترسی فرق داره

یکی از مهم‌ترین مفاهیم امنیت API تفاوت بین Authentication و Authorization است. Authentication یعنی سرور تشخیص بده درخواست از طرف چه کاربری اومده. Authorization یعنی بررسی کنه همون کاربر اجازه انجام این عملیات رو داره یا نه. داشتن یک توکن معتبر به این معنی نیست که کاربر اجازه دیدن تمام داده‌های سیستم رو داره.

فرض کن Endpoint زیر جزئیات هشدار قیمت رو برمی‌گردونه:

GET /alerts/825

برنامه ممکنه توکن ایمان رو بفرسته، اما کاربر شناسه 825 رو به 826 تغییر بده. اگه سرور فقط معتبر بودن توکن رو بررسی کنه و هشدار شماره ۸۲۶ رو برگردونه، کاربر می‌تونه اطلاعات هشدار متعلق به شخص دیگری رو ببینه. این آسیب‌پذیری در OWASP با عنوان Broken Object Level Authorization شناخته میشه و یکی از مهم‌ترین خطرهای APIهاست. سرور باید در هر درخواست بررسی کنه شیء درخواست‌شده واقعاً متعلق به همون کاربره یا کاربر مجوز مدیریتی لازم رو داره.

منطق اشتباه سمت سرور تقریباً این شکلیه:

$alertId = $_GET['id'];

$alert = findAlertById($alertId);

returnJson($alert);

اما منطق درست باید شناسه کاربر احراز هویت‌شده رو هم در Query دخالت بده.

$userId = getAuthenticatedUserId();
$alertId = $_GET['id'];

$alert = findAlertByIdAndUserId(
    $alertId,
    $userId
);

if (!$alert) {
    http_response_code(404);
    exit;
}

returnJson($alert);

دقت کن که شناسه کاربر نباید از Body درخواست گرفته بشه و مورداعتماد قرار بگیره. سرور باید اون رو از توکن معتبر استخراج کنه. مثلاً کاربر نباید بتونه چنین درخواستی بفرسته و انتظار داشته باشیم مقدار userId رو صادقانه وارد کرده باشه:

{
  "userId": 42,
  "productId": 918
}

برای افزودن یک کالا به علاقه‌مندی‌ها، فقط productId باید از کاربر دریافت بشه و userId باید از Session یا Token استخراج بشه.

برنامه نباید اطلاعات اضافی از API دریافت کنه

گاهی سرور یک شیء کامل کاربر رو می‌فرسته، در حالی که اپ فقط نام نمایشی رو لازم داره. مثلاً پاسخ API ممکنه شامل ایمیل، شماره تلفن، نقش داخلی، هش رمز عبور یا اطلاعات مدیریتی هم باشه. حتی اگه رابط کاربری این فیلدها رو نمایش نده، همه اون‌ها در پاسخ شبکه وجود دارن و قابل مشاهده هستن. بنابراین سرور باید فقط اطلاعاتی رو برگردونه که واقعاً برای همون صفحه لازم‌اند.

مدل پاسخ بهتره محدود و هدفمند باشه.

data class UserProfileResponse(
    val id: Long,
    val displayName: String,
    val avatarUrl: String?
)

نباید مدل دیتابیس رو مستقیماً به عنوان پاسخ API ارسال کنیم. چون با اضافه شدن هر ستون جدید به دیتابیس، ممکنه ناخواسته اطلاعات حساس هم وارد پاسخ API بشن. OWASP این نوع مشکل رو در دسته کنترل دسترسی در سطح ویژگی‌های شیء قرار میده؛ یعنی سرور باید علاوه بر خود شیء، روی فیلدهایی که قابل خواندن یا تغییر هستن هم کنترل داشته باشه.

همین موضوع در درخواست‌های ویرایش هم وجود داره. اگه کاربر فقط قراره نام نمایشی خودش رو تغییر بده، نباید سرور هر فیلدی که در JSON ارسال شد قبول کنه. وگرنه مهاجم ممکنه فیلدی مثل isAdmin یا role رو هم به درخواست اضافه کنه. بهتره برای هر عملیات DTO جدا داشته باشیم.

data class UpdateProfileRequest(
    val displayName: String
)

محدودیت درخواست باید روی سرور اجرا بشه

فرض کن Endpoint ورود هیچ محدودیتی نداشته باشه. مهاجم می‌تونه در چند دقیقه هزاران رمز مختلف رو برای یک شماره تلفن امتحان کنه. یا ممکنه Endpoint دریافت قیمت رو با صدها درخواست در ثانیه صدا بزنه و منابع سرور یا سرویس خارجی رو مصرف کنه. اینجا به Rate Limiting نیاز داریم؛ یعنی سرور مشخص کنه هر IP، حساب کاربری یا Device در یک بازه زمانی چند درخواست مجازه.

محدود کردن دکمه در اپ یا گذاشتن Delay داخل کد اندروید امنیت ایجاد نمی‌کنه، چون مهاجم اصلاً مجبور نیست از رابط کاربری برنامه استفاده کنه. اون می‌تونه مستقیم به API درخواست بفرسته. Rate Limiting باید روی سرور، API Gateway، Reverse Proxy یا سرویس‌هایی مثل Cloudflare اجرا بشه. OWASP مصرف نامحدود منابع و نبود محدودیت مناسب روی درخواست‌ها رو یکی از خطرهای اصلی API معرفی می‌کنه، چون می‌تونه باعث سوءاستفاده مالی یا از دسترس خارج شدن سرویس بشه.

برای مثال ممکنه سیاست سرور قیمت‌بان این باشه که هر حساب فقط پنج تلاش ورود در یک دقیقه داشته باشه، هر کاربر فقط تعداد مشخصی درخواست بروزرسانی قیمت در ساعت بفرسته و درخواست‌های پرهزینه محدودیت سخت‌گیرانه‌تری داشته باشن. وقتی محدودیت پر شد، سرور معمولاً پاسخ 429 Too Many Requests میده و می‌تونه زمان تقریبی تلاش بعدی رو هم در هدر Retry-After قرار بده.

در اندروید هم باید این پاسخ رو به شکل مناسبی مدیریت کنیم.

when (response.code()) {
    401 -> {
        showLoginRequired()
    }

    403 -> {
        showPermissionDenied()
    }

    429 -> {
        showMessage(
            "تعداد درخواست‌ها زیاد بوده. کمی بعد دوباره امتحان کن."
        )
    }

    in 500..599 -> {
        showMessage(
            "سرور موقتاً پاسخ نمیده."
        )
    }
}

اطلاعات حساس رو داخل Log چاپ نکن

هنگام توسعه خیلی وسوسه‌کننده است که تمام درخواست‌ها و پاسخ‌های Retrofit رو با HttpLoggingInterceptor نمایش بدیم. این ابزار برای Debug فوق‌العاده مفیده، اما اگه سطح BODY در نسخه انتشار فعال بمونه، ممکنه توکن‌ها، شماره تلفن، ایمیل، اطلاعات پروفایل و Body درخواست‌ها داخل Log قرار بگیرن. پس Logging کامل باید فقط در نسخه Debug فعال باشه.

val loggingInterceptor =
    HttpLoggingInterceptor().apply {
        level =
            if (BuildConfig.DEBUG) {
                HttpLoggingInterceptor.Level.BODY
            } else {
                HttpLoggingInterceptor.Level.NONE
            }

        redactHeader("Authorization")
        redactHeader("Cookie")
    }

بعد این Interceptor رو به Client اضافه می‌کنیم.

val okHttpClient =
    OkHttpClient.Builder()
        .addInterceptor(loggingInterceptor)
        .addInterceptor(
            AuthInterceptor(tokenProvider)
        )
        .build()

متد redactHeader باعث میشه مقدار هدرهای حساس حتی در حالت Debug هم به شکل کامل داخل Log دیده نشه. علاوه بر این، نباید توکن رو با Log.d چاپ کنیم، داخل پیام خطا نمایش بدیم یا برای بررسی مشکل در ابزارهای گزارش Crash ارسال کنیم. پیام خطا باید برای کاربر قابل‌فهم باشه، اما جزئیات داخلی سرور، Query دیتابیس، Stack Trace یا اطلاعات امنیتی نباید داخل پاسخ عمومی API قرار بگیرن.

Certificate Pinning همیشه انتخاب خوبی نیست

Certificate Pinning یعنی برنامه فقط گواهی یا کلید عمومی مشخصی رو برای دامنه سرور قبول کنه. این روش می‌تونه بعضی حمله‌های پیشرفته رو سخت‌تر کنه، اما هزینه عملیاتی زیادی داره. اگه گواهی سرور تمدید یا Certificate Authority تغییر کنه و Pin پشتیبان مناسبی نداشته باشیم، تمام نسخه‌های نصب‌شده برنامه ممکنه ناگهان ارتباطشون رو با سرور از دست بدن و رفع مشکل هم نیازمند انتشار آپدیت باشه.

به همین دلیل مستندات اندروید Pinning رو به طور عمومی برای برنامه‌های اندروید توصیه نمی‌کنن. اگر پروژه‌ای واقعاً به اون نیاز داشته باشه، باید چند Pin پشتیبان، برنامه دقیق چرخش کلید و تاریخ انقضای مناسب داشته باشه. برای پروژه‌ای مثل قیمت‌بان، استفاده درست از HTTPS، اعتبارسنجی استاندارد گواهی، احراز هویت قوی و کنترل دسترسی سمت سرور معمولاً مهم‌تر و کم‌خطرتر از اضافه کردن شتاب‌زده Certificate Pinning است.

ProGuard و Obfuscation کمک می‌کنن، اما امنیت سرور نیستن

در نسخه Release می‌تونیم با R8 نام کلاس‌ها و متدها رو کوتاه و مبهم کنیم تا مهندسی معکوس سخت‌تر بشه. این کار ارزشمنده، اما نباید بهش مثل گاوصندوق نگاه کنیم. مهاجم همچنان می‌تونه رفتار برنامه، رشته‌ها، آدرس‌های شبکه و درخواست‌های خروجی رو بررسی کنه. Obfuscation فقط هزینه تحلیل رو بالا می‌بره؛ راز واقعی و دائمی ایجاد نمی‌کنه.

android {
    buildTypes {
        release {
            isMinifyEnabled = true
            isShrinkResources = true

            proguardFiles(
                getDefaultProguardFile(
                    "proguard-android-optimize.txt"
                ),
                "proguard-rules.pro"
            )
        }
    }
}

پس حتی در نسخه Obfuscateشده هم نباید Secret Key، رمز دیتابیس، کلید خصوصی سرور یا توکن مدیریتی قرار بدیم. هر چیزی که قدرت دائمی و گسترده‌ای داره، باید روی سرور باقی بمونه.

جمع‌بندی

امنیت API از یک ترفند یا یک کتابخانه خاص به دست نمیاد. در قیمت‌بان باید تمام ارتباط‌ها با HTTPS انجام بشن و Cleartext Traffic غیرفعال باشه. کاربر بعد از ورود باید Access Token اختصاصی بگیره، توکن‌ها نباید داخل Log یا سورس‌کد قرار بگیرن و برای نگهداری بهتر اطلاعات حساس میشه از رمزنگاری مبتنی بر Android Keystore استفاده کرد. OkHttp Interceptor هدر احراز هویت رو به درخواست‌ها اضافه می‌کنه و Authenticator می‌تونه انقضای Access Token رو با Refresh Token مدیریت کنه.

اما بخش مهم‌تر امنیت روی سروره. سرور باید برای تک‌تک درخواست‌ها هویت کاربر و مجوز دسترسی اون رو بررسی کنه، شناسه کاربر رو از توکن بگیره، اطلاعات اضافی برنگردونه، فیلدهای غیرمجاز رو قبول نکنه و روی عملیات حساس Rate Limit داشته باشه. هیچ API Key ثابت، آدرس مخفی، Obfuscation یا بررسی داخل اپ نمی‌تونه جای این کنترل‌ها رو بگیره. اصل مهم اینه: برنامه اندروید رو یک محیط قابل‌اعتماد فرض نکن؛ هر درخواست رو روی سرور دوباره بررسی کن.


درس پنجاهم

SSL Pinning

اصلاً SSL Pinning چیه؟

در درس قبلی گفتیم که تمام ارتباط‌های قیمت‌بان باید با HTTPS انجام بشن. HTTPS باعث میشه داده‌های بین برنامه و سرور رمزنگاری بشن و سیستم‌عامل هم گواهی سرور رو بررسی کنه. اگه گواهی معتبر باشه و نام دامنه با اون تطابق داشته باشه، اتصال برقرار میشه. این سازوکار در حالت عادی برای بیشتر برنامه‌ها امنیت مناسبی ایجاد می‌کنه، اما یک نکته وجود داره: اندروید فقط به گواهی خود سرور اعتماد نمی‌کنه، بلکه به مجموعه بزرگی از Certificate Authorityهای معتبر سیستم اعتماد داره. یعنی اگه یکی از این مراکز صدور گواهی اشتباهی برای دامنه ما گواهی صادر کنه یا به خطر بیفته، از نظر تئوری ممکنه یک مهاجم بتونه خودش رو جای سرور اصلی جا بزنه.

SSL Pinning یا دقیق‌تر Certificate Pinning یک لایه محدودکننده اضافه می‌کنه. برنامه علاوه بر بررسی عادی TLS میگه: «من فقط وقتی به این دامنه وصل میشم که زنجیره گواهی شامل یکی از کلیدهای عمومی مشخص‌شده توسط خودم باشه.» بنابراین حتی اگه مهاجم یک گواهی ظاهراً معتبر از یک CA مورداعتماد سیستم داشته باشه، اتصال رد میشه چون کلید عمومی اون با Pinهای داخل برنامه تطابق نداره. با این حال باید از همین ابتدا بدونیم که Android استفاده عمومی از Certificate Pinning رو توصیه نمی‌کنه، چون تغییر گواهی، تغییر CA یا اشتباه در مدیریت Pinها می‌تونه تمام نسخه‌های نصب‌شده برنامه رو از سرور قطع کنه.

HTTPS بدون Pinning چطور اعتماد می‌کنه؟

برای فهم Pinning باید اول بدونیم اتصال عادی HTTPS چطور کار می‌کنه. وقتی قیمت‌بان به آدرس https://api.gheimatban.com درخواست می‌فرسته، سرور گواهی خودش و معمولاً چند گواهی واسطه رو ارائه میده. اندروید زنجیره گواهی رو بررسی می‌کنه و می‌بینه آیا در نهایت به یکی از Root CAهای مورداعتماد سیستم می‌رسه یا نه. بعد تاریخ اعتبار گواهی، نام دامنه و امضای دیجیتال هم بررسی میشن. اگه همه‌چیز درست باشه، یک کانال رمزنگاری‌شده بین برنامه و سرور شکل می‌گیره.

این روش به این معنیه که برنامه ما فقط به یک گواهی خاص اعتماد نداره؛ در واقع به تمام CAهایی اعتماد داره که سیستم‌عامل معتبر می‌دونه. این مدل برای وب و بیشتر اپلیکیشن‌ها ضروریه، چون گواهی‌ها مرتب تمدید میشن و ممکنه زیرساخت سرور یا شرکت صادرکننده گواهی تغییر کنه. Pinning این دایره اعتماد رو محدود می‌کنه و میگه اعتبار عمومی گواهی کافی نیست؛ یکی از کلیدهای از قبل تعیین‌شده هم باید در زنجیره وجود داشته باشه.

روند عادی تقریباً این شکلیه:

برنامه ← گواهی سرور ← گواهی واسطه ← Root CA مورداعتماد اندروید

با Pinning یک شرط دیگر هم اضافه میشه:

زنجیره معتبر TLS + تطابق با یکی از Pinهای برنامه

چرا بهش SSL Pinning میگن، با اینکه TLS داریم؟

اصطلاح SSL Pinning هنوز خیلی رایجه، اما از نظر فنی بیشتر اتصال‌های امروزی از TLS استفاده می‌کنن، نه نسخه‌های قدیمی SSL. اسم دقیق‌تر این مفهوم Certificate Pinning یا Public Key Pinning است. با این حال در آموزش‌ها، مستندات قدیمی و گفت‌وگوهای برنامه‌نویسی همچنان عبارت SSL Pinning زیاد دیده میشه.

نکته مهم‌تر اینه که معمولاً خود فایل کامل گواهی رو Pin نمی‌کنیم، بلکه هش کلید عمومی موجود در گواهی رو ذخیره می‌کنیم. اندروید در Network Security Configuration از هش SubjectPublicKeyInfo استفاده می‌کنه و OkHttp هم Pinها رو به شکل هش SHA-256 کلید عمومی می‌پذیره. مزیت Pin کردن کلید عمومی اینه که اگه گواهی با همون کلید تمدید بشه، Pin همچنان معتبر می‌مونه. اما اگه کلید جدیدی تولید بشه، باید Pin جدید هم از قبل داخل برنامه وجود داشته باشه.

Pinning جلوی چه حمله‌ای رو می‌گیره؟

سناریوی اصلی، حمله Man-in-the-Middle است. فرض کن کاربر به یک شبکه Wi-Fi آلوده متصل شده و مهاجم تلاش می‌کنه بین برنامه و سرور قرار بگیره. در اتصال HTTPS عادی، مهاجم برای موفقیت باید گواهی معتبری برای دامنه ما ارائه بده. در بیشتر شرایط این کار سخت و غیرعملیه، چون گواهی جعلی توسط سیستم رد میشه. اما اگه مهاجم به یک CA مورداعتماد دسترسی پیدا کرده باشه، یک CA اشتباهی گواهی صادر کرده باشه یا روی دستگاه کاربر یک CA جدید نصب شده باشه، شرایط ممکنه متفاوت بشه.

Pinning میگه حتی اگه گواهی از نظر سیستم معتبره، کلید عمومی اون باید با کلیدی که ما از قبل داخل برنامه ثبت کردیم تطابق داشته باشه. بنابراین بسیاری از Proxyهای تحلیل شبکه، CAهای نصب‌شده توسط کاربر و گواهی‌های صادرشده توسط CAهای دیگر رد میشن. این قابلیت برای بعضی برنامه‌های بسیار حساس مثل بانکداری، سامانه‌های سازمانی خاص یا برنامه‌هایی با مدل تهدید سخت‌گیرانه ممکنه ارزش داشته باشه.

اما Pinning جلوی همه حملات رو نمی‌گیره. اگه خود گوشی Root شده باشه، مهاجم بتونه کد برنامه رو تغییر بده، Pinning رو Hook کنه یا نسخه دست‌کاری‌شده برنامه رو اجرا کنه، امکان دور زدن وجود داره. بنابراین Pinning یک لایه دفاعی اضافه است، نه تضمین امنیت مطلق.

بزرگ‌ترین خطر SSL Pinning چیه؟

بزرگ‌ترین خطر اینه که خودمون کاربران رو از سرور قطع کنیم. فرض کن نسخه فعلی قیمت‌بان فقط کلید عمومی فعلی سرور رو Pin کرده. شش ماه بعد گواهی منقضی میشه و مدیر سرور هنگام تمدید، یک کلید خصوصی جدید تولید می‌کنه. گواهی جدید کاملاً معتبره و مرورگرها بدون مشکل اون رو قبول می‌کنن، اما نسخه نصب‌شده برنامه فقط Pin قدیمی رو می‌شناسه. نتیجه اینه که تمام درخواست‌های API با خطای SSL شکست می‌خورن و کاربران نمی‌تونن وارد برنامه بشن.

مشکل بدتر اینه که برای رفع این وضعیت باید نسخه جدید برنامه رو منتشر کنیم، اما ممکنه کاربران نتونن به‌سرعت اون رو نصب کنن. بعضی کاربران آپدیت خودکار رو خاموش کردن، بعضی‌ها به فروشگاه دسترسی ندارن و بعضی نسخه‌های قدیمی ممکنه ماه‌ها فعال بمونن. به همین دلیل مستندات اندروید تأکید می‌کنن اگه Pinning استفاده میشه، باید حتماً Pin پشتیبان و زمان انقضای مناسب وجود داشته باشه.

همیشه باید حداقل یک Pin پشتیبان داشته باشیم

هیچ‌وقت نباید فقط یک کلید رو Pin کنیم. حداقل باید دو Pin داخل برنامه وجود داشته باشه: Pin اصلی که سرور الآن از اون استفاده می‌کنه و یک Pin پشتیبان که کلید خصوصی اون از قبل در اختیار خودمون قرار داره. اگه مجبور شدیم گواهی یا کلید سرور رو تغییر بدیم، گواهی جدید رو با کلید پشتیبان صادر می‌کنیم. چون Pin اون از قبل داخل نسخه‌های نصب‌شده برنامه وجود داره، اتصال قطع نمیشه.

بعد نسخه جدید برنامه رو منتشر می‌کنیم و داخل اون یک Pin پشتیبان تازه قرار میدیم. این فرایند باید شبیه چرخش کلیدها باشه، نه اینکه بعد از خرابی تازه دنبال راه‌حل بگردیم. Pin پشتیبان فقط یک هش تصادفی نیست؛ باید واقعاً کلید خصوصی مربوط به اون رو در جای امن نگهداری کنیم تا در زمان بحران قابل استفاده باشه.

ساختار درست این شکلیه:

Pin فعال فعلی
Pin پشتیبان قابل‌کنترل
زمان انقضای Pinning
برنامه مشخص برای چرخش کلید

بدون این موارد، فعال کردن Pinning بیشتر از اینکه امنیت ایجاد کنه، ریسک عملیاتی ایجاد می‌کنه.

روش اول: Pinning با Network Security Configuration

اندروید از نسخه ۷، یعنی API 24، قابلیتی به نام Network Security Configuration داره. با این قابلیت می‌تونیم تنظیمات امنیت شبکه رو داخل فایل XML تعریف کنیم و لازم نیست TrustManager یا HostnameVerifier سفارشی بنویسیم. این روش declarative است، فقط روی دامنه‌های مشخص اعمال میشه و اندروید خودش اعتبارسنجی زنجیره گواهی رو مدیریت می‌کنه. مستندات اندروید هم استفاده از این روش رو به پیاده‌سازی‌های دستی و خطاپذیر ترجیح میدن.

داخل مسیر زیر یک فایل می‌سازیم:

res/xml/network_security_config.xml

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>

    <domain-config cleartextTrafficPermitted="false">

        <domain includeSubdomains="true">
            api.gheimatban.com
        </domain>

        <pin-set expiration="2028-12-31">

            <pin digest="SHA-256">
                BASE64_PRIMARY_PUBLIC_KEY_HASH
            </pin>

            <pin digest="SHA-256">
                BASE64_BACKUP_PUBLIC_KEY_HASH
            </pin>

        </pin-set>

    </domain-config>

</network-security-config>

داخل domain دامنه API رو قرار میدیم. ویژگی includeSubdomains="true" یعنی این تنظیم علاوه بر دامنه اصلی، روی زیردامنه‌های اون هم اعمال میشه. داخل pin-set دو هش داریم؛ اولی Pin کلید فعلی و دومی Pin پشتیبان است. مقدار expiration هم مشخص می‌کنه Pinning تا چه تاریخی اجرا بشه. بعد از اون تاریخ، اندروید Pinها رو بررسی نمی‌کنه و فقط اعتبارسنجی عادی TLS باقی می‌مونه. این تاریخ یک راه فرار برای نسخه‌های قدیمی برنامه است تا در صورت تغییر زیرساخت، برای همیشه از سرور قطع نشن.

حالا این فایل رو داخل Manifest معرفی می‌کنیم:

<application
    android:name=".GheimatBanApplication"
    android:networkSecurityConfig="@xml/network_security_config"
    android:theme="@style/Theme.GheimatBan">

    <!-- Activities -->

</application>

از این لحظه به بعد، تمام اتصال‌هایی که از زیرساخت شبکه اندروید استفاده می‌کنن و به دامنه مشخص‌شده میرن، باید علاوه بر اعتبارسنجی معمول TLS، با یکی از Pinها هم تطابق داشته باشن. اگه هیچ Pinی در زنجیره گواهی پیدا نشه، اتصال با خطای SSL شکست می‌خوره و درخواست اصلاً به API نمیرسه.

مقدار Pin رو از کجا بیاریم؟

مقدار Pin خود گواهی یا کلید خصوصی نیست. چیزی که داخل فایل قرار می‌گیره، هش SHA-256 بخش Subject Public Key Info گواهیه که به شکل Base64 نوشته شده. برای به‌دست آوردن این مقدار می‌تونیم از openssl استفاده کنیم. این دستور باید روی یک سیستم مورداعتماد و برای دامنه واقعی اجرا بشه.

openssl s_client \
  -connect api.gheimatban.com:443 \
  -servername api.gheimatban.com \
  </dev/null 2>/dev/null \
| openssl x509 -pubkey -noout \
| openssl pkey -pubin -outform der \
| openssl dgst -sha256 -binary \
| openssl enc -base64

خروجی چیزی شبیه این خواهد بود:

mS8W7Yh3jK9q1Zx2p0F7b6n4V8c3L5a9R2t6Q1u7X0M=

همین مقدار باید داخل تگ pin قرار بگیره، بدون پیشوند sha256/. اما اینجا یک احتیاط مهم وجود داره: نباید فقط Pin رو از یک اتصال ناشناس روی یک شبکه نامطمئن استخراج کنیم و بعد بدون بررسی داخل برنامه قرار بدیم. چون اگه همون لحظه تحت حمله MITM باشیم، ممکنه Pin گواهی مهاجم رو ثبت کنیم. بهتره مقدار Pin از تیم زیرساخت یا از گواهی و کلید عمومی رسمی سرور دریافت و مستقل تأیید بشه.

همچنین باید Pin پشتیبان رو از کلیدی بگیریم که هنوز روی سرور فعال نشده، اما کلید خصوصی اون در اختیار امن خودمونه. این بخش معمولاً توسط تیم Backend یا DevOps مدیریت میشه، نه اینکه برنامه‌نویس اندروید خودش یک هش تصادفی بسازه.

آیا گواهی Leaf رو Pin کنیم یا CA رو؟

زنجیره گواهی معمولاً شامل گواهی دامنه یا Leaf Certificate، یک یا چند گواهی Intermediate و در نهایت Root CA است. Pin می‌تونه با کلید عمومی هر کدوم از گواهی‌های موجود در زنجیره تطابق داشته باشه. هر انتخاب مزایا و معایب خودش رو داره.

اگه گواهی Leaf رو Pin کنیم، محدودیت سخت‌گیرانه‌تر میشه و برنامه فقط کلید همان سرور رو قبول می‌کنه. اما اگه هنگام تمدید گواهی کلید عوض بشه، Pin هم باید عوض بشه. اگه Intermediate CA رو Pin کنیم، گواهی دامنه می‌تونه با انعطاف بیشتری تمدید بشه، اما هر گواهی دیگری که با همون Intermediate صادر شده باشه ممکنه شرط Pin رو برآورده کنه. Pin کردن Root CA هم دامنه اعتماد بسیار بزرگ‌تری ایجاد می‌کنه و ممکنه بخش بزرگی از مزیت Pinning رو کاهش بده.

برای بسیاری از پروژه‌هایی که واقعاً به Pinning نیاز دارن، Pin کردن کلید Leaf همراه با یک کلید پشتیبان تحت‌کنترل انتخاب دقیقیه. اما تصمیم نهایی باید با تیم امنیت و زیرساخت گرفته بشه، چون مدل تمدید گواهی و چرخش کلید هر سازمان متفاوته.

روش دوم: Pinning با OkHttp

اگه تمام درخواست‌های قیمت‌بان با OkHttp و Retrofit انجام میشن، می‌تونیم از CertificatePinner خود OkHttp استفاده کنیم. در این روش Pin فقط روی همان OkHttpClient اعمال میشه و کنترل اون داخل کد Kotlin قرار می‌گیره. OkHttp هم از Pinهای SHA-256 مربوط به کلید عمومی گواهی استفاده می‌کنه.

import okhttp3.CertificatePinner
import okhttp3.OkHttpClient

private const val API_HOST =
    "api.gheimatban.com"

private const val PRIMARY_PIN =
    "sha256/BASE64_PRIMARY_PUBLIC_KEY_HASH"

private const val BACKUP_PIN =
    "sha256/BASE64_BACKUP_PUBLIC_KEY_HASH"

val certificatePinner =
    CertificatePinner.Builder()
        .add(
            API_HOST,
            PRIMARY_PIN
        )
        .add(
            API_HOST,
            BACKUP_PIN
        )
        .build()

val okHttpClient =
    OkHttpClient.Builder()
        .certificatePinner(
            certificatePinner
        )
        .build()

بعد همین Client رو به Retrofit میدیم:

val retrofit =
    Retrofit.Builder()
        .baseUrl(
            "https://api.gheimatban.com/"
        )
        .client(okHttpClient)
        .addConverterFactory(
            GsonConverterFactory.create()
        )
        .build()

وقتی OkHttp به سرور وصل میشه، اول اعتبارسنجی عادی TLS انجام میشه. بعد CertificatePinner زنجیره گواهی رو بررسی می‌کنه و می‌بینه آیا دست‌کم یکی از کلیدهای عمومی با Pinهای تعریف‌شده مطابقت داره یا نه. اگه تطابق وجود نداشته باشه، OkHttp یک SSLPeerUnverifiedException ایجاد می‌کنه و درخواست شکست می‌خوره.

Pinning با XML بهتره یا OkHttp؟

هیچ پاسخ مطلقی وجود نداره. Network Security Configuration در سطح پلتفرم اعمال میشه و تنظیمات امنیتی رو از کد شبکه جدا نگه می‌داره. این روش می‌تونه روی اتصال‌های مختلف برنامه اعمال بشه و قابلیت‌هایی مثل Pin پشتیبان، تاریخ انقضا و Debug Override رو به صورت رسمی پشتیبانی می‌کنه. به همین دلیل برای پروژه‌ای که تصمیم گرفته واقعاً Pinning داشته باشه، معمولاً انتخاب تمیزتر و کم‌خطرتریه.

CertificatePinner در OkHttp هم ساده و قابل‌فهمه و برای پروژه‌هایی که تمام ارتباط شبکه‌شون از یک OkHttpClient مشخص عبور می‌کنه، کنترل مستقیمی ایجاد می‌کنه. اما تاریخ انقضای Pin به شکل خودکار در اون وجود نداره و مدیریت چرخش Pin باید در منطق انتشار نسخه‌ها انجام بشه. همچنین اگه برنامه از WebView، کتابخانه شبکه دیگری یا اتصال خارج از OkHttp استفاده کنه، Pinning تعریف‌شده روی OkHttp اون مسیرها رو پوشش نمیده.

نباید هم‌زمان و بدون دلیل هر دو روش رو فعال کنیم. چون در اون صورت دو لایه Pinning مستقل داریم و عیب‌یابی و چرخش کلید پیچیده‌تر میشه. یک روش رو بر اساس معماری پروژه انتخاب می‌کنیم و همون رو دقیق مدیریت می‌کنیم.

برای نسخه Debug چطور Proxy شبکه رو استفاده کنیم؟

وقتی Pinning فعال باشه، ابزارهایی مثل Charles Proxy، Burp Suite یا Proxyman معمولاً نمی‌تونن ترافیک HTTPS برنامه رو بخونن. این ابزارها یک CA مخصوص خودشون روی دستگاه نصب می‌کنن و گواهی موقت برای دامنه API می‌سازن، اما چون کلید اون گواهی با Pin برنامه تطابق نداره، اتصال رد میشه. این رفتار در نسخه Release مطلوبه، ولی هنگام توسعه ممکنه عیب‌یابی شبکه رو دشوار کنه.

Network Security Configuration امکان debug-overrides رو فراهم می‌کنه. با این بخش می‌تونیم فقط در نسخه Debug به گواهی‌های نصب‌شده توسط کاربر اعتماد کنیم و اجازه بدیم اون‌ها Pinning رو دور بزنن. این تنظیم نباید روی نسخه Release اثر داشته باشه.

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>

    <domain-config cleartextTrafficPermitted="false">

        <domain includeSubdomains="true">
            api.gheimatban.com
        </domain>

        <pin-set expiration="2028-12-31">

            <pin digest="SHA-256">
                BASE64_PRIMARY_PUBLIC_KEY_HASH
            </pin>

            <pin digest="SHA-256">
                BASE64_BACKUP_PUBLIC_KEY_HASH
            </pin>

        </pin-set>

    </domain-config>

    <debug-overrides>
        <trust-anchors>
            <certificates
                src="user"
                overridePins="true" />
        </trust-anchors>
    </debug-overrides>

</network-security-config>

ویژگی overridePins="true" باعث میشه گواهی‌هایی که از Trust Anchor مخصوص Debug میان، از بررسی Pinning عبور کنن. طبق مستندات اندروید، Debug Override فقط زمانی اعمال میشه که برنامه Debuggable باشه و روی نسخه Release فعال نیست. با این حال باید فایل نهایی رو دقیق بررسی کنیم و مطمئن بشیم نسخه انتشار واقعاً debuggable=false است.

خطای Pinning رو چطور مدیریت کنیم؟

وقتی Pin با گواهی سرور تطابق نداشته باشه، برنامه نباید خطای خام و فنی رو به کاربر نمایش بده. برای کاربر فرقی نمی‌کنه SSLPeerUnverifiedException رخ داده یا زنجیره گواهی اشتباه بوده؛ فقط باید بدونه اتصال امن با سرور برقرار نشده. در عین حال این خطا نباید مثل قطع بودن معمولی اینترنت مدیریت بشه، چون ممکنه نشانه تغییر اشتباه گواهی، حمله شبکه یا خراب شدن تنظیمات Pinning باشه.

import javax.net.ssl.SSLHandshakeException
import javax.net.ssl.SSLPeerUnverifiedException

suspend fun loadProducts(): Result<List<Product>> {
    return try {
        val products =
            apiService.getProducts()

        Result.success(products)
    } catch (
        exception: SSLPeerUnverifiedException
    ) {
        Result.failure(
            SecureConnectionException()
        )
    } catch (
        exception: SSLHandshakeException
    ) {
        Result.failure(
            SecureConnectionException()
        )
    } catch (
        exception: Exception
    ) {
        Result.failure(exception)
    }
}

یک Exception اختصاصی هم تعریف می‌کنیم:

class SecureConnectionException :
    Exception(
        "Secure connection could not be established."
    )

داخل ViewModel می‌تونیم پیام مناسب رو برای رابط کاربری تولید کنیم:

when (exception) {
    is SecureConnectionException -> {
        _uiState.update {
            it.copy(
                errorMessage =
                    "ارتباط امن با سرور برقرار نشد. " +
                    "لطفاً برنامه رو به‌روز کن یا کمی بعد دوباره امتحان کن."
            )
        }
    }

    else -> {
        _uiState.update {
            it.copy(
                errorMessage =
                    "دریافت اطلاعات با خطا روبه‌رو شد."
            )
        }
    }
}

نکته مهم اینه که بعد از خطای Pinning نباید خودکار به HTTP برگردیم، بررسی گواهی رو خاموش کنیم یا هر گواهی‌ای رو قبول کنیم. این کار تمام هدف Pinning و حتی امنیت HTTPS رو از بین می‌بره. اگه Pinning شکست خورد، اتصال باید متوقف بمونه تا علت واقعی برطرف بشه.

TrustManager سفارشی نساز

یکی از خطرناک‌ترین اشتباه‌ها اینه که برای حل خطاهای SSL یک X509TrustManager بسازیم که تمام گواهی‌ها رو قبول کنه. بعضی کدهای اینترنتی متدی خالی برای checkServerTrusted می‌سازن یا یک HostnameVerifier قرار میدن که همیشه true برگردونه. این کار نه‌تنها Pinning نیست، بلکه اعتبارسنجی کامل HTTPS رو غیرفعال می‌کنه و برنامه رو در برابر حمله MITM کاملاً آسیب‌پذیر می‌سازه.

نمونه خطرناک این شکلیه و نباید در پروژه استفاده بشه:

val unsafeHostnameVerifier =
    HostnameVerifier { _, _ ->
        true
    }

همین‌طور نباید چنین TrustManagerی بسازیم:

override fun checkServerTrusted(
    chain: Array<X509Certificate>,
    authType: String
) {
    // قبول کردن تمام گواهی‌ها؛ کاملاً ناامن
}

اگه سرور گواهی Self-signed داره، راه‌حل این نیست که تمام اعتبارسنجی‌ها رو خاموش کنیم. باید CA یا گواهی موردنظر رو به شکل محدود و مشخص داخل Network Security Configuration تعریف کنیم. مستندات اندروید هم استفاده از پیکربندی declarative رو به APIهای سفارشی و خطاپذیر اعتماد گواهی ترجیح میدن.

آیا Pin داخل APK قابل استخراج نیست؟

چرا، Pin داخل APK قرار داره و قابل استخراج است. اما این موضوع به خودی خود مشکل امنیتی نیست، چون Pin یک Secret نیست. Pin فقط هش کلید عمومی سروره و دانستن اون هیچ اجازه‌ای برای جعل سرور ایجاد نمی‌کنه. مهاجم برای ساخت گواهی مطابق با Pin به کلید خصوصی متناظر نیاز داره و کلید خصوصی نباید داخل برنامه یا مخزن کد اندروید وجود داشته باشه.

بنابراین نباید برای مخفی کردن Pin وقت زیادی صرف Obfuscation یا رمزگذاری عجیب کنیم. چیزی که باید محرمانه بمونه، کلید خصوصی سرور و کلید خصوصی Pin پشتیبانه. این کلیدها باید روی زیرساخت امن، Secret Manager یا HSM نگهداری بشن و هرگز وارد APK نشن.

چطور Pinning رو آزمایش کنیم؟

فقط اینکه برنامه به سرور وصل شد کافی نیست. باید مطمئن بشیم اتصال با Pin اشتباه واقعاً شکست می‌خوره و Pin پشتیبان هم به‌درستی کار می‌کنه. اولین آزمایش ساده اینه که موقتاً یک Pin اشتباه در نسخه Debug قرار بدیم و درخواست واقعی ارسال کنیم. درخواست باید با خطای اعتبارسنجی SSL شکست بخوره.

private const val WRONG_PIN =
    "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="

بعد این Pin رو به‌تنهایی داخل CertificatePinner قرار میدیم:

val certificatePinner =
    CertificatePinner.Builder()
        .add(
            "api.gheimatban.com",
            WRONG_PIN
        )
        .build()

در این شرایط اتصال نباید برقرار بشه. OkHttp هنگام شکست معمولاً Pinهای مشاهده‌شده در زنجیره گواهی رو داخل متن Exception نمایش میده. طبق مستندات OkHttp، یکی از روش‌های راه‌اندازی اولیه اینه که روی یک شبکه مورداعتماد عمداً Pin اشتباه تعریف کنیم، درخواست رو اجرا کنیم و Pinهای موردانتظار رو از خطا استخراج کنیم. این روش نباید روی شبکه ناشناس یا همراه ابزار MITM انجام بشه.

بعد باید سناریوی چرخش کلید رو هم قبل از انتشار بررسی کنیم. یعنی نسخه‌ای از برنامه که Pin اصلی و پشتیبان رو داره، باید بتواند هم با گواهی فعلی و هم با گواهی آزمایشی مبتنی بر کلید پشتیبان ارتباط برقرار کنه. بدون چنین آزمایشی، داشتن Pin پشتیبان فقط روی کاغذ باقی می‌مونه.

آیا قیمت‌بان واقعاً باید SSL Pinning داشته باشه؟

برای نسخه معمولی قیمت‌بان احتمالاً HTTPS استاندارد، اعتبارسنجی صحیح گواهی، احراز هویت امن، کنترل دسترسی سمت سرور و نگهداری درست توکن‌ها کافی هستن. Android به‌طور صریح Pinning رو برای همه اپلیکیشن‌ها توصیه نمی‌کنه، چون خطر قطع شدن اتصال در اثر تغییرات آینده سرور بالاست.

Pinning زمانی منطقی‌تره که پروژه اطلاعات بسیار حساس داشته باشه، تیم زیرساخت توان مدیریت چرخش کلید رو داشته باشه، Pin پشتیبان واقعی آماده شده باشه و نسخه‌های قدیمی برنامه هم به‌صورت فعال مدیریت بشن. صرف اینکه «Pinning امنیت رو بیشتر می‌کنه» دلیل کافی برای فعال کردن اون نیست. هر لایه امنیتی باید هزینه، خطر و برنامه نگهداری خودش رو داشته باشه.

برای قیمت‌بان می‌تونیم این تصمیم رو این‌طور بگیریم: اگه برنامه فقط قیمت‌های عمومی، علاقه‌مندی‌ها و هشدارهای ساده رو مدیریت می‌کنه، Pinning احتمالاً ارزش ریسک عملیاتی رو نداره. اما اگه بعداً اطلاعات مالی، پرداخت، داده‌های تجاری حساس یا دسترسی مدیریتی مهم به برنامه اضافه شد، میشه بعد از طراحی فرایند چرخش کلید و انتشار اضطراری، دوباره این گزینه رو بررسی کرد.

جمع‌بندی

SSL Pinning باعث میشه برنامه علاوه بر اعتبارسنجی عادی HTTPS، کلید عمومی سرور رو هم با Pinهای از قبل ثبت‌شده مقایسه کنه. این کار دامنه اعتماد رو محدود می‌کنه و بعضی حمله‌های Man-in-the-Middle رو سخت‌تر می‌کنه، اما امنیت مطلق ایجاد نمی‌کنه و روی دستگاه دست‌کاری‌شده قابل دور زدن است.

در اندروید می‌تونیم Pinning رو با Network Security Configuration یا CertificatePinner در OkHttp پیاده کنیم. روش XML قابلیت‌هایی مثل دامنه مشخص، Pin پشتیبان، تاریخ انقضا و Debug Override رو به‌صورت رسمی ارائه میده. در هر دو روش باید حداقل یک Pin پشتیبان واقعی داشته باشیم، چرخش کلید رو از قبل آزمایش کنیم و هیچ‌وقت بعد از شکست Pinning به اتصال ناامن برنگردیم.

مهم‌ترین درس این بخش اینه که Pinning فقط یک قطعه کد نیست؛ یک تعهد عملیاتی بلندمدته. اگه تیم نتونه گواهی‌ها، کلیدهای پشتیبان، نسخه‌های قدیمی و انتشار اضطراری رو درست مدیریت کنه، HTTPS استاندارد و سالم انتخاب امن‌تر و قابل‌اعتمادتری خواهد بود.


درس پنجاه‌ویکم

جلوگیری از Hook

اصلاً Hook کردن یعنی چی؟

فرض کن داخل قیمت‌بان متدی داریم که قبل از ارسال یک درخواست حساس بررسی می‌کنه آیا کاربر وارد حسابش شده یا نه. این متد در حالت عادی مقدار درست رو برمی‌گردونه و اجازه نمیده کاربر بدون توکن معتبر به بخش‌های محافظت‌شده وارد بشه. اما برنامه اندروید روی دستگاه کاربر اجرا میشه و شخصی که کنترل کامل دستگاه رو در اختیار داره، ممکنه هنگام اجرا رفتار همین متد رو تغییر بده. مثلاً کاری کنه متد بررسی ورود همیشه true برگردونه، نتیجه تشخیص Root رو دست‌کاری کنه، داده‌های ورودی و خروجی یک تابع رو بخونه یا توکن رو درست قبل از ارسال درخواست استخراج کنه. به این تغییر دادن رفتار برنامه در زمان اجرا، معمولاً Hooking گفته میشه.

Hook لزوماً به این معنی نیست که فایل APK از ابتدا تغییر کرده. گاهی برنامه اصلی نصب شده، اما ابزار دیگری هنگام اجرا وارد فرایند برنامه میشه و اجرای متدها رو کنترل می‌کنه. مهاجم می‌تونه قبل از اجرای یک متد، پارامترهای اون رو عوض کنه، اجازه نده بدنه اصلی اجرا بشه یا نتیجه ساختگی برگردونه. به همین دلیل چیزی که داخل سورس‌کد نوشته‌ایم، الزاماً همون چیزی نیست که روی یک دستگاه دست‌کاری‌شده اجرا میشه. ابزارهایی مثل Frida، Xposed و بعضی چارچوب‌های مبتنی بر Zygisk برای تحلیل امنیتی، تست نفوذ و همچنین دور زدن کنترل‌های برنامه استفاده میشن.

چرا جلوگیری کامل از Hook تقریباً ممکن نیست؟

قبل از هر پیاده‌سازی باید یک واقعیت مهم رو بپذیریم: روی دستگاهی که کاملاً در اختیار مهاجم قرار داره، جلوگیری صددرصدی از Hook ممکن نیست. کد برنامه، حافظه فرایند و محیط اجرا همگی روی دستگاه کاربر قرار دارن. اگه مهاجم دسترسی کافی داشته باشه، می‌تونه کنترل‌های ما رو پیدا کنه، نتیجه اون‌ها رو تغییر بده یا حتی خود متد تشخیص Hook رو Hook کنه. هر بررسی‌ای که داخل اپ انجام میشه، در نهایت خودش یک قطعه کده و بنابراین ممکنه هدف دست‌کاری قرار بگیره.

پس عنوان «جلوگیری از Hook» رو نباید به معنی ساختن یک دیوار غیرقابل‌عبور برداشت کنیم. هدف واقعی اینه که حمله رو سخت‌تر، پرهزینه‌تر و قابل‌شناسایی‌تر کنیم و مهم‌تر از همه، اجازه ندیم دست‌کاری کلاینت به تنهایی باعث دسترسی غیرمجاز به سرور بشه. یعنی حتی اگه مهاجم دکمه‌ای رو فعال کرد یا نتیجه یک شرط رو عوض کرد، سرور همچنان باید درخواست رو مستقل بررسی و در صورت نامعتبر بودن رد کنه. Play Integrity API هم خودش به عنوان بخشی از یک راهبرد چندلایه معرفی شده و گوگل توصیه می‌کنه نتیجه اون تنها معیار دفاعی برنامه نباشه.

خطر اصلی Hook برای قیمت‌بان چیه؟

در قیمت‌بان ممکنه Hook برای اهداف مختلفی استفاده بشه. مهاجم می‌تونه Interceptor شبکه رو Hook کنه و قبل از رمزنگاری، توکن یا اطلاعات درخواست رو بخونه. می‌تونه متد محاسبه محدودیت کاربر رو تغییر بده و تصور کنه قابلیت پولی براش فعال شده. حتی ممکنه پارامتر یک درخواست رو درست قبل از ارسال تغییر بده؛ مثلاً شناسه کاربر، شناسه هشدار قیمت یا تعداد درخواست‌های مجاز رو دست‌کاری کنه. اگه سرور به مقادیر ارسالی برنامه اعتماد کنه، یک Hook کوچک روی کلاینت می‌تونه به دسترسی جدی منجر بشه.

به همین دلیل اولین دفاع در برابر Hook اصلاً داخل اندروید نیست؛ روی سروره. سرور باید کاربر رو از روی Access Token شناسایی کنه، مالکیت هر داده رو بررسی کنه و محدودیت‌های پلن رو براساس اطلاعات دیتابیس خودش اعمال کنه. برنامه نباید برای سرور تعیین کنه کاربر Premium است یا اجازه حذف هشدار شماره ۸۲۵ رو داره. اپ فقط درخواست رو می‌فرسته و سرور خودش تصمیم نهایی رو می‌گیره.

این منطق ناامن است:

data class CreateAlertRequest(
    val userId: Long,
    val productId: Long,
    val isPremium: Boolean
)

چون مهاجم می‌تونه هنگام اجرا مقدار userId یا isPremium رو تغییر بده. مدل بهتر فقط داده‌ای رو می‌فرسته که واقعاً باید توسط کاربر انتخاب بشه:

data class CreateAlertRequest(
    val productId: Long,
    val targetPrice: Long
)

در این حالت سرور شناسه کاربر رو از توکن استخراج می‌کنه و وضعیت Premium رو هم از دیتابیس خودش می‌خونه. حتی اگه مهاجم مدل درخواست رو Hook کنه، نمی‌تونه با اضافه کردن یک Boolean خودش رو به کاربر پولی تبدیل کنه.

اول از همه نسخه Release رو سخت‌تر می‌کنیم

یکی از ساده‌ترین کارها برای بالا بردن هزینه مهندسی معکوس، فعال کردن R8 در نسخه Release است. R8 کدهای استفاده‌نشده رو حذف می‌کنه، نام بسیاری از کلاس‌ها و متدها رو تغییر میده و ساختار برنامه رو برای تحلیل‌گر ناخواناتر می‌کنه. این کار Hook رو متوقف نمی‌کنه، اما پیدا کردن متدی مثل isDeviceSafe رو سخت‌تر می‌کنه، چون ممکنه بعد از Obfuscation نام اون به چیزی مثل a تغییر کرده باشه.

android {
    buildTypes {
        release {
            isMinifyEnabled = true
            isShrinkResources = true

            proguardFiles(
                getDefaultProguardFile(
                    "proguard-android-optimize.txt"
                ),
                "proguard-rules.pro"
            )
        }
    }
}

البته مهاجم حرفه‌ای فقط به اسم متدها وابسته نیست و می‌تونه با بررسی مسیر اجرای برنامه یا درخواست‌های شبکه، بخش موردنظر رو پیدا کنه. پس Obfuscation یک لایه تأخیریه، نه یک مرز امنیتی. همچنین نباید با Ruleهای بیش از حد گسترده همه کلاس‌ها رو از Obfuscation خارج کنیم، چون در اون صورت بخش بزرگی از فایده R8 از بین میره.

مثلاً این Rule عملاً تمام کد پروژه رو با نام اصلی نگه می‌داره و انتخاب خوبی نیست:

-keep class com.example.gheimatban.** { *; }

بهتره فقط کلاس‌هایی رو نگه داریم که کتابخانه یا Serialization واقعاً به نام و ساختار اون‌ها نیاز داره. اگه از Retrofit و Gson یا Moshi استفاده می‌کنیم، Ruleها باید محدود و براساس نیاز واقعی تنظیم بشن.

Debuggable نباید در نسخه انتشار فعال باشه

برنامه Debuggable امکان اتصال Debugger و بعضی ابزارهای تحلیل رو ساده‌تر می‌کنه. Gradle در حالت عادی نسخه Debug رو با قابلیت Debug و نسخه Release رو بدون اون می‌سازه، اما ممکنه با تنظیم اشتباه Manifest یا Build Type، این ویژگی ناخواسته در نسخه نهایی فعال بمونه. بهتره هیچ‌وقت android:debuggable="true" رو به صورت ثابت داخل Manifest قرار ندیم.

نسخه درست Manifest معمولاً اصلاً این ویژگی رو مشخص نمی‌کنه:

<application
    android:name=".GheimatBanApplication"
    android:theme="@style/Theme.GheimatBan">

    <!-- Activities -->

</application>

در صورت نیاز می‌تونیم هنگام اجرا هم بررسی کنیم آیا برنامه Debuggable است یا نه. این بررسی به تنهایی ضد Hook نیست، اما می‌تونه جلوی انتشار اشتباه نسخه Debug رو بگیره.

fun Context.isAppDebuggable(): Boolean {
    return applicationInfo.flags and
        ApplicationInfo.FLAG_DEBUGGABLE != 0
}

مثلاً در نسخه Release می‌تونیم چنین وضعیت اشتباهی رو گزارش کنیم:

if (!BuildConfig.DEBUG && applicationContext.isAppDebuggable()) {
    securityReporter.report(
        SecurityEvent.UnexpectedDebuggableBuild
    )
}

نباید انتظار داشته باشیم که مهاجم نتونه نتیجه همین متد رو تغییر بده. ارزش اصلی این بررسی، پیدا کردن خطاهای بسته‌بندی و ایجاد یک سیگنال امنیتی اضافه است.

تشخیص اتصال Debugger

اندروید API ساده‌ای برای بررسی اتصال Debugger داره. می‌تونیم در زمان انجام یک عملیات حساس بررسی کنیم آیا Debugger به برنامه متصل شده یا برنامه منتظر اتصال Debugger است.

import android.os.Debug

fun isDebuggerAttached(): Boolean {
    return Debug.isDebuggerConnected() ||
        Debug.waitingForDebugger()
}

بعد نتیجه رو داخل یک ارزیابی چندلایه استفاده می‌کنیم:

if (isDebuggerAttached()) {
    securityReporter.report(
        SecurityEvent.DebuggerDetected
    )
}

این کد نباید به تنهایی باعث بسته شدن فوری برنامه بشه. ممکنه در نسخه آزمایشی خود تیم، ابزارهای تست یا بعضی محیط‌های خاص Debugger وجود داشته باشه. از طرف دیگر، مهاجم می‌تونه نتیجه Debug.isDebuggerConnected یا متد isDebuggerAttached رو Hook کنه و همیشه false برگردونه. بنابراین این فقط یک نشانه است و باید کنار نشانه‌های دیگر قرار بگیره.

بررسی امضای برنامه

وقتی یک مهاجم APK رو تغییر میده و دوباره بسته‌بندی می‌کنه، نمی‌تونه اون رو با کلید اصلی انتشار ما امضا کنه؛ مگر اینکه کلید امضای ما هم افشا شده باشه. پس می‌تونیم هش Certificate امضای نسخه نصب‌شده رو با مقدار موردانتظار مقایسه کنیم. این روش بیشتر برای تشخیص Repackaging و تغییر فایل APK کاربرد داره، نه Hook خالص روی نسخه اصلی، اما چون بسیاری از حملات Hook با نسخه دست‌کاری‌شده یا Loader اختصاصی همراه میشن، سیگنال مفیدی است.

ابتدا یک تابع برای خواندن هش SHA-256 گواهی امضا می‌سازیم:

import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.util.Base64
import java.security.MessageDigest

fun Context.getSigningCertificateHashes(): Set<String> {
    val packageInfo =
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
            packageManager.getPackageInfo(
                packageName,
                PackageManager.GET_SIGNING_CERTIFICATES
            )
        } else {
            @Suppress("DEPRECATION")
            packageManager.getPackageInfo(
                packageName,
                PackageManager.GET_SIGNATURES
            )
        }

    val signatures =
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
            val signingInfo = packageInfo.signingInfo

            if (signingInfo.hasMultipleSigners()) {
                signingInfo.apkContentsSigners
            } else {
                signingInfo.signingCertificateHistory
            }
        } else {
            @Suppress("DEPRECATION")
            packageInfo.signatures
        }

    return signatures.map { signature ->
        val digest =
            MessageDigest.getInstance("SHA-256")
                .digest(signature.toByteArray())

        Base64.encodeToString(
            digest,
            Base64.NO_WRAP
        )
    }.toSet()
}

بعد Certificateهای مجاز رو بررسی می‌کنیم:

private const val RELEASE_CERTIFICATE_SHA256 =
    "EXPECTED_BASE64_SHA256_HASH"

fun Context.hasExpectedSignature(): Boolean {
    return RELEASE_CERTIFICATE_SHA256 in
        getSigningCertificateHashes()
}

مقدار واقعی باید از Certificate رسمی نسخه Release گرفته بشه، نه اینکه مقدار نمونه داخل کد باقی بمونه. اگه Play App Signing فعال باشه، باید Certificate امضایی رو بررسی کنیم که Google Play واقعاً برای نسخه توزیع‌شده استفاده می‌کنه، نه Upload Certificate محلی. با این حال این کنترل هم داخل APK قرار داره و مهاجم می‌تونه مقدار موردانتظار، شرط یا نتیجه تابع رو دست‌کاری کنه. بنابراین بررسی سمت کلاینت رو باید فقط یک لایه اولیه بدونیم.

Play Integrity از بررسی محلی قابل‌اعتمادتره

برای بررسی قوی‌تر اصالت برنامه، گوگل Play Integrity API رو ارائه میده. این API می‌تونه به سرور کمک کنه تشخیص بده درخواست از نسخه‌ای اومده که Google Play اون رو می‌شناسه، با Certificate موردانتظار امضا شده و روی یک محیط قابل‌اعتمادتر اجرا میشه. نتیجه appIntegrity می‌تونه مشخص کنه آیا برنامه دست‌کاری‌نشده و شناخته‌شده توسط Google Play است یا نه. تصمیم اصلی هم باید روی Backend گرفته بشه، نه اینکه اپ توکن Integrity رو دریافت کنه و خودش نتیجه نهایی رو اعلام کنه.

روند استاندارد به این شکله:

برنامه یک عملیات حساس رو آماده می‌کنه
برنامه از داده‌های مهم درخواست Hash می‌سازه
Play Integrity یک توکن امضاشده برمی‌گردونه
برنامه درخواست و توکن رو برای Backend می‌فرسته
Backend توکن رو از طریق سرویس گوگل Decode می‌کنه
Backend نتیجه Integrity و Hash درخواست رو بررسی می‌کنه
Backend اجازه یا عدم اجازه عملیات رو تعیین می‌کنه

این تفاوت خیلی مهمه. اگه فقط داخل برنامه بنویسیم «در صورت معتبر بودن Integrity ادامه بده»، مهاجم می‌تونه همین شرط رو Hook کنه. اما وقتی توکن به سرور میره و سرور نتیجه رو بررسی می‌کنه، تغییر دادن یک شرط محلی کافی نیست.

کتابخانه Play Integrity رو اضافه می‌کنیم

ابتدا باید Play Integrity API رو در Play Console فعال و پروژه Google Cloud رو به برنامه متصل کنیم. بعد کتابخانه رسمی رو به ماژول اپ اضافه می‌کنیم. شماره نسخه کتابخانه ممکنه با گذشت زمان تغییر کنه، بنابراین باید نسخه جاری رو از مستندات رسمی انتخاب کنیم.

dependencies {
    implementation(
        "com.google.android.play:integrity:CURRENT_VERSION"
    )
}

برای Standard Request ابتدا یک Provider آماده می‌کنیم. این آماده‌سازی بهتره زودتر از لحظه عملیات حساس انجام بشه تا هنگام درخواست اصلی تأخیر کمتری داشته باشیم.

import android.content.Context
import com.google.android.play.core.integrity.IntegrityManagerFactory
import com.google.android.play.core.integrity.StandardIntegrityManager

class IntegrityTokenProvider(
    context: Context,
    private val cloudProjectNumber: Long
) {

    private val integrityManager =
        IntegrityManagerFactory
            .createStandard(context)

    private var tokenProvider:
        StandardIntegrityManager.StandardIntegrityTokenProvider? = null

    fun prepare(
        onSuccess: () -> Unit,
        onError: (Throwable) -> Unit
    ) {
        val request =
            StandardIntegrityManager
                .PrepareIntegrityTokenRequest
                .builder()
                .setCloudProjectNumber(
                    cloudProjectNumber
                )
                .build()

        integrityManager
            .prepareIntegrityToken(request)
            .addOnSuccessListener { provider ->
                tokenProvider = provider
                onSuccess()
            }
            .addOnFailureListener(onError)
    }
}

شماره Cloud Project محرمانه محسوب نمیشه و می‌تونه داخل برنامه وجود داشته باشه. چیزی که امنیت ایجاد می‌کنه، پاسخ امضاشده گوگل و اعتبارسنجی اون روی سروره، نه مخفی کردن این شماره.

درخواست رو به نتیجه Integrity متصل می‌کنیم

فرض کن کاربر می‌خواد یک هشدار قیمت باارزش ایجاد کنه. نباید یک Integrity Token عمومی بگیریم و برای چند درخواست مختلف دوباره استفاده کنیم. بهتره اطلاعات مهم همون درخواست رو با ترتیب ثابت Serialize کنیم، از اون SHA-256 بگیریم و Hash رو داخل requestHash قرار بدیم. در این صورت توکن Integrity فقط به محیط دستگاه وابسته نیست، بلکه به محتوای همان عملیات هم متصل میشه. گوگل توصیه می‌کنه پارامترهای مهم درخواست داخل Hash قرار بگیرن و سرور دوباره همون Hash رو محاسبه و مقایسه کنه. Standard Requestها همچنین در برابر استفاده مجدد مکرر از یک توکن محافظت دارن.

ابتدا تابع ساخت Hash رو می‌نویسیم:

import android.util.Base64
import java.security.MessageDigest

fun sha256Base64Url(input: String): String {
    val digest =
        MessageDigest.getInstance("SHA-256")
            .digest(input.toByteArray(Charsets.UTF_8))

    return Base64.encodeToString(
        digest,
        Base64.URL_SAFE or
            Base64.NO_WRAP or
            Base64.NO_PADDING
    )
}

بعد داده‌های درخواست رو با یک قالب ثابت می‌سازیم:

data class CreateAlertCommand(
    val productId: Long,
    val targetPrice: Long
) {
    fun stableContent(): String {
        return "productId=$productId" +
            "&targetPrice=$targetPrice"
    }
}

حالا Integrity Token رو برای همین درخواست می‌گیریم:

fun requestToken(
    command: CreateAlertCommand,
    onSuccess: (String) -> Unit,
    onError: (Throwable) -> Unit
) {
    val provider =
        tokenProvider ?: run {
            onError(
                IllegalStateException(
                    "Integrity provider is not prepared."
                )
            )
            return
        }

    val requestHash =
        sha256Base64Url(
            command.stableContent()
        )

    val request =
        StandardIntegrityManager
            .StandardIntegrityTokenRequest
            .builder()
            .setRequestHash(requestHash)
            .build()

    provider
        .request(request)
        .addOnSuccessListener { response ->
            onSuccess(response.token())
        }
        .addOnFailureListener(onError)
}

داده حساس نباید به صورت متن ساده داخل requestHash قرار بگیره. در اینجا فقط خروجی SHA-256 رو می‌فرستیم. همچنین فرمت stableContent باید در اپ و سرور دقیقاً یکسان باشه؛ چون اگه ترتیب فیلدها یا نحوه نمایش اعداد متفاوت باشه، Hashها با هم تطابق پیدا نمی‌کنن.

توکن Integrity رو همراه درخواست API می‌فرستیم

مدل درخواست می‌تونه توکن Integrity رو هم همراه اطلاعات عملیات ارسال کنه:

data class CreateAlertApiRequest(
    val productId: Long,
    val targetPrice: Long,
    val integrityToken: String
)

در ViewModel یا Repository اول توکن رو می‌گیریم و بعد درخواست اصلی رو می‌فرستیم:

fun createAlert(
    command: CreateAlertCommand
) {
    integrityTokenProvider.requestToken(
        command = command,
        onSuccess = { integrityToken ->
            viewModelScope.launch {
                alertRepository.createAlert(
                    CreateAlertApiRequest(
                        productId =
                            command.productId,
                        targetPrice =
                            command.targetPrice,
                        integrityToken =
                            integrityToken
                    )
                )
            }
        },
        onError = {
            showIntegrityError()
        }
    )
}

در پروژه واقعی بهتره APIهای Task-based گوگل رو با Coroutine هماهنگ کنیم، خطاها رو دسته‌بندی کنیم و آماده‌سازی Provider رو هم در زمان مناسب انجام بدیم. اما اصل معماری همین است: توکن باید نزدیک به عملیات حساس دریافت و به درخواست مشخصی متصل بشه. گوگل هم توصیه می‌کنه verdict رو تا جای ممکن نزدیک به عملیاتی درخواست کنیم که قصد محافظت از اون رو داریم.

تصمیم امنیتی باید روی سرور گرفته بشه

Backend بعد از دریافت integrityToken باید اون رو با سرویس Play Integrity Decode کنه. سپس باید چند بخش مهم پاسخ رو بررسی کنه: نام Package، نتیجه شناسایی برنامه، Certificate، نسخه برنامه، وضعیت دستگاه و requestHash. فقط اینکه توکن قابل Decode بوده کافی نیست. سرور باید مطمئن بشه Hash داخل verdict با Hashی که خودش از productId و targetPrice محاسبه کرده تطابق داره.

شبه‌منطق سمت سرور به این شکله:

Decode integrity token

Check package name

Check appRecognitionVerdict == PLAY_RECOGNIZED

Check expected signing certificate

Recalculate request hash

Compare calculated hash with verdict requestHash

Apply device-integrity policy

Check authenticated user permissions

Perform the requested operation

این متن کد نیست، بلکه ترتیب منطقی بررسی‌هاست. نکته کلیدی اینه که Integrity جای احراز هویت و Authorization رو نمی‌گیره. حتی یک برنامه سالم روی دستگاه سالم هم نباید اجازه داشته باشه اطلاعات کاربر دیگری رو ویرایش کنه. Integrity فقط میگه محیط درخواست چقدر قابل‌اعتماد به نظر میرسه؛ مجوز خود عملیات همچنان باید جداگانه بررسی بشه.

تشخیص Root یا ابزار Hook چقدر ارزش داره؟

می‌تونیم دنبال نشانه‌هایی مثل فایل‌های su، Package ابزارهای شناخته‌شده، مسیرهای Frida یا کتابخانه‌های بارگذاری‌شده بگردیم. اما این بررسی‌ها معمولاً مسابقه موش و گربه هستن. نام Package قابل تغییر است، فایل‌ها قابل مخفی شدن هستن و ابزارهای جدید می‌تونن نشانه‌های خودشون رو پنهان کنن. از طرف دیگر، یک تشخیص سخت‌گیرانه ممکنه کاربران سالم رو هم مسدود کنه؛ مثلاً دستگاه‌های سازمانی، ROMهای سفارشی یا ابزارهای Accessibility رفتار متفاوتی دارن.

بهتره این موارد رو به عنوان مجموعه‌ای از Signalها ببینیم، نه حکم قطعی. برای نمونه یک مدل ارزیابی محلی می‌سازیم:

data class LocalSecuritySignals(
    val debuggerAttached: Boolean,
    val signatureValid: Boolean,
    val appDebuggable: Boolean,
    val suspiciousLibrariesFound: Boolean
) {
    val riskScore: Int
        get() {
            var score = 0

            if (debuggerAttached) score += 2
            if (!signatureValid) score += 5
            if (appDebuggable) score += 2
            if (suspiciousLibrariesFound) score += 2

            return score
        }
}

بعد براساس امتیاز تصمیم سطح‌بندی‌شده می‌گیریم:

when {
    signals.riskScore >= 7 -> {
        blockSensitiveAction()
    }

    signals.riskScore >= 3 -> {
        requireServerIntegrityCheck()
    }

    else -> {
        continueNormally()
    }
}

حتی این تصمیم بهتره نهایی نباشه، چون مهاجم می‌تونه riskScore رو هم Hook کنه. ارزش اصلی سیگنال‌های محلی اینه که برای Telemetry، گزارش رفتار مشکوک و انتخاب زمان درخواست Play Integrity استفاده بشن. بخش‌های مهم همچنان باید توسط سرور کنترل بشن.

بررسی کتابخانه‌های بارگذاری‌شده

یکی از روش‌های تشخیص ابتدایی، بررسی فایل /proc/self/maps است. این فایل فهرستی از بخش‌های حافظه و کتابخانه‌های بارگذاری‌شده در فرایند فعلی رو نشون میده. گاهی نام ابزار یا کتابخانه مشکوک در اون دیده میشه. می‌تونیم دنبال چند واژه شناخته‌شده بگردیم، اما باید بدونیم که نام‌ها قابل تغییرن و نتیجه منفی به معنی سالم بودن قطعی محیط نیست.

import java.io.File

fun hasSuspiciousLoadedLibraries(): Boolean {
    val suspiciousTerms =
        listOf(
            "frida",
            "xposed",
            "substrate",
            "zygisk"
        )

    return runCatching {
        File("/proc/self/maps")
            .useLines { lines ->
                lines.any { line ->
                    suspiciousTerms.any { term ->
                        line.contains(
                            term,
                            ignoreCase = true
                        )
                    }
                }
            }
    }.getOrDefault(false)
}

این بررسی ممکنه False Positive و False Negative داشته باشه. نباید فقط به خاطر پیدا شدن یک کلمه، حساب کاربر رو مسدود یا حذف کنیم. بهتره این نتیجه کنار امضا، Debugger، Integrity و رفتار سرور ارزیابی بشه. همچنین نمایش جزئیات دقیق تشخیص به کاربر می‌تونه به مهاجم کمک کنه بفهمه کدوم کنترل فعال شده؛ برای کاربر عادی یک پیام عمومی کافی است.

کدهای حساس رو بین چند نقطه پخش نکن

بعضی توسعه‌دهنده‌ها تمام کنترل امنیت رو داخل یک متد می‌ذارن:

fun isDeviceSecure(): Boolean {
    return !isRooted() &&
        !isDebuggerAttached() &&
        hasExpectedSignature() &&
        !hasSuspiciousLoadedLibraries()
}

بعد در یک نقطه می‌نویسن:

if (isDeviceSecure()) {
    performSensitiveAction()
}

این طراحی برای مهاجم خیلی جذابه، چون فقط کافیه همین یک متد رو پیدا کنه و نتیجه اون رو همیشه true قرار بده. بهتره کنترل‌ها در چند لایه مستقل باشن: بعضی هنگام شروع برنامه، بعضی نزدیک عملیات حساس، بعضی داخل Repository و مهم‌ترین‌ها روی Backend. البته پراکنده‌سازی نباید به کد بی‌نظم تبدیل بشه؛ می‌تونیم یک SecurityMonitor مرکزی داشته باشیم، اما تصمیم‌های حساس فقط به یک Boolean محلی وابسته نباشن.

class SecurityMonitor(
    private val context: Context
) {

    fun collectLocalSignals(): LocalSecuritySignals {
        return LocalSecuritySignals(
            debuggerAttached =
                isDebuggerAttached(),
            signatureValid =
                context.hasExpectedSignature(),
            appDebuggable =
                context.isAppDebuggable(),
            suspiciousLibrariesFound =
                hasSuspiciousLoadedLibraries()
        )
    }
}

خروجی این کلاس می‌تونه برای گزارش و انتخاب سیاست استفاده بشه، اما Backend باید همچنان کنترل نهایی رو انجام بده.

رفتار مشکوک رو ثبت کن، اما اطلاعات حساس نفرست

وقتی نشانه‌ای از Hook یا Tampering پیدا میشه، بهتره یک رویداد امنیتی برای سرور ثبت کنیم. این گزارش می‌تونه شامل نسخه برنامه، نوع سیگنال، زمان رخداد، شناسه ناشناس Session و وضعیت Integrity باشه. نباید توکن، رمز عبور، اطلاعات خصوصی یا مسیرهای کامل حافظه رو داخل گزارش بفرستیم. همچنین سرور نباید به گزارش خود اپ اعتماد کامل داشته باشه، چون مهاجم می‌تونه ارسال اون رو متوقف یا محتوای اون رو تغییر بده.

data class SecurityEventRequest(
    val eventType: String,
    val appVersion: String,
    val sessionId: String,
    val occurredAt: Long
)

مثلاً:

securityApi.reportEvent(
    SecurityEventRequest(
        eventType = "DEBUGGER_DETECTED",
        appVersion = BuildConfig.VERSION_NAME,
        sessionId = sessionId,
        occurredAt = System.currentTimeMillis()
    )
)

ارزش اصلی این داده‌ها در تحلیل گروهی مشخص میشه. اگه یک حساب از ده‌ها دستگاه مشکوک، با الگوی درخواست غیرعادی و Integrity نامعتبر فعالیت کنه، سرور می‌تونه محدودیت بیشتری اعمال کنه. گوگل هم توصیه می‌کنه قبل از اجرای سیاست سخت‌گیرانه، مدتی verdictها رو فقط جمع‌آوری کنیم تا بفهمیم کاربران واقعی چه نتایجی دریافت می‌کنن و Enforcement باعث مسدود شدن چه تعداد کاربر سالم خواهد شد.

برخورد صفر و صدی معمولاً اشتباهه

فرض کن Play Integrity به دلیل قدیمی بودن Play Store پاسخ قابل‌استفاده‌ای نده. آیا باید فوراً تمام برنامه رو ببندیم؟ برای برنامه‌ای مثل قیمت‌بان احتمالاً نه. بهتره Enforcement سطح‌بندی‌شده داشته باشیم. مشاهده قیمت‌های عمومی می‌تونه بدون Integrity سخت‌گیرانه ادامه پیدا کنه، اما تغییر ایمیل، حذف حساب، خرید اشتراک یا عملیات مدیریتی نیازمند بررسی قوی‌تر باشه.

یک سیاست نمونه می‌تونه این‌طور باشه:

enum class SecurityDecision {
    ALLOW,
    ALLOW_WITH_LIMITS,
    REQUIRE_REAUTHENTICATION,
    BLOCK_SENSITIVE_ACTION
}

ولی بهتره تصمیم نهایی این Enum از پاسخ سرور بیاد:

data class SecurityDecisionResponse(
    val decision: SecurityDecision,
    val message: String?
)

سرور با ترکیب Integrity، سابقه حساب، نوع عملیات، Rate Limit و سایر سیگنال‌ها تصمیم می‌گیره. مثلاً درخواست مشاهده لیست کالاها رو مجاز می‌کنه، اما ساخت صدها هشدار قیمت یا تغییر اطلاعات حساس رو متوقف می‌کنه. این روش هم امنیت بهتری داره و هم احتمال آسیب زدن به کاربران سالم رو کاهش میده.

Play Integrity حتی سیگنال اختیاری appAccessRiskVerdict هم داره که می‌تونه وجود برنامه‌های در حال اجرا با قابلیت Capture، Overlay یا کنترل دستگاه رو گزارش کنه. سرویس‌های Accessibility تأییدشده توسط Google Play از این تشخیص مستثنا میشن تا ابزارهای قانونی دسترس‌پذیری بی‌دلیل علامت‌گذاری نشن. این اطلاعات برای عملیات‌های خیلی حساس مفیده، اما باز هم نباید بدون بررسی اثر اون روی کاربران واقعی، به مسدودسازی کامل منجر بشه.

پیام مناسب به کاربر چی باشه؟

اگه برنامه فقط بنویسه «Frida شناسایی شد» یا «Xposed پیدا شد»، هم برای کاربر عادی نامفهومه و هم جزئیات کنترل امنیتی رو به مهاجم اعلام می‌کنه. پیام بهتر باید روی عملی که فعلاً قابل انجام نیست تمرکز کنه، نه روش تشخیص.

مثلاً:

val message =
    "امکان تأیید امنیت این دستگاه وجود نداشت. " +
    "برای انجام این عملیات، نسخه رسمی برنامه رو " +
    "از Google Play نصب و دوباره تلاش کن."

در بعضی شرایط Play Integrity می‌تونه Remediation Dialog رسمی Google Play رو نمایش بده تا کاربر نسخه رسمی برنامه رو دریافت کنه یا مشکل قابل‌حل محیط رو برطرف کنه. این کار برای کاربران سالم بهتر از نمایش یک خطای بسته و بدون راه‌حله. مستندات رسمی پیشنهاد می‌کنن وقتی verdict مشکلی مثل نسخه ناشناخته یا دست‌کاری‌شده رو نشون میده، در صورت مناسب بودن از همین فرایندهای اصلاحی استفاده کنیم.

کارهایی که نباید انجام بدیم

نباید کل امنیت رو روی مخفی کردن نام کلاس‌ها یا چند شرط محلی بنا کنیم. نباید بعد از تشخیص ابزار مشکوک اطلاعات کاربر رو پاک کنیم، حسابش رو حذف کنیم یا برنامه رو عمداً Crash بدیم. این رفتار ممکنه به کاربران سالم آسیب بزنه و برای مهاجم حرفه‌ای هم مانع جدی ایجاد نمی‌کنه. همچنین نباید Secret Key دائمی داخل کد Native قرار بدیم و تصور کنیم چون فایل C++ سخت‌تر تحلیل میشه، کلید امن شده. کد Native تحلیل رو سخت‌تر می‌کنه، اما همچنان روی دستگاه اجرا میشه و قابل بررسی یا Hook است.

نباید Play Integrity Token رو فقط داخل برنامه Decode یا نتیجه اون رو محلی بررسی کنیم. توکن باید به سرور ارسال بشه و نتیجه روی Backend ارزیابی بشه. نباید یک Integrity Token رو برای تمام درخواست‌ها Cache کنیم، چون اتصال اون به عملیات خاص از بین میره و خطر سوءاستفاده بیشتر میشه. گوگل هم توصیه می‌کنه verdictها Cache نشن و برای Standard Request از requestHash استفاده بشه تا پاسخ به محتوای همان درخواست متصل بمونه.

جمع‌بندی

Hooking یعنی تغییر دادن رفتار برنامه در زمان اجرا؛ مثلاً عوض کردن پارامترها، خواندن توکن، رد نکردن شرط‌های امنیتی یا برگرداندن نتیجه ساختگی از یک متد. چون برنامه روی دستگاه کاربر اجرا میشه، هیچ روش محلی‌ای نمی‌تونه Hook رو به‌طور قطعی متوقف کنه. R8، بررسی Debugger، کنترل Certificate امضا، جست‌وجوی کتابخانه‌های مشکوک و بررسی Debuggable بودن فقط هزینه حمله رو بالا می‌برن و سیگنال‌های بیشتری برای شناسایی ایجاد می‌کنن.

دفاع اصلی قیمت‌بان باید روی Backend باشه. سرور نباید به شناسه کاربر، وضعیت Premium، محدودیت‌ها یا تصمیم‌های ارسال‌شده از اپ اعتماد کنه. برای عملیات حساس هم می‌تونیم از Play Integrity API استفاده کنیم، Hash داده‌های مهم درخواست رو داخل requestHash بذاریم و verdict رو روی سرور بررسی کنیم. در نهایت سرور باید براساس چند سیگنال مختلف تصمیم بگیره که عملیات رو مجاز، محدود یا مسدود کنه.

اصل مهم این درس اینه: سعی نکن Hook رو فقط با یک متد تشخیص بدی؛ کاری کن که حتی با Hook شدن برنامه، مهاجم نتونه سرور رو مجبور به انجام عملیات غیرمجاز کنه.


درس پنجاه‌ودوم

جلوگیری از Root Abuse

اصلاً Root شدن گوشی چه تغییری ایجاد می‌کنه؟

در حالت عادی، اندروید هر برنامه رو داخل یک محیط جدا اجرا می‌کنه و اجازه نمیده برنامه‌ها به‌راحتی فایل‌ها، حافظه و داده‌های خصوصی همدیگه رو بخونن. Root شدن دستگاه باعث میشه کاربر یا ابزارهای نصب‌شده روی گوشی به سطح دسترسی بسیار بالاتری برسن و بعضی محدودیت‌های سیستم‌عامل رو دور بزنن. در چنین محیطی ممکنه فایل‌های خصوصی برنامه خوانده بشن، توکن‌ها از حافظه استخراج بشن، رفتار متدها هنگام اجرا تغییر کنه یا نسخه دست‌کاری‌شده برنامه اجرا بشه. البته Root بودن دستگاه به‌تنهایی ثابت نمی‌کنه که صاحب گوشی مهاجمه؛ خیلی‌ها برای نصب رام سفارشی، شخصی‌سازی سیستم یا ابزارهای مدیریتی دستگاه خودشون رو Root می‌کنن. مسئله ما خود Root نیست، بلکه سوءاستفاده‌ایه که با کمک دسترسی Root انجام میشه.

فرض کن داخل قیمت‌بان تعداد هشدارهای رایگان رو در برنامه بررسی کرده باشیم. کاربر عادی فقط می‌تونه پنج هشدار قیمت بسازه، اما کاربر Premium اجازه ساخت تعداد بیشتری رو داره. روی دستگاه Rootشده، مهاجم ممکنه مقدار محلی isPremium رو تغییر بده، دیتابیس داخلی رو ویرایش کنه یا نتیجه متد محدودیت رو Hook کنه. اگه سرور به نتیجه‌ای که اپ فرستاده اعتماد کنه، مهاجم می‌تونه محدودیت رو دور بزنه. پس اولین اصل این درس اینه که Root Abuse رو نباید فقط با شناسایی Root حل کنیم؛ باید معماری رو طوری بسازیم که حتی روی دستگاه Rootشده، تصمیم‌های حساس همچنان در اختیار سرور بمونن.

تشخیص Root با جلوگیری از سوءاستفاده فرق داره

خیلی از آموزش‌ها چند مسیر مثل /system/bin/su رو بررسی می‌کنن و بعد میگن «دستگاه Root شده است». این نوع تشخیص ممکنه بعضی دستگاه‌ها رو شناسایی کنه، اما قابل‌اعتماد کامل نیست. ابزارهای Root می‌تونن فایل‌ها و Packageهای خودشون رو مخفی کنن، نتیجه متدهای بررسی رو تغییر بدن یا فقط برای برنامه ما محیط ظاهراً سالمی بسازن. از طرف دیگر، بعضی دستگاه‌های آزمایشی، رام‌های سفارشی یا محیط‌های خاص ممکنه بدون سوءاستفاده واقعی بعضی نشانه‌های Root رو داشته باشن.

بنابراین بهتره بین سه مفهوم تفاوت بذاریم. مفهوم اول Root Detection است؛ یعنی پیدا کردن نشانه‌های دسترسی سطح بالا. مفهوم دوم Device Integrity است؛ یعنی ارزیابی اینکه سیستم‌عامل، Bootloader و نسخه نصب‌شده برنامه چقدر قابل‌اعتماد به نظر میرسن. مفهوم سوم Abuse Prevention است؛ یعنی جلوگیری از اینکه یک محیط دست‌کاری‌شده بتونه عملیات غیرمجاز روی سرور انجام بده. مورد سوم هدف اصلی ماست. دو مورد اول فقط اطلاعاتی فراهم می‌کنن که برای رسیدن به هدف اصلی استفاده میشن.

تصمیم‌های حساس رو هیچ‌وقت به کلاینت نسپار

فرض کن برنامه برای ساخت هشدار قیمت چنین مدلی رو به سرور بفرسته:

data class CreateAlertRequest(
    val userId: Long,
    val productId: Long,
    val targetPrice: Long,
    val currentAlertCount: Int,
    val isPremium: Boolean
)

این مدل از نظر امنیتی مشکل داره. تمام این مقدارها داخل دستگاه ساخته میشن و روی یک گوشی Rootشده قابل تغییرن. مهاجم می‌تونه isPremium رو true کنه، تعداد هشدارها رو صفر بفرسته یا شناسه کاربر دیگری رو جایگزین کنه. مدل درخواست باید فقط شامل داده‌ای باشه که واقعاً کاربر انتخاب می‌کنه:

data class CreateAlertRequest(
    val productId: Long,
    val targetPrice: Long
)

سرور باید شناسه کاربر رو از Access Token استخراج کنه، نوع اشتراک رو از دیتابیس خودش بخونه و تعداد هشدارهای موجود رو خودش بشمره. منطق سمت سرور می‌تونه شبیه این باشه:

$userId = getAuthenticatedUserId();
$productId = (int) $request['productId'];
$targetPrice = (int) $request['targetPrice'];

$user = findUserById($userId);

if (!$user) {
    http_response_code(401);
    exit;
}

$alertCount = countActiveAlerts($userId);

$maximumAlerts = $user['is_premium'] ? 100 : 5;

if ($alertCount >= $maximumAlerts) {
    http_response_code(403);

    echo json_encode([
        'message' => 'Alert limit reached.'
    ]);

    exit;
}

createAlert(
    userId: $userId,
    productId: $productId,
    targetPrice: $targetPrice
);

حتی اگه مهاجم رابط کاربری Premium رو فعال کنه، دکمه‌های مخفی رو نمایش بده یا محدودیت محلی رو حذف کنه، سرور همچنان عملیات غیرمجاز رو رد می‌کنه. این مهم‌ترین دفاع در برابر Root Abuse است؛ نه پیدا کردن یک فایل خاص روی گوشی.

با یک بررسی محلی ساده شروع می‌کنیم

با اینکه بررسی‌های محلی قطعی نیستن، همچنان می‌تونن به عنوان Signal اولیه مفید باشن. مثلاً وجود فایل su، Packageهای شناخته‌شده مدیریت Root، Build Tagهای غیرمعمول یا قابل‌نوشتن بودن بعضی مسیرهای سیستمی می‌تونه ریسک محیط رو بالاتر ببره. بهتره این بررسی‌ها رو داخل یک کلاس مستقل قرار بدیم و نتیجه رو به شکل چند نشانه برگردونیم، نه یک Boolean قطعی.

data class RootSignals(
    val suBinaryFound: Boolean,
    val rootManagerFound: Boolean,
    val suspiciousBuildTags: Boolean,
    val dangerousSystemProperties: Boolean
) {
    val score: Int
        get() {
            var result = 0

            if (suBinaryFound) result += 3
            if (rootManagerFound) result += 2
            if (suspiciousBuildTags) result += 1
            if (dangerousSystemProperties) result += 2

            return result
        }
}

حالا کلاس بررسی رو می‌سازیم:

import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import java.io.File

class RootSignalCollector(
    private val context: Context
) {

    private val suPaths =
        listOf(
            "/system/bin/su",
            "/system/xbin/su",
            "/sbin/su",
            "/system/sd/xbin/su",
            "/system/bin/failsafe/su",
            "/data/local/su",
            "/data/local/bin/su",
            "/data/local/xbin/su"
        )

    private val rootManagerPackages =
        listOf(
            "com.topjohnwu.magisk",
            "eu.chainfire.supersu",
            "com.koushikdutta.superuser",
            "com.noshufou.android.su"
        )

    fun collect(): RootSignals {
        return RootSignals(
            suBinaryFound = hasSuBinary(),
            rootManagerFound = hasRootManager(),
            suspiciousBuildTags = hasSuspiciousBuildTags(),
            dangerousSystemProperties =
                hasDangerousSystemProperties()
        )
    }

    private fun hasSuBinary(): Boolean {
        return suPaths.any { path ->
            runCatching {
                File(path).exists()
            }.getOrDefault(false)
        }
    }

    private fun hasRootManager(): Boolean {
        return rootManagerPackages.any { packageName ->
            isPackageInstalled(packageName)
        }
    }

    private fun isPackageInstalled(
        packageName: String
    ): Boolean {
        return try {
            context.packageManager.getPackageInfo(
                packageName,
                PackageManager.PackageInfoFlags.of(0)
            )

            true
        } catch (
            exception: PackageManager.NameNotFoundException
        ) {
            false
        }
    }

    private fun hasSuspiciousBuildTags(): Boolean {
        return Build.TAGS
            ?.contains("test-keys") == true
    }

    private fun hasDangerousSystemProperties(): Boolean {
        val properties =
            listOf(
                "ro.debuggable" to "1",
                "ro.secure" to "0"
            )

        return properties.any { (name, expectedValue) ->
            readSystemProperty(name) == expectedValue
        }
    }

    private fun readSystemProperty(
        name: String
    ): String? {
        return runCatching {
            val process =
                Runtime.getRuntime().exec(
                    arrayOf(
                        "getprop",
                        name
                    )
                )

            process.inputStream
                .bufferedReader()
                .use { reader ->
                    reader.readLine()
                }
        }.getOrNull()
    }
}

این کد فقط چند نشانه رایج رو بررسی می‌کنه. نتیجه منفی به معنی سالم بودن قطعی دستگاه نیست و نتیجه مثبت هم به‌تنهایی نباید باعث حذف حساب یا بستن کامل برنامه بشه. ابزارهای Root می‌تونن این نشانه‌ها رو مخفی کنن و خود تابع collect هم قابل Hook شدنه. بنابراین این کلاس فقط برای جمع‌آوری اطلاعات اولیه، ثبت Telemetry و انتخاب سطح سخت‌گیری استفاده میشه.

چرا نباید فقط بگیم «Root پیدا شد، برنامه رو ببند»؟

فرض کن کاربر قیمت‌بان رو روی گوشی Rootشده نصب کرده، اما فقط می‌خواد قیمت کالاهای عمومی رو ببینه. بستن کامل برنامه احتمالاً واکنش بیش از حد سخت‌گیرانه‌ایه. مشاهده قیمت عمومی ریسک امنیتی زیادی نداره، اما تغییر ایمیل، خرید اشتراک، مدیریت حساب یا انجام عملیات مدیریتی حساس‌تره. پس بهتره سیاست ما متناسب با نوع عملیات باشه.

می‌تونیم عملیات‌ها رو دسته‌بندی کنیم:

enum class OperationSensitivity {
    PUBLIC,
    ACCOUNT,
    FINANCIAL,
    ADMINISTRATIVE
}

بعد نتیجه بررسی محلی رو به یک تصمیم اولیه تبدیل کنیم:

enum class LocalSecurityDecision {
    ALLOW,
    REQUIRE_SERVER_CHECK,
    RESTRICT_SENSITIVE_ACTIONS
}

fun decideLocally(
    signals: RootSignals,
    sensitivity: OperationSensitivity
): LocalSecurityDecision {
    if (sensitivity == OperationSensitivity.PUBLIC) {
        return LocalSecurityDecision.ALLOW
    }

    return when {
        signals.score >= 5 -> {
            LocalSecurityDecision.RESTRICT_SENSITIVE_ACTIONS
        }

        signals.score >= 2 -> {
            LocalSecurityDecision.REQUIRE_SERVER_CHECK
        }

        else -> {
            LocalSecurityDecision.ALLOW
        }
    }
}

این تصمیم هنوز نهایی نیست. مهاجم می‌تونه Score یا خروجی تابع رو تغییر بده. فایده اون بیشتر برای تجربه کاربری و مدیریت اولیه درخواست است. تصمیم واقعی برای عملیات حساس باید بعد از بررسی سرور انجام بشه.

برای بررسی جدی‌تر از Play Integrity استفاده می‌کنیم

Play Integrity API می‌تونه به Backend کمک کنه تشخیص بده درخواست از نسخه شناخته‌شده برنامه و یک دستگاه قابل‌اعتماد میاد یا نه. در verdict مربوط به Device Integrity، برچسب MEETS_DEVICE_INTEGRITY معمولاً نشون میده برنامه روی یک دستگاه واقعی و تأییدشده اجرا شده. در Android 13 و بالاتر، این verdict شامل اثبات سخت‌افزاری قفل بودن Bootloader و استفاده از تصویر تأییدشده سازنده هم میشه. اگه verdict خالی باشه، ممکنه دستگاه نشانه‌های Root، Hook، دست‌کاری سیستم یا محیط شبیه‌سازی‌شده نامعتبر داشته باشه.

کتابخانه فعلی Play Integrity رو به پروژه اضافه می‌کنیم:

dependencies {
    implementation(
        "com.google.android.play:integrity:1.6.0"
    )
}

نسخه 1.6.0 در یادداشت‌های انتشار رسمی به عنوان نسخه منتشرشده کتابخانه ثبت شده است.

برای Standard Request ابتدا Provider رو آماده می‌کنیم:

import android.content.Context
import com.google.android.play.core.integrity.IntegrityManagerFactory
import com.google.android.play.core.integrity.StandardIntegrityManager
import kotlinx.coroutines.tasks.await

class IntegrityProvider(
    context: Context,
    private val cloudProjectNumber: Long
) {

    private val manager =
        IntegrityManagerFactory.createStandard(context)

    private var preparedProvider:
        StandardIntegrityManager.StandardIntegrityTokenProvider? =
        null

    suspend fun prepare() {
        val request =
            StandardIntegrityManager
                .PrepareIntegrityTokenRequest
                .builder()
                .setCloudProjectNumber(
                    cloudProjectNumber
                )
                .build()

        preparedProvider =
            manager
                .prepareIntegrityToken(request)
                .await()
    }

    suspend fun requestToken(
        requestHash: String
    ): String {
        val provider =
            preparedProvider
                ?: error(
                    "Integrity provider is not prepared."
                )

        val request =
            StandardIntegrityManager
                .StandardIntegrityTokenRequest
                .builder()
                .setRequestHash(requestHash)
                .build()

        return provider
            .request(request)
            .await()
            .token()
    }
}

اگه از await استفاده می‌کنیم، وابستگی Coroutines Play Services هم لازمه:

dependencies {
    implementation(
        "org.jetbrains.kotlinx:" +
            "kotlinx-coroutines-play-services:" +
            "CURRENT_VERSION"
    )
}

توکن Integrity رو به همان درخواست حساس وصل می‌کنیم

فرض کن کاربر می‌خواد ایمیل حسابش رو تغییر بده. نباید یک توکن Integrity عمومی بگیریم و برای چند عملیات مختلف استفاده کنیم. بهتره Hash داده‌های اصلی همون درخواست رو بسازیم و داخل requestHash قرار بدیم. بعد سرور همون داده‌ها رو دوباره Hash می‌کنه و بررسی می‌کنه verdict واقعاً به همان درخواست مربوط بوده.

import android.util.Base64
import java.security.MessageDigest

fun sha256Base64Url(
    value: String
): String {
    val bytes =
        MessageDigest
            .getInstance("SHA-256")
            .digest(
                value.toByteArray(
                    Charsets.UTF_8
                )
            )

    return Base64.encodeToString(
        bytes,
        Base64.URL_SAFE or
            Base64.NO_WRAP or
            Base64.NO_PADDING
    )
}

مدل فرمان رو با یک نمایش ثابت می‌سازیم:

data class ChangeEmailCommand(
    val newEmail: String,
    val requestId: String
) {
    fun stableContent(): String {
        return buildString {
            append("newEmail=")
            append(newEmail.trim().lowercase())
            append("&requestId=")
            append(requestId)
        }
    }
}

حالا Repository قبل از ارسال درخواست، توکن Integrity رو دریافت می‌کنه:

data class ChangeEmailRequest(
    val newEmail: String,
    val requestId: String,
    val integrityToken: String
)

class AccountRepository(
    private val accountApi: AccountApi,
    private val integrityProvider: IntegrityProvider
) {

    suspend fun changeEmail(
        command: ChangeEmailCommand
    ) {
        val requestHash =
            sha256Base64Url(
                command.stableContent()
            )

        val integrityToken =
            integrityProvider.requestToken(
                requestHash
            )

        accountApi.changeEmail(
            ChangeEmailRequest(
                newEmail = command.newEmail,
                requestId = command.requestId,
                integrityToken = integrityToken
            )
        )
    }
}

توکن رمزگذاری‌شده باید به Backend فرستاده بشه و سرور با API رسمی گوگل اون رو Decode و Verify کنه. گوگل صراحتاً توصیه می‌کنه verdict روی سرور بررسی بشه و ابتدا جزئیات درخواست، از جمله requestHash، با درخواست اصلی تطابق داده بشه. Standard Integrity همچنین در برابر استفاده مجدد مکرر از یک توکن، محافظت خودکار Replay داره.

سرور دقیقاً چه چیزهایی رو بررسی می‌کنه؟

سرور نباید فقط دنبال عبارت MEETS_DEVICE_INTEGRITY بگرده. اول باید مطمئن بشه توکن واقعاً برای Package خود قیمت‌بان صادر شده، نسخه برنامه و Certificate امضا درست هستن و Hash درخواست با داده‌های دریافتی تطابق داره. بعد Device Integrity و نوع عملیات بررسی میشه. ترتیب منطقی می‌تونه چنین چیزی باشه:

نام Package درست است
نسخه برنامه قابل‌قبول است
Certificate امضا موردانتظار است
برنامه توسط Google Play شناخته شده است
Hash درخواست با داده‌های API تطابق دارد
Device Integrity سیاست لازم رو برآورده می‌کند
Access Token معتبر است
کاربر اجازه انجام عملیات رو دارد
Rate Limit نقض نشده است

نمونه ساده تصمیم‌گیری سمت سرور:

$verdict = decodeIntegrityToken(
    $request['integrityToken']
);

if (
    $verdict['packageName']
    !== 'com.example.gheimatban'
) {
    denyRequest('INVALID_APP');
}

$expectedHash = createRequestHash([
    'newEmail' => normalizeEmail(
        $request['newEmail']
    ),
    'requestId' => $request['requestId']
]);

if (
    !hash_equals(
        $expectedHash,
        $verdict['requestHash']
    )
) {
    denyRequest('REQUEST_MISMATCH');
}

$deviceVerdicts =
    $verdict['deviceRecognitionVerdict']
        ?? [];

$meetsDeviceIntegrity =
    in_array(
        'MEETS_DEVICE_INTEGRITY',
        $deviceVerdicts,
        true
    );

if (!$meetsDeviceIntegrity) {
    denyRequest('DEVICE_INTEGRITY_REQUIRED');
}

$userId = getAuthenticatedUserId();

changeUserEmail(
    userId: $userId,
    newEmail: $request['newEmail']
);

در پروژه واقعی، Decode کردن توکن باید از طریق درخواست Server-to-Server به سرویس Play Integrity انجام بشه و داده‌ها نباید مستقیماً از اپ قابل‌اعتماد فرض بشن. پاسخ Decodeشده شامل بخش‌هایی مثل requestDetails، appIntegrity، deviceIntegrity و اطلاعات محیطه که Backend براساس سیاست خودش ارزیابی می‌کنه.

همه عملیات‌ها به Strong Integrity نیاز ندارن

Play Integrity علاوه بر MEETS_DEVICE_INTEGRITY می‌تونه در صورت فعال‌سازی، برچسب‌هایی مثل MEETS_BASIC_INTEGRITY و MEETS_STRONG_INTEGRITY هم بده. MEETS_STRONG_INTEGRITY برای Android 13 و بالاتر علاوه بر سلامت دستگاه، به جدید بودن وصله‌های امنیتی بخش‌های سیستم و Vendor هم توجه می‌کنه. استفاده از این سطح برای تمام درخواست‌های عادی ممکنه کاربران سالمی رو که گوشی قدیمی‌تر یا آپدیت امنیتی عقب‌افتاده دارن، بی‌دلیل مسدود کنه.

برای قیمت‌بان می‌تونیم سیاست مرحله‌ای داشته باشیم. دیدن قیمت‌های عمومی حتی بدون verdict کامل مجاز باشه. ساخت هشدار قیمت به احراز هویت و کنترل محدودیت سمت سرور نیاز داشته باشه. تغییر ایمیل، حذف حساب یا خرید اشتراک به MEETS_DEVICE_INTEGRITY وابسته باشه. فقط عملیات‌های مدیریتی بسیار حساس یا پرداخت‌های پرریسک، در صورت نیاز واقعی، از MEETS_STRONG_INTEGRITY استفاده کنن. این روش احتمال False Positive و مسدود شدن بی‌دلیل کاربران سالم رو کم می‌کنه.

نتیجه سرور رو به مدل قابل‌مدیریت تبدیل می‌کنیم

بهتره Backend فقط یک خطای مبهم 403 برنگردونه. می‌تونه تصمیم امنیتی مشخصی به برنامه بده:

enum class SecurityAction {
    ALLOW,
    LIMIT,
    REQUIRE_REAUTHENTICATION,
    REQUIRE_INTEGRITY_REMEDIATION,
    BLOCK
}

data class SecurityResponse(
    val action: SecurityAction,
    val message: String?,
    val integrityDialogTypeCode: Int?
)

داخل ViewModel براساس پاسخ سرور رفتار می‌کنیم:

when (response.action) {
    SecurityAction.ALLOW -> {
        continueOperation()
    }

    SecurityAction.LIMIT -> {
        showLimitedMode()
    }

    SecurityAction.REQUIRE_REAUTHENTICATION -> {
        navigateToLogin()
    }

    SecurityAction.REQUIRE_INTEGRITY_REMEDIATION -> {
        showIntegrityRepair(
            response.integrityDialogTypeCode
        )
    }

    SecurityAction.BLOCK -> {
        showSecurityMessage(
            response.message
                ?: "این عملیات قابل انجام نیست."
        )
    }
}

نکته مهم اینه که کد Dialog باید از تصمیم سرور بیاد، چون Backend verdict واقعی رو دیده. برنامه نباید فقط براساس نتیجه Root Detection محلی تصمیم بگیره کدوم Dialog نمایش داده بشه.

به کاربر فرصت اصلاح وضعیت رو میدیم

Play Integrity امکان نمایش Remediation Dialog رو فراهم می‌کنه. Dialog نوع GET_INTEGRITY می‌تونه در بعضی شرایط به کاربر کمک کنه مشکلاتی مثل نسخه دست‌کاری‌شده برنامه، نصب غیرمجاز، مشکل Play Services یا ضعیف بودن Device Integrity رو برطرف کنه. گوگل توصیه می‌کنه سرور پس از بررسی verdict تعیین کنه کدوم Dialog باید نمایش داده بشه.

با نسخه‌های جدید کتابخانه، می‌تونیم از StandardIntegrityManager.showDialog استفاده کنیم:

import android.app.Activity
import com.google.android.play.core.integrity.StandardIntegrityDialogRequest
import com.google.android.play.core.integrity.StandardIntegrityManager
import kotlinx.coroutines.tasks.await

class IntegrityRemediationManager(
    private val manager: StandardIntegrityManager
) {

    suspend fun showDialog(
        activity: Activity,
        dialogTypeCode: Int,
        integrityToken: String
    ): Int {
        val request =
            StandardIntegrityDialogRequest
                .builder()
                .setActivity(activity)
                .setTypeCode(dialogTypeCode)
                .setStandardIntegrityResponse(
                    StandardIntegrityDialogRequest
                        .StandardIntegrityResponse
                        .TokenResponse(
                            integrityToken
                        )
                )
                .build()

        return manager
            .showDialog(request)
            .await()
    }
}

بعد از بسته شدن Dialog نباید فرض کنیم مشکل حتماً حل شده. باید Provider رو دوباره آماده کنیم، توکن تازه بگیریم و نتیجه جدید رو برای سرور بفرستیم. مستندات رسمی هم تأکید می‌کنن بعد از Remediation باید یک Integrity Check تازه انجام بشه.

اطلاعات حساس رو روی دستگاه Rootشده کمتر نگه دار

حتی با Play Integrity باید فرض کنیم دستگاه می‌تونه به خطر بیفته. Access Tokenها باید عمر کوتاه داشته باشن و Refresh Token باید قابل لغو باشه. اطلاعاتی که اپ واقعاً لازم نداره نباید در دیتابیس محلی ذخیره بشن. رمزها، Secret Keyهای سرور، توکن‌های مدیریتی و کلیدهای دائمی سرویس‌های خارجی هرگز نباید داخل APK قرار بگیرن. Android Keystore می‌تونه استخراج مستقیم کلیدها رو سخت‌تر کنه، اما روی دستگاهی که فرایند برنامه تحت کنترل مهاجم قرار گرفته، تضمین مطلق ایجاد نمی‌کنه.

همچنین بهتره عملیات‌های حساس به Session فعلی متصل باشن. مثلاً تغییر ایمیل فقط با Access Token، تأیید دوباره رمز یا کد یک‌بارمصرف و Integrity Token همان درخواست انجام بشه. اگه مهاجم یک توکن قدیمی رو از حافظه استخراج کرد، عمر کوتاه و کنترل‌های سرور دامنه آسیب رو محدود می‌کنن.

Root Signalها رو گزارش می‌کنیم، اما بهشون اعتماد کامل نداریم

می‌تونیم نتیجه بررسی‌های محلی رو برای تحلیل امنیتی به سرور بفرستیم:

data class DeviceSecurityTelemetry(
    val suBinaryFound: Boolean,
    val rootManagerFound: Boolean,
    val suspiciousBuildTags: Boolean,
    val localRiskScore: Int,
    val appVersion: String,
    val createdAt: Long
)

اما این گزارش نباید معیار قطعی باشه، چون مهاجم می‌تونه مقدارها رو تغییر بده یا ارسال گزارش رو متوقف کنه. کاربرد اصلی Telemetry پیدا کردن الگوهاست. مثلاً اگه یک حساب از ده‌ها دستگاه با verdict نامعتبر، درخواست‌های پرتعداد و رفتار غیرعادی استفاده کنه، سرور می‌تونه Rate Limit سخت‌تری اعمال کنه یا احراز هویت دوباره بخواد. Play Integrity حتی قابلیت‌هایی مثل Recent Device Activity و Device Recall رو ارائه میده که می‌تونن در شناسایی درخواست‌های پرتعداد یا سوءاستفاده تکرارشونده از یک دستگاه کمک کنن.

جمع‌بندی

Root Abuse یعنی استفاده از دسترسی سطح بالای دستگاه برای تغییر رفتار برنامه، استخراج داده‌ها، دور زدن محدودیت‌ها یا ساخت درخواست‌های غیرمجاز. پیدا کردن فایل su یا Packageهای مدیریت Root می‌تونه یک Signal اولیه بده، اما هیچ بررسی محلی‌ای قطعی نیست و هر متدی داخل اپ ممکنه Hook یا Patch بشه. بنابراین قیمت‌بان نباید تصمیم‌هایی مثل نوع اشتراک، مالکیت داده، تعداد هشدار مجاز یا اجازه انجام عملیات رو به کلاینت بسپره.

دفاع واقعی چندلایه است. برنامه نشانه‌های محلی رو جمع می‌کنه، عملیات‌های عمومی و حساس رو از هم جدا می‌کنه و برای درخواست‌های مهم Play Integrity Token می‌گیره. Backend توکن رو Decode می‌کنه، requestHash، اصالت برنامه، Device Integrity، Access Token، مالکیت داده و Rate Limit رو بررسی می‌کنه و بعد تصمیم نهایی رو می‌گیره. اگه مشکل قابل‌اصلاح باشه، برنامه می‌تونه Remediation Dialog رسمی Google Play رو نمایش بده و بعد یک Integrity Check تازه انجام بده.

اصل مهم این درس اینه: هدف ما ممنوع کردن همه گوشی‌های Rootشده نیست؛ هدف اینه که Root شدن دستگاه نتونه قدرت تصمیم‌گیری سرور رو از بین ببره.


درس پنجاه‌وسوم

محافظت از اطلاعات

محافظت از اطلاعات فقط یعنی رمزگذاری کردن؟

وقتی درباره محافظت از اطلاعات حرف می‌زنیم، اولین چیزی که معمولاً به ذهن میاد رمزگذاریه. اما رمزگذاری فقط یکی از لایه‌های محافظته. قبل از اینکه بپرسیم «این داده رو چطور Encrypt کنیم؟» باید بپرسیم اصلاً چرا این داده رو جمع می‌کنیم، کجا نگهش می‌داریم، تا چه زمانی بهش نیاز داریم، چه کسی باید بهش دسترسی داشته باشه و آیا واقعاً لازم است نسخه‌ای از اون روی گوشی باقی بمونه یا نه. داده‌ای که اصلاً ذخیره نشده، دزدیده هم نمیشه. داده‌ای که بعد از پایان نیاز پاک شده، در حمله آینده قابل استخراج نیست. اطلاعاتی که فقط روی سرور نگهداری میشن، از طریق خواندن دیتابیس محلی برنامه افشا نمیشن. بنابراین محافظت اطلاعات از کمینه‌سازی داده شروع میشه، نه از انتخاب الگوریتم رمزنگاری.

در قیمت‌بان همه داده‌ها حساسیت یکسانی ندارن. نام کالا، قیمت عمومی و دسته‌بندی محصول اطلاعات عمومی هستن. فهرست علاقه‌مندی‌های کاربر اطلاعات شخصی محسوب میشه، اما معمولاً حساسیت رمز عبور یا Refresh Token رو نداره. ایمیل، شماره تلفن، Session، توکن احراز هویت و اطلاعات مربوط به پرداخت حساس‌تر هستن. رمز عبور هم اصولاً نباید بعد از ورود در گوشی ذخیره بشه. این دسته‌بندی روی محل ذخیره، زمان نگهداری، امکان Backup و نحوه نمایش هر داده اثر می‌ذاره.

راهنمای امنیت اندروید هم توصیه می‌کنه برنامه فقط اطلاعات شخصی‌ای رو دریافت کنه که واقعاً به اون‌ها نیاز داره؛ چون کاهش دسترسی و نگهداری داده، سطح خطر رو هم کاهش میده.

اول مشخص می‌کنیم چه چیزی کجا ذخیره بشه

در اندروید چند محل اصلی برای نگهداری اطلاعات داریم. فایل‌های Internal Storage، دیتابیس خصوصی برنامه، DataStore و SharedPreferences در حالت عادی داخل Sandbox برنامه قرار می‌گیرن و برنامه‌های دیگر نمی‌تونن مستقیم بهشون دسترسی داشته باشن. Shared Storage یا حافظه عمومی برای فایل‌هایی ساخته شده که قرار است کاربر یا برنامه‌های دیگر هم ببینن؛ مثل تصویر، ویدئو یا سند قابل اشتراک. بنابراین قرار دادن توکن، فایل پروفایل خصوصی، خروجی دیتابیس یا اطلاعات حساب داخل Downloads یا مسیرهای عمومی انتخاب خطرناکیه.

راهنمای رسمی اندروید پیشنهاد می‌کنه اطلاعاتی که نباید در دسترس برنامه‌های دیگر باشن داخل Internal Storage، Preferences یا دیتابیس خصوصی برنامه نگهداری بشن. اطلاعات حساس نباید بی‌دلیل وارد Shared Storage بشن.

برای قیمت‌بان می‌تونیم چنین سیاستی داشته باشیم:

متن عمومی کالاها و قیمت‌های Cacheشده داخل Room ذخیره میشن. تنظیماتی مثل انتخاب Theme یا فعال بودن اعلان‌ها داخل DataStore قرار می‌گیرن. Access Token و Refresh Token به شکل رمزگذاری‌شده ذخیره میشن. تصاویر عمومی کالا می‌تونن داخل Cache قرار بگیرن. رمز عبور، کد یک‌بارمصرف، اطلاعات کامل کارت بانکی و Secret Key سرور اصلاً روی دستگاه نگهداری نمیشن.

نکته مهم اینه که Internal Storage به تنهایی به معنی رمزگذاری اختصاصی داده نیست. Sandbox جلوی دسترسی معمول برنامه‌های دیگر رو می‌گیره، اما در دستگاه Rootشده یا فرایند دست‌کاری‌شده ممکنه این محدودیت‌ها دور زده بشن. پس برای اطلاعات بسیار حساس، علاوه بر انتخاب محل خصوصی، رمزگذاری هم منطقیه.

DataStore امن‌تر از SharedPreferences نیست؛ مدرن‌تره

در پروژه‌های قدیمی معمولاً تنظیمات و حتی توکن‌ها داخل SharedPreferences ذخیره میشدن. برای پروژه‌های جدید، Android استفاده از DataStore رو توصیه می‌کنه، چون مبتنی بر Coroutine و Flow است و عملیات نوشتن رو به شکل سازگارتر و Transactional انجام میده. SharedPreferences API هم‌زمان داره، ممکنه Thread رابط کاربری رو متوقف کنه و در مدیریت خطا و دوام داده محدودیت‌هایی داره.

اما یک اشتباه مهم اینه که تصور کنیم DataStore خودش داده رو رمزگذاری می‌کنه. DataStore فقط محل و روش ذخیره‌سازی مدرن‌تریه. اگه Access Token رو مستقیماً داخل اون بنویسیم، مقدار توکن همچنان به شکل قابل‌خواندن داخل فایل DataStore قرار می‌گیره.

این کد برای تنظیمات عادی مناسبه:

val Context.settingsDataStore by preferencesDataStore(
    name = "settings"
)

object SettingsKeys {
    val DARK_MODE = booleanPreferencesKey("dark_mode")
    val NOTIFICATIONS_ENABLED =
        booleanPreferencesKey("notifications_enabled")
}

بعد تنظیمات رو ذخیره می‌کنیم:

class SettingsRepository(
    private val context: Context
) {

    val darkMode: Flow<Boolean> =
        context.settingsDataStore.data.map { preferences ->
            preferences[SettingsKeys.DARK_MODE] ?: false
        }

    suspend fun setDarkMode(enabled: Boolean) {
        context.settingsDataStore.edit { preferences ->
            preferences[SettingsKeys.DARK_MODE] = enabled
        }
    }
}

ولی نباید فقط به خاطر استفاده از DataStore، توکن رو بدون محافظت داخل اون قرار بدیم:

preferences[ACCESS_TOKEN] = accessToken

برای چنین داده‌ای ابتدا مقدار رو رمز می‌کنیم و بعد Ciphertext رو داخل DataStore می‌ذاریم.

Android Keystore دقیقاً از چی محافظت می‌کنه؟

Android Keystore برای نگهداری کلیدهای رمزنگاری ساخته شده، نه برای ذخیره مستقیم ایمیل، توکن یا فایل. وقتی یک کلید داخل Keystore ساخته میشه، Key Material به صورت معمول وارد حافظه برنامه نمیشه و قابل Export کردن نیست. عملیات رمزنگاری توسط زیرساخت سیستم انجام میشه و روی بعضی دستگاه‌ها کلید می‌تونه به سخت‌افزار امن مثل TEE یا Secure Element متصل بشه. همچنین میشه استفاده از کلید رو به شرایط مشخصی مثل احراز هویت کاربر، الگوریتم خاص یا حالت رمزنگاری مشخص محدود کرد.

مدل کلی ما این شکلیه:

Access Token

رمزگذاری با کلید داخل Android Keystore

Ciphertext و IV

ذخیره داخل DataStore

کلید اصلی داخل سورس، strings.xml، فایل Asset یا DataStore قرار نمی‌گیره. قرار دادن یک کلید ثابت داخل APK رمزگذاری واقعی ایجاد نمی‌کنه، چون مهاجم می‌تونه هم فایل رمز‌شده و هم کلید باز کردن اون رو از برنامه استخراج کنه. مستندات Android هم Hardcode کردن کلیدهای رمزنگاری داخل سورس یا Assetهای برنامه رو یک آسیب‌پذیری می‌دونن.

یک موتور رمزگذاری با AES-GCM می‌سازیم

برای رمزگذاری Tokenهای قیمت‌بان یک کلید AES داخل Android Keystore می‌سازیم. از حالت GCM استفاده می‌کنیم، چون علاوه بر محرمانه کردن داده، دست‌کاری شدن Ciphertext رو هم تشخیص میده. راهنمای Cryptography اندروید برای رمزنگاری متقارن، الگوریتم‌هایی مثل AES/GCM/NoPadding رو پیشنهاد می‌کنه.

ابتدا مدل نتیجه رمزگذاری رو تعریف می‌کنیم:

data class EncryptedValue(
    val cipherText: ByteArray,
    val initializationVector: ByteArray
)

حالا کلاس CryptoManager رو می‌سازیم:

package com.example.gheimatban.security

import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec

class CryptoManager {

    companion object {
        private const val KEY_ALIAS =
            "gheimatban_auth_token_key"

        private const val ANDROID_KEYSTORE =
            "AndroidKeyStore"

        private const val TRANSFORMATION =
            "AES/GCM/NoPadding"

        private const val AUTH_TAG_LENGTH_BITS = 128
    }

    private val keyStore: KeyStore =
        KeyStore.getInstance(
            ANDROID_KEYSTORE
        ).apply {
            load(null)
        }

    fun encrypt(
        plainText: ByteArray
    ): EncryptedValue {
        val cipher =
            Cipher.getInstance(TRANSFORMATION)

        cipher.init(
            Cipher.ENCRYPT_MODE,
            getOrCreateSecretKey()
        )

        val cipherText =
            cipher.doFinal(plainText)

        return EncryptedValue(
            cipherText = cipherText,
            initializationVector = cipher.iv
        )
    }

    fun decrypt(
        encryptedValue: EncryptedValue
    ): ByteArray {
        val cipher =
            Cipher.getInstance(TRANSFORMATION)

        val parameterSpec =
            GCMParameterSpec(
                AUTH_TAG_LENGTH_BITS,
                encryptedValue.initializationVector
            )

        cipher.init(
            Cipher.DECRYPT_MODE,
            getOrCreateSecretKey(),
            parameterSpec
        )

        return cipher.doFinal(
            encryptedValue.cipherText
        )
    }

    private fun getOrCreateSecretKey(): SecretKey {
        val existingKey =
            keyStore.getKey(
                KEY_ALIAS,
                null
            ) as? SecretKey

        if (existingKey != null) {
            return existingKey
        }

        val keyGenerator =
            KeyGenerator.getInstance(
                KeyProperties.KEY_ALGORITHM_AES,
                ANDROID_KEYSTORE
            )

        val keySpec =
            KeyGenParameterSpec.Builder(
                KEY_ALIAS,
                KeyProperties.PURPOSE_ENCRYPT or
                    KeyProperties.PURPOSE_DECRYPT
            )
                .setBlockModes(
                    KeyProperties.BLOCK_MODE_GCM
                )
                .setEncryptionPaddings(
                    KeyProperties.ENCRYPTION_PADDING_NONE
                )
                .setKeySize(256)
                .build()

        keyGenerator.init(keySpec)

        return keyGenerator.generateKey()
    }

    fun deleteKey() {
        if (keyStore.containsAlias(KEY_ALIAS)) {
            keyStore.deleteEntry(KEY_ALIAS)
        }
    }
}

در اولین استفاده، getOrCreateSecretKey یک کلید ۲۵۶ بیتی AES داخل Keystore می‌سازه. در دفعات بعد همان کلید قبلی رو برمی‌گردونه. هنگام رمزگذاری، Cipher یک IV تصادفی تولید می‌کنه و ما اون رو کنار Ciphertext ذخیره می‌کنیم. IV محرمانه نیست، اما برای رمزگشایی لازمه. چیزی که نباید ذخیره یا Export بشه خود Secret Key است.

هر بار رمزگذاری باید IV تازه داشته باشه. نباید یک IV ثابت تعریف کنیم یا IV عملیات قبلی رو دوباره استفاده کنیم. cipher.init(Cipher.ENCRYPT_MODE, key) خودش IV مناسب رو تولید می‌کنه و ما فقط خروجی اون رو نگه می‌داریم.

Ciphertext رو برای ذخیره به String تبدیل می‌کنیم

DataStore Preferences مستقیماً ByteArray رو ذخیره نمی‌کنه، پس Ciphertext و IV رو با Base64 به String تبدیل می‌کنیم. Base64 رمزگذاری امنیتی نیست؛ فقط بایت‌ها رو به متن قابل‌ذخیره تبدیل می‌کنه.

package com.example.gheimatban.security

import android.util.Base64

fun ByteArray.toBase64(): String {
    return Base64.encodeToString(
        this,
        Base64.NO_WRAP
    )
}

fun String.fromBase64(): ByteArray {
    return Base64.decode(
        this,
        Base64.NO_WRAP
    )
}

حالا می‌تونیم یک مخزن مخصوص توکن بسازیم.

SecureTokenStore رو با Keystore و DataStore می‌سازیم

ابتدا DataStore مربوط به احراز هویت رو تعریف می‌کنیم:

package com.example.gheimatban.data.auth

import android.content.Context
import androidx.datastore.preferences.preferencesDataStore

val Context.authDataStore by preferencesDataStore(
    name = "auth_secure"
)

کلیدهای DataStore رو هم می‌سازیم:

package com.example.gheimatban.data.auth

import androidx.datastore.preferences.core.stringPreferencesKey

object AuthPreferencesKeys {

    val ACCESS_TOKEN_CIPHER =
        stringPreferencesKey(
            "access_token_cipher"
        )

    val ACCESS_TOKEN_IV =
        stringPreferencesKey(
            "access_token_iv"
        )

    val REFRESH_TOKEN_CIPHER =
        stringPreferencesKey(
            "refresh_token_cipher"
        )

    val REFRESH_TOKEN_IV =
        stringPreferencesKey(
            "refresh_token_iv"
        )
}

حالا کلاس اصلی ذخیره توکن رو می‌نویسیم:

package com.example.gheimatban.data.auth

import android.content.Context
import androidx.datastore.preferences.core.edit
import com.example.gheimatban.security.CryptoManager
import com.example.gheimatban.security.EncryptedValue
import com.example.gheimatban.security.fromBase64
import com.example.gheimatban.security.toBase64
import kotlinx.coroutines.flow.first
import java.security.GeneralSecurityException

class SecureTokenStore(
    private val context: Context,
    private val cryptoManager: CryptoManager
) {

    suspend fun saveTokens(
        accessToken: String,
        refreshToken: String
    ) {
        val encryptedAccessToken =
            cryptoManager.encrypt(
                accessToken.toByteArray(
                    Charsets.UTF_8
                )
            )

        val encryptedRefreshToken =
            cryptoManager.encrypt(
                refreshToken.toByteArray(
                    Charsets.UTF_8
                )
            )

        context.authDataStore.edit { preferences ->
            preferences[
                AuthPreferencesKeys.ACCESS_TOKEN_CIPHER
            ] = encryptedAccessToken.cipherText.toBase64()

            preferences[
                AuthPreferencesKeys.ACCESS_TOKEN_IV
            ] = encryptedAccessToken
                .initializationVector
                .toBase64()

            preferences[
                AuthPreferencesKeys.REFRESH_TOKEN_CIPHER
            ] = encryptedRefreshToken.cipherText.toBase64()

            preferences[
                AuthPreferencesKeys.REFRESH_TOKEN_IV
            ] = encryptedRefreshToken
                .initializationVector
                .toBase64()
        }
    }

    suspend fun getAccessToken(): String? {
        return readToken(
            cipherKey =
                AuthPreferencesKeys.ACCESS_TOKEN_CIPHER,
            ivKey =
                AuthPreferencesKeys.ACCESS_TOKEN_IV
        )
    }

    suspend fun getRefreshToken(): String? {
        return readToken(
            cipherKey =
                AuthPreferencesKeys.REFRESH_TOKEN_CIPHER,
            ivKey =
                AuthPreferencesKeys.REFRESH_TOKEN_IV
        )
    }

    private suspend fun readToken(
        cipherKey:
            androidx.datastore.preferences.core.Preferences.Key<String>,
        ivKey:
            androidx.datastore.preferences.core.Preferences.Key<String>
    ): String? {
        val preferences =
            context.authDataStore.data.first()

        val cipherText =
            preferences[cipherKey]
                ?: return null

        val initializationVector =
            preferences[ivKey]
                ?: return null

        return try {
            val decryptedBytes =
                cryptoManager.decrypt(
                    EncryptedValue(
                        cipherText =
                            cipherText.fromBase64(),
                        initializationVector =
                            initializationVector.fromBase64()
                    )
                )

            decryptedBytes.toString(
                Charsets.UTF_8
            )
        } catch (
            exception: GeneralSecurityException
        ) {
            clearTokens()
            null
        } catch (
            exception: IllegalArgumentException
        ) {
            clearTokens()
            null
        }
    }

    suspend fun clearTokens() {
        context.authDataStore.edit { preferences ->
            preferences.remove(
                AuthPreferencesKeys.ACCESS_TOKEN_CIPHER
            )

            preferences.remove(
                AuthPreferencesKeys.ACCESS_TOKEN_IV
            )

            preferences.remove(
                AuthPreferencesKeys.REFRESH_TOKEN_CIPHER
            )

            preferences.remove(
                AuthPreferencesKeys.REFRESH_TOKEN_IV
            )
        }
    }
}

اگه فایل DataStore خراب یا دست‌کاری شده باشه، AES-GCM هنگام doFinal خطا میده و ما توکن‌ها رو پاک می‌کنیم. در این شرایط بهتره کاربر دوباره وارد حساب بشه، نه اینکه برنامه با داده مشکوک ادامه بده.

توجه کن که رمزگذاری محلی حمله رو غیرممکن نمی‌کنه. اگه مهاجم کنترل فرایند برنامه رو در اختیار داشته باشه، ممکنه زمانی که Token پس از رمزگشایی در حافظه استفاده میشه اون رو بخونه. Keystore و رمزگذاری بیشتر از داده در حالت سکون محافظت می‌کنن؛ یعنی وقتی فایل‌های برنامه کپی یا استخراج شدن، توکن فوراً قابل‌خواندن نباشه.

توکن رو بی‌دلیل در حافظه نگه ندار

بعد از رمزگشایی، Access Token برای مدت کوتاهی وارد حافظه برنامه میشه تا در درخواست API استفاده بشه. نباید اون رو در یک Singleton عمومی، متغیر قابل‌تغییر سراسری یا StateFlow قابل‌مشاهده توسط رابط کاربری نگه داریم. همچنین Token نباید وارد SavedStateHandle، Bundle یا Navigation Argument بشه؛ چون این محل‌ها برای انتقال و بازیابی State طراحی شدن، نه نگهداری اطلاعات احراز هویت.

مدل ساده TokenProvider می‌تونه فقط هنگام نیاز Token رو بخونه:

interface TokenProvider {

    suspend fun getAccessToken(): String?

    suspend fun getRefreshToken(): String?

    suspend fun saveTokens(
        accessToken: String,
        refreshToken: String
    )

    suspend fun clear()
}

پیاده‌سازی اون به SecureTokenStore وصل میشه:

class DefaultTokenProvider(
    private val secureTokenStore: SecureTokenStore
) : TokenProvider {

    override suspend fun getAccessToken(): String? {
        return secureTokenStore.getAccessToken()
    }

    override suspend fun getRefreshToken(): String? {
        return secureTokenStore.getRefreshToken()
    }

    override suspend fun saveTokens(
        accessToken: String,
        refreshToken: String
    ) {
        secureTokenStore.saveTokens(
            accessToken = accessToken,
            refreshToken = refreshToken
        )
    }

    override suspend fun clear() {
        secureTokenStore.clearTokens()
    }
}

چون Interceptor عادی OkHttp تابع هم‌زمان داره، نباید بدون فکر داخل اون عملیات سنگین Coroutine انجام بدیم. در معماری واقعی معمولاً Access Token کوتاه‌عمر رو در یک Cache محدود حافظه نگه می‌داریم و Refresh Token فقط هنگام نیاز از ذخیره‌ساز امن خونده میشه. نکته مهم اینه که Cache نباید عمومی، قابل Log یا وابسته به UI باشه و هنگام خروج کاربر پاک بشه.

هنگام خروج فقط صفحه رو عوض نکن

Logout واقعی یعنی اطلاعات نشست از چند جا پاک بشه. فقط رفتن به صفحه Login کافی نیست. باید Access Token و Refresh Token محلی پاک بشن، Cookieهای احتمالی حذف بشن، داده‌های شخصی Cacheشده در صورت نیاز حذف بشن و Refresh Token روی سرور هم Revoke بشه.

class LogoutUseCase(
    private val authApi: AuthApi,
    private val tokenProvider: TokenProvider,
    private val privateDatabase: PrivateDatabase,
    private val imageCache: UserImageCache
) {

    suspend operator fun invoke() {
        val refreshToken =
            tokenProvider.getRefreshToken()

        runCatching {
            if (refreshToken != null) {
                authApi.logout(
                    LogoutRequest(
                        refreshToken = refreshToken
                    )
                )
            }
        }

        tokenProvider.clear()
        privateDatabase.clearAllTables()
        imageCache.clearUserImages()
    }
}

حتی اگه درخواست Logout به سرور شکست خورد، اطلاعات محلی باید پاک بشن. البته Backend هم بهتره امکان لغو Session از راه‌های دیگر، مثل خروج از همه دستگاه‌ها یا تغییر رمز عبور، داشته باشه. Access Tokenهای کوتاه‌عمر هم باعث میشن در صورت نرسیدن درخواست Revoke، دسترسی قبلی برای مدت نامحدود باقی نمونه.

اطلاعات حساس نباید داخل Log ظاهر بشن

یکی از رایج‌ترین نشت‌ها از مسیر رمزنگاری نیست؛ از مسیر Log اتفاق میفته. ممکنه برنامه‌نویس هنگام Debug کل پاسخ ورود، Headerها یا مدل کاربر رو چاپ کنه و بعد همین Logging ناخواسته وارد نسخه Release بشه. توکن، ایمیل، شماره تلفن، کد یک‌بارمصرف، Cookie و پاسخ کامل سرور نباید داخل Log نوشته بشن. Android هم توصیه می‌کنه از ثبت اطلاعات شخصی در Log خودداری کنیم و Logging نسخه Production رو محدود نگه داریم.

برای OkHttp باید Logging فقط در نسخه Debug فعال باشه و Headerهای حساس هم Redact بشن:

fun createLoggingInterceptor():
    HttpLoggingInterceptor {

    return HttpLoggingInterceptor().apply {
        level =
            if (BuildConfig.DEBUG) {
                HttpLoggingInterceptor.Level.BASIC
            } else {
                HttpLoggingInterceptor.Level.NONE
            }

        redactHeader("Authorization")
        redactHeader("Cookie")
        redactHeader("Set-Cookie")
        redactHeader("X-Api-Key")
    }
}

سطح BODY رو فقط برای مدت محدود و روی APIهای غیرحساس استفاده می‌کنیم. حتی در نسخه Debug هم چاپ Body ورود، Refresh Token یا اطلاعات خصوصی انتخاب خوبی نیست.

می‌تونیم یک Logger مرکزی بسازیم که در نسخه Release چیزی چاپ نکنه:

object AppLogger {

    fun debug(
        tag: String,
        message: String
    ) {
        if (BuildConfig.DEBUG) {
            Log.d(tag, message)
        }
    }

    fun error(
        tag: String,
        message: String,
        throwable: Throwable? = null
    ) {
        if (BuildConfig.DEBUG) {
            Log.e(
                tag,
                message,
                throwable
            )
        }
    }
}

نباید چنین چیزی بنویسیم:

AppLogger.debug(
    tag = "Auth",
    message = "Access token: $accessToken"
)

حتی اگه Logger در Release خاموش باشه، همین Log ممکنه روی گوشی توسعه‌دهنده، Screen Share یا گزارش خطا باقی بمونه.

Crash Report هم می‌تونه اطلاعات رو لو بده

ابزارهای Crash Reporting معمولاً Stack Trace، Breadcrumb، نام صفحه و Metadataهای اضافه رو جمع می‌کنن. نباید مدل کامل کاربر، Body درخواست، Token یا شماره تلفن رو به عنوان Custom Key ارسال کنیم. برای شناسایی کاربر بهتره از یک شناسه داخلی یا Hash کنترل‌شده استفاده کنیم، نه اطلاعات قابل‌شناسایی مستقیم.

نمونه نامناسب:

crashReporter.setCustomKey(
    "login_response",
    loginResponse.toString()
)

چون toString ممکنه Access Token و اطلاعات شخصی رو هم وارد گزارش کنه.

مدل امن‌تر اینه که فقط وضعیت لازم ثبت بشه:

crashReporter.setCustomKey(
    "auth_state",
    "authenticated"
)

crashReporter.setCustomKey(
    "api_operation",
    "refresh_prices"
)

پیام خطای Backend هم باید پاک‌سازی بشه. Stack Trace PHP، Query دیتابیس، مسیر فایل سرور و تنظیمات داخلی نباید به اپ فرستاده بشن.

صفحه‌های حساس رو از Screenshot محافظت می‌کنیم

اگه برنامه صفحه‌ای شامل اطلاعات حساس داشته باشه، ممکنه تصویر اون در Recent Apps، Screenshot یا Screen Recording دیده بشه. اندروید پرچم FLAG_SECURE رو برای جلوگیری از ثبت محتوای Window در Screenshot و نمایش اون روی نمایشگرهای غیرامن ارائه میده.

در یک Activity می‌تونیم این پرچم رو فعال کنیم:

override fun onCreate(
    savedInstanceState: Bundle?
) {
    super.onCreate(savedInstanceState)

    window.setFlags(
        WindowManager.LayoutParams.FLAG_SECURE,
        WindowManager.LayoutParams.FLAG_SECURE
    )

    setContent {
        PaymentScreen()
    }
}

اما فعال کردن FLAG_SECURE روی کل قیمت‌بان احتمالاً تجربه کاربری رو بی‌دلیل خراب می‌کنه. کاربر ممکنه بخواد تصویر قیمت کالا رو برای دیگری بفرسته. بنابراین این محدودیت فقط باید روی صفحه‌هایی مثل اطلاعات پرداخت، بازیابی حساب، کدهای حساس یا پنل مدیریتی اعمال بشه.

در Compose می‌تونیم یک Effect قابل‌استفاده مجدد بسازیم:

@Composable
fun SecureScreenEffect(
    enabled: Boolean
) {
    val view = LocalView.current

    DisposableEffect(enabled) {
        val activity =
            view.context.findActivity()

        if (enabled) {
            activity?.window?.addFlags(
                WindowManager.LayoutParams.FLAG_SECURE
            )
        }

        onDispose {
            activity?.window?.clearFlags(
                WindowManager.LayoutParams.FLAG_SECURE
            )
        }
    }
}

تابع پیدا کردن Activity:

fun Context.findActivity(): Activity? {
    var currentContext = this

    while (
        currentContext is ContextWrapper
    ) {
        if (currentContext is Activity) {
            return currentContext
        }

        currentContext =
            currentContext.baseContext
    }

    return null
}

بعد داخل صفحه حساس استفاده می‌کنیم:

@Composable
fun ChangePasswordScreen() {
    SecureScreenEffect(
        enabled = true
    )

    // محتوای صفحه
}

وقتی صفحه از Composition خارج بشه، پرچم پاک میشه و باقی صفحه‌های برنامه دوباره قابل Screenshot خواهند بود.

Clipboard محل امن نگهداری اطلاعات نیست

وقتی کاربر چیزی رو Copy می‌کنه، اون داده وارد Clipboard سیستم میشه و ممکنه توسط Keyboard، پیش‌نمایش Clipboard یا برنامه‌های دیگر دیده بشه. نباید رمز عبور، Refresh Token یا کدهای مدیریتی رو خودکار Copy کنیم. اگه به دلیل تجربه کاربری لازم شد مقدار حساسی مثل کد بازیابی موقت کپی بشه، بهتره اون رو به عنوان Sensitive علامت‌گذاری کنیم تا پیش‌نمایش واضحش در رابط Clipboard یا Keyboard نمایش داده نشه.

Android از API 33 به بعد می‌تونه از ClipDescription.EXTRA_IS_SENSITIVE استفاده کنه و برای نسخه‌های قدیمی‌تر هم کلید سازگار متنی وجود داره.

fun copySensitiveText(
    context: Context,
    label: String,
    value: String
) {
    val clipboardManager =
        context.getSystemService(
            Context.CLIPBOARD_SERVICE
        ) as ClipboardManager

    val clipData =
        ClipData.newPlainText(
            label,
            value
        )

    val extras =
        PersistableBundle().apply {
            if (
                Build.VERSION.SDK_INT >=
                Build.VERSION_CODES.TIRAMISU
            ) {
                putBoolean(
                    ClipDescription.EXTRA_IS_SENSITIVE,
                    true
                )
            } else {
                putBoolean(
                    "android.content.extra.IS_SENSITIVE",
                    true
                )
            }
        }

    clipData.description.extras = extras
    clipboardManager.setPrimaryClip(clipData)
}

این علامت محتوا رو Encrypt نمی‌کنه؛ فقط نمایش آشکار اون رو محدودتر می‌کنه. پس هنوز هم نباید برای داده‌های خیلی حساس به Clipboard تکیه کنیم.

Backup می‌تونه فایل خصوصی برنامه رو هم منتقل کنه

یک نکته‌ای که خیلی راحت فراموش میشه اینه که Auto Backup اندروید به صورت پیش‌فرض بخش بزرگی از داده‌های خصوصی برنامه رو پشتیبان‌گیری می‌کنه؛ از جمله Shared Preferences، فایل‌های Internal Storage و دیتابیس‌ها. بنابراین خصوصی بودن فایل داخل Sandbox به این معنی نیست که اون فایل هرگز از دستگاه خارج نمیشه. Android امکان تعیین قوانین Backup و حذف فایل‌های حساس رو فراهم می‌کنه.

در Android 12 به بعد، فایل data-extraction-rules رو تعریف می‌کنیم. داخل res/xml/backup_rules.xml می‌نویسیم:

<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>

    <cloud-backup
        disableIfNoEncryptionCapabilities="true">

        <exclude
            domain="sharedpref"
            path="auth_secure.preferences_pb" />

        <exclude
            domain="database"
            path="private_user.db" />

        <exclude
            domain="file"
            path="sensitive/" />

    </cloud-backup>

    <device-transfer>

        <exclude
            domain="sharedpref"
            path="auth_secure.preferences_pb" />

        <exclude
            domain="database"
            path="private_user.db" />

        <exclude
            domain="file"
            path="sensitive/" />

    </device-transfer>

</data-extraction-rules>

برای Android 11 و پایین‌تر هم فایل جداگانه‌ای مثل backup_rules_legacy.xml می‌سازیم:

<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>

    <exclude
        domain="sharedpref"
        path="auth_secure.preferences_pb" />

    <exclude
        domain="database"
        path="private_user.db" />

    <exclude
        domain="file"
        path="sensitive/" />

</full-backup-content>

بعد هر دو رو در Manifest معرفی می‌کنیم:

<application
    android:name=".GheimatBanApplication"
    android:dataExtractionRules="@xml/backup_rules"
    android:fullBackupContent="@xml/backup_rules_legacy"
    android:theme="@style/Theme.GheimatBan">

    <!-- Activities -->

</application>

حذف توکن‌ها از Backup مهمه، چون توکن Session نباید روی یک دستگاه جدید Restore بشه و بدون ورود دوباره کاربر ادامه پیدا کنه. همچنین کلید Android Keystore ممکنه روی دستگاه جدید وجود نداشته باشه؛ در نتیجه Ciphertext بازیابی‌شده قابل رمزگشایی نخواهد بود. رفتار امن اینه که بعد از انتقال دستگاه، کاربر دوباره وارد حساب بشه.

فایل‌هایی که داخل getNoBackupFilesDir ذخیره میشن به طور خودکار از Backup کنار گذاشته میشن. برای داده‌ای که هرگز نباید منتقل بشه، این مسیر هم انتخاب مناسبیه:

val sensitiveDirectory =
    File(
        context.noBackupFilesDir,
        "sensitive"
    ).apply {
        mkdirs()
    }

اطلاعات Room رو هم براساس حساسیت جدا می‌کنیم

لازم نیست کل دیتابیس قیمت‌ها رو فقط به خاطر چند فیلد حساس رمزگذاری کنیم. طراحی بهتر اینه که داده عمومی و خصوصی رو از هم جدا کنیم. مثلاً جدول قیمت کالاها می‌تونه Cache عمومی باشه و حتی بعد از خروج کاربر باقی بمونه. اما تاریخچه شخصی جست‌وجو، هشدارهای خصوصی و اطلاعات حساب باید در جدول‌ها یا دیتابیس جدا قرار بگیرن تا هنگام Logout به‌راحتی پاک بشن.

@Database(
    entities = [
        ProductEntity::class,
        PriceEntity::class
    ],
    version = 1
)
abstract class PublicCacheDatabase :
    RoomDatabase() {

    abstract fun productDao(): ProductDao
}

دیتابیس خصوصی جدا:

@Database(
    entities = [
        UserAlertEntity::class,
        PrivateSearchEntity::class
    ],
    version = 1
)
abstract class PrivateDatabase :
    RoomDatabase() {

    abstract fun alertDao(): AlertDao

    abstract fun privateSearchDao():
        PrivateSearchDao
}

با این جداسازی، هنگام Logout فقط دیتابیس خصوصی پاک میشه:

privateDatabase.clearAllTables()

در عین حال Cache عمومی قیمت‌ها باقی می‌مونه و برنامه بعد از خروج هم سریع باز میشه.

نباید اطلاعاتی مثل Access Token رو داخل Entity کاربر قرار بدیم:

@Entity
data class UserEntity(
    @PrimaryKey
    val id: Long,
    val displayName: String,
    val accessToken: String
)

توکن متعلق به لایه احراز هویته و باید چرخه عمر و محافظت جداگانه‌ای داشته باشه.

ورودی‌ها و خروجی‌های برنامه رو هم محدود می‌کنیم

محافظت اطلاعات فقط درباره فایل داخل گوشی نیست. ممکنه داده از طریق Intent، Deep Link، Exported Component یا FileProvider نشت کنه. Activity یا Service نباید بی‌دلیل exported=true باشه. همچنین فایل خصوصی رو نباید با URI نوع file:// یا Permission دائمی در اختیار برنامه دیگری بذاریم.

یک Activity داخلی:

<activity
    android:name=".ui.private.PrivateProfileActivity"
    android:exported="false" />

برای اشتراک کنترل‌شده یک فایل، از FileProvider و مجوز موقت استفاده می‌کنیم:

val uri =
    FileProvider.getUriForFile(
        context,
        "${context.packageName}.fileprovider",
        file
    )

val shareIntent =
    Intent(Intent.ACTION_SEND).apply {
        type = "application/pdf"
        putExtra(
            Intent.EXTRA_STREAM,
            uri
        )

        addFlags(
            Intent.FLAG_GRANT_READ_URI_PERMISSION
        )
    }

فقط فایلی باید Share بشه که واقعاً برای اشتراک ساخته شده. نباید مسیر اصلی دیتابیس، فایل تنظیمات یا پوشه خصوصی برنامه رو در اختیار FileProvider قرار بدیم.

اطلاعات حساس رو داخل Notification کامل نمایش نده

اعلان ممکنه روی Lock Screen دیده بشه. مثلاً اگه پیام اعلان شامل ایمیل کامل، موجودی مالی یا جزئیات خصوصی کاربر باشه، هر کسی که صفحه گوشی رو می‌بینه می‌تونه اون رو بخونه. برای Notificationهای حساس میشه نسخه عمومی جدا تعریف کرد:

val publicNotification =
    NotificationCompat.Builder(
        context,
        PRIVATE_CHANNEL_ID
    )
        .setSmallIcon(
            R.drawable.ic_notification
        )
        .setContentTitle(
            "GheimatBan"
        )
        .setContentText(
            "یک اعلان جدید داری."
        )
        .build()

بعد اعلان اصلی:

val privateNotification =
    NotificationCompat.Builder(
        context,
        PRIVATE_CHANNEL_ID
    )
        .setSmallIcon(
            R.drawable.ic_notification
        )
        .setContentTitle(
            "هشدار قیمت"
        )
        .setContentText(
            "قیمت کالای موردعلاقه‌ات کاهش پیدا کرد."
        )
        .setVisibility(
            NotificationCompat.VISIBILITY_PRIVATE
        )
        .setPublicVersion(
            publicNotification
        )
        .build()

بهتره متن اعلان هم تا جای ممکن کمینه باشه. کاربر بعد از باز کردن برنامه و عبور از قفل دستگاه می‌تونه جزئیات کامل رو ببینه.

پاک کردن داده یعنی تمام Referenceها هم پاک بشن

گاهی توکن رو از DataStore حذف می‌کنیم، اما نسخه دیگری از اون داخل متغیر حافظه، CookieJar، Queue درخواست‌های آفلاین یا WorkManager باقی مونده. خروج امن باید تمام مسیرها رو در نظر بگیره. Workهای مربوط به حساب قبلی باید Cancel بشن و عملیات Pending نباید بعد از ورود کاربر دیگری اجرا بشن.

class SessionCleaner(
    private val context: Context,
    private val tokenProvider: TokenProvider,
    private val privateDatabase: PrivateDatabase,
    private val cookieJar: ClearableCookieJar
) {

    suspend fun clearSession() {
        tokenProvider.clear()
        privateDatabase.clearAllTables()
        cookieJar.clear()

        WorkManager
            .getInstance(context)
            .cancelAllWorkByTag(
                "USER_PRIVATE_SYNC"
            )
    }
}

همچنین هر Work خصوصی باید به شناسه Session وابسته باشه. وقتی Worker اجرا میشه، بررسی کنه هنوز همان Session فعال است. در غیر این صورت بدون ارسال اطلاعات متوقف بشه.

آیا باید همه‌چیز رو رمزگذاری کنیم؟

رمزگذاری هزینه و پیچیدگی داره. اگه تمام Cache قیمت کالاها رو Encrypt کنیم، مدیریت Migration، Backup، خرابی کلید و عملکرد برنامه پیچیده‌تر میشه؛ در حالی که خود قیمت‌ها عمومی هستن. بهتره رمزگذاری رو براساس مدل تهدید استفاده کنیم.

اطلاعات عمومی مثل نام و قیمت کالا معمولاً به رمزگذاری اختصاصی نیاز ندارن. داده‌های شخصی کم‌خطر بهتره داخل Storage خصوصی قرار بگیرن و چرخه پاک‌سازی روشنی داشته باشن. توکن‌ها، کلیدهای محلی و اطلاعات حساس باید رمزگذاری بشن. رمز عبور، کلید خصوصی سرور، Secret Key سرویس‌های خارجی و اطلاعاتی که اپ نیازی به اون‌ها نداره اصلاً نباید ذخیره بشن.

رمزگذاری بی‌هدف ممکنه حس امنیت کاذب ایجاد کنه. اگه برنامه بعد از رمزگشایی Token رو داخل Log چاپ کنه، امن بودن فایل ذخیره‌سازی فایده‌ای نداره. اگه Backup اشتباه تنظیم شده باشه، Ciphertext و فایل‌های خصوصی ممکنه به دستگاه دیگر منتقل بشن. اگه Backend اجازه دسترسی غیرمجاز بده، رمزگذاری دیتابیس گوشی جلوی نشت سرور رو نمی‌گیره. محافظت اطلاعات باید از لحظه جمع‌آوری تا ذخیره، نمایش، انتقال، Backup، گزارش خطا و حذف نهایی ادامه داشته باشه.

جمع‌بندی

محافظت از اطلاعات در قیمت‌بان از این تصمیم شروع میشه که چه داده‌ای واقعاً باید جمع‌آوری و ذخیره بشه. داده عمومی، داده شخصی و داده احراز هویت نباید با یک سیاست یکسان مدیریت بشن. اطلاعات خصوصی باید داخل Storage اختصاصی برنامه قرار بگیرن و داده‌های بسیار حساس مثل Tokenها باید قبل از ذخیره شدن با کلیدی داخل Android Keystore رمزگذاری بشن.

در این درس یک CryptoManager با AES/GCM/NoPadding ساختیم، Ciphertext و IV رو داخل DataStore قرار دادیم و یک SecureTokenStore برای ذخیره و بازیابی Tokenها پیاده کردیم. بعد دیدیم که امنیت فقط به فایل ختم نمیشه؛ Logها، Crash Reportها، Screenshot، Clipboard، Notification، Backup، Room، WorkManager و Componentهای Exportشده هم ممکنه باعث افشای اطلاعات بشن.

مهم‌ترین اصل این درس اینه: هر داده‌ای باید فقط به اندازه لازم، در امن‌ترین محل متناسب با حساسیتش و فقط تا زمانی که واقعاً بهش نیاز داریم نگهداری بشه. رمزگذاری مهمه، اما محافظت واقعی زمانی شکل می‌گیره که کل چرخه زندگی اطلاعات رو کنترل کنیم.