Wprowadzenie — dlaczego bezpieczeństwo aplikacji mobilnych jest tak ważne w 2026 roku
Nie będę owijał w bawełnę — rok 2026 to pod względem bezpieczeństwa mobilnego prawdziwe pole minowe. Raporty OWASP i ENISA mówią same za siebie: liczba ataków na aplikacje mobilne wzrosła o ponad 40% w porównaniu z 2024 rokiem, a średni koszt naruszenia danych w sektorze mobilnym przekroczył 4,5 miliona dolarów. To nie są abstrakcyjne liczby — to realne straty, które dotykają firmy i ich użytkowników.
.NET MAUI jako dojrzała platforma do tworzenia aplikacji wieloplatformowych oferuje sporo mechanizmów bezpieczeństwa. Problem w tym, że same mechanizmy to za mało — trzeba je prawidłowo wdrożyć.
Krajobraz zagrożeń obejmuje dziś nie tylko klasyczne wektory ataku, jak przechwytywanie komunikacji sieciowej (Man-in-the-Middle) czy inżynieria odwrotna. Mamy do czynienia z coraz bardziej wyrafinowanymi technikami — od ataków na łańcuch dostaw (supply chain attacks), przez wykorzystanie podatności w bibliotekach natywnych, aż po omijanie uwierzytelniania biometrycznego z użyciem deepfake. Szczerze mówiąc, tempo ewolucji tych zagrożeń potrafi zaskoczyć nawet doświadczonych deweloperów.
W tym przewodniku przeprowadzimy Cię przez najważniejsze mechanizmy bezpieczeństwa dostępne w .NET MAUI. Zaczniemy od SecureStorage, przejdziemy przez uwierzytelnianie biometryczne i zarządzanie tokenami OAuth2, a skończymy na szyfrowaniu baz danych i zabezpieczaniu komunikacji sieciowej. Każda sekcja zawiera gotowy do użycia kod, który możesz zaadaptować w swoich projektach.
SecureStorage w .NET MAUI — bezpieczne przechowywanie wrażliwych danych
SecureStorage to wbudowany mechanizm .NET MAUI, który umożliwia bezpieczne przechowywanie niewielkich porcji danych w postaci par klucz-wartość. To preferowane rozwiązanie do przechowywania tokenów, kluczy API i danych uwierzytelniających — czyli wszystkiego, co nie powinno leżeć w postaci jawnego tekstu.
Architektura platformowa — jak SecureStorage działa pod maską
Kluczowa zaleta SecureStorage? Na każdej platformie wykorzystuje natywne, sprawdzone mechanizmy kryptograficzne systemu operacyjnego. Oto jak to wygląda w praktyce:
- Android — wykorzystuje klasę
EncryptedSharedPreferencesz biblioteki AndroidX Security. Dane szyfrowane są algorytmem AES-256 w trybie GCM (Galois/Counter Mode), co zapewnia zarówno poufność, jak i integralność danych. Klucz główny (master key) przechowywany jest w Android Keystore — sprzętowym module kryptograficznym niedostępnym dla innych aplikacji. - iOS / macOS — wykorzystuje natywny KeyChain, który jest jednym z najbezpieczniejszych mechanizmów przechowywania danych na platformach Apple. Dane w KeyChain są szyfrowane na poziomie systemu i chronione przez Secure Enclave.
- Windows — korzysta z klasy
DataProtectionProvider, szyfrując dane za pomocą DPAPI (Data Protection API) powiązanego z kontem użytkownika Windows.
Praktyczne użycie SecureStorage — kompletne przykłady kodu
Interfejs ISecureStorage udostępnia trzy kluczowe metody: SetAsync do zapisywania, GetAsync do odczytywania i Remove do usuwania danych. Poniżej kompletna klasa serwisowa, którą sam stosuję jako punkt wyjścia w swoich projektach:
using Microsoft.Maui.Storage;
namespace MojaAplikacja.Services;
/// <summary>
/// Serwis odpowiedzialny za bezpieczne przechowywanie wrażliwych danych.
/// Wykorzystuje natywne mechanizmy kryptograficzne każdej platformy.
/// </summary>
public class BezpiecznyMagazynService : IBezpiecznyMagazynService
{
private readonly ISecureStorage _secureStorage;
// Stałe definiujące klucze — unikamy magicznych ciągów znaków
private const string KluczTokenDostepu = "access_token";
private const string KluczTokenOdswiezania = "refresh_token";
private const string KluczCzasWygasniecia = "token_expiry";
private const string KluczIdentyfikatorUzytkownika = "user_id";
public BezpiecznyMagazynService(ISecureStorage secureStorage)
{
_secureStorage = secureStorage;
}
/// <summary>
/// Zapisuje token dostępu wraz z czasem wygaśnięcia.
/// </summary>
public async Task ZapiszTokenDostepuAsync(string token, DateTime wygasa)
{
await _secureStorage.SetAsync(KluczTokenDostepu, token);
await _secureStorage.SetAsync(KluczCzasWygasniecia,
wygasa.ToString("O")); // Format ISO 8601
}
/// <summary>
/// Pobiera token dostępu. Zwraca null, jeśli token nie istnieje
/// lub wygasł.
/// </summary>
public async Task<string?> PobierzTokenDostepuAsync()
{
var token = await _secureStorage.GetAsync(KluczTokenDostepu);
if (string.IsNullOrEmpty(token))
return null;
var czasWygasnieciaStr = await _secureStorage.GetAsync(KluczCzasWygasniecia);
if (DateTime.TryParse(czasWygasnieciaStr, out var wygasa)
&& wygasa < DateTime.UtcNow)
{
// Token wygasł — usuwamy go z magazynu
UsunTokeny();
return null;
}
return token;
}
/// <summary>
/// Zapisuje token odświeżania.
/// </summary>
public async Task ZapiszTokenOdswiezaniaAsync(string refreshToken)
{
await _secureStorage.SetAsync(KluczTokenOdswiezania, refreshToken);
}
/// <summary>
/// Pobiera token odświeżania.
/// </summary>
public async Task<string?> PobierzTokenOdswiezaniaAsync()
{
return await _secureStorage.GetAsync(KluczTokenOdswiezania);
}
/// <summary>
/// Usuwa wszystkie przechowywane tokeny — wywoływane przy wylogowaniu.
/// </summary>
public void UsunTokeny()
{
_secureStorage.Remove(KluczTokenDostepu);
_secureStorage.Remove(KluczTokenOdswiezania);
_secureStorage.Remove(KluczCzasWygasniecia);
}
/// <summary>
/// Całkowicie czyści magazyn — przydatne przy resetowaniu aplikacji.
/// </summary>
public void WyczyscWszystko()
{
_secureStorage.RemoveAll();
}
}
Rejestracja serwisu w kontenerze DI w pliku MauiProgram.cs:
builder.Services.AddSingleton<ISecureStorage>(SecureStorage.Default);
builder.Services.AddSingleton<IBezpiecznyMagazynService, BezpiecznyMagazynService>();
Ograniczenia i najlepsze praktyki
Zanim rzucisz się na SecureStorage z entuzjazmem, jest kilka rzeczy, o których warto wiedzieć:
- Tylko ciągi znaków — SecureStorage przechowuje wyłącznie dane typu
string. Obiekty złożone trzeba serializować do JSON przed zapisem. - Limity pojemności — to nie jest miejsce na duże zbiory danych. Na Androidzie limit wynosi ok. 1 MB na plik SharedPreferences.
- Brak synchronizacji — dane nie są synchronizowane między urządzeniami użytkownika.
- Odinstalowanie na iOS — uwaga, to pułapka! Dane w KeyChain mogą przetrwać odinstalowanie aplikacji na iOS. Rozważ czyszczenie danych przy pierwszym uruchomieniu po instalacji.
- Backup na Androidzie — domyślnie dane z EncryptedSharedPreferences mogą trafić do kopii zapasowych. Skonfiguruj reguły wykluczenia w pliku
AndroidManifest.xml.
Uwierzytelnianie biometryczne w .NET MAUI
Uwierzytelnianie biometryczne — odcisk palca albo rozpoznawanie twarzy — stało się standardem w nowoczesnych aplikacjach mobilnych. I słusznie. To wygodne dla użytkownika i jednocześnie bezpieczne. W ekosystemie .NET MAUI najlepszym rozwiązaniem do implementacji biometrii jest pakiet Oscore.Maui.Biometric (następca popularnego Plugin.Fingerprint), w pełni dostosowany do najnowszych wersji frameworka.
Instalacja i konfiguracja pakietu
Zaczynamy od instalacji pakietu NuGet:
<!-- W pliku .csproj projektu -->
<ItemGroup>
<PackageReference Include="Oscore.Maui.Biometric" Version="1.3.0" />
</ItemGroup>
Następnie rejestrujemy serwis biometryczny w MauiProgram.cs:
using Oscore.Maui.Biometric;
namespace MojaAplikacja;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.UseBiometric() // Rejestracja serwisu biometrycznego
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
});
// Rejestracja pozostałych serwisów
builder.Services.AddSingleton<ISecureStorage>(SecureStorage.Default);
builder.Services.AddTransient<StronaLogowania>();
return builder.Build();
}
}
Konfiguracja na Androidzie
W AndroidManifest.xml dodajemy uprawnienie USE_BIOMETRIC:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="false"
android:supportsRtl="true"
android:networkSecurityConfig="@xml/network_security_config">
</application>
</manifest>
Konfiguracja na iOS
W pliku Info.plist musimy dodać opis użycia Face ID — Apple tego wymaga:
<key>NSFaceIDUsageDescription</key>
<string>Aplikacja wykorzystuje Face ID do bezpiecznego uwierzytelniania
i ochrony Twoich danych.</string>
Kompletna implementacja uwierzytelniania biometrycznego
No dobrze, przejdźmy do konkretów. Poniżej pełna implementacja serwisu biometrycznego z obsługą różnych scenariuszy:
using Oscore.Maui.Biometric;
namespace MojaAplikacja.Services;
/// <summary>
/// Serwis zarządzający uwierzytelnianiem biometrycznym.
/// Obsługuje sprawdzanie dostępności, typ biometrii oraz sam proces
/// uwierzytelniania.
/// </summary>
public class SerwisUwierzytelnianiBiometrycznego
{
private readonly IBiometric _biometric;
public SerwisUwierzytelnianiBiometrycznego(IBiometric biometric)
{
_biometric = biometric;
}
/// <summary>
/// Sprawdza, czy urządzenie obsługuje uwierzytelnianie biometryczne
/// i czy użytkownik je skonfigurował.
/// </summary>
public async Task<(bool Dostepne, string Komunikat)> SprawdzDostepnoscAsync()
{
var typBiometrii = await _biometric.GetBiometricTypeAsync();
return typBiometrii switch
{
BiometricType.Fingerprint => (true, "Dostępny odcisk palca"),
BiometricType.Face => (true, "Dostępne rozpoznawanie twarzy"),
BiometricType.None => (false,
"Biometria nie jest dostępna na tym urządzeniu " +
"lub nie została skonfigurowana."),
_ => (false, "Nieznany typ biometrii")
};
}
/// <summary>
/// Przeprowadza uwierzytelnianie biometryczne.
/// Zwraca true, jeśli użytkownik został pomyślnie uwierzytelniony.
/// </summary>
public async Task<WynikUwierzytelniania> UwierzytelnijAsync(
string powod = "Potwierdź swoją tożsamość, aby kontynuować")
{
try
{
// Najpierw sprawdzamy dostępność
var (dostepne, komunikat) = await SprawdzDostepnoscAsync();
if (!dostepne)
{
return new WynikUwierzytelniania
{
Sukces = false,
KomunikatBledu = komunikat
};
}
// Konfigurujemy żądanie uwierzytelniania
var request = new AuthenticationRequest
{
Title = "Uwierzytelnianie",
Subtitle = powod,
NegativeButtonText = "Anuluj",
AllowPasswordAuth = true // Fallback na hasło/PIN
};
// Wykonujemy uwierzytelnianie
var result = await _biometric.AuthenticateAsync(request);
return new WynikUwierzytelniania
{
Sukces = result.IsAuthenticated,
KomunikatBledu = result.IsAuthenticated
? null
: "Uwierzytelnianie nie powiodło się. Spróbuj ponownie."
};
}
catch (Exception ex)
{
return new WynikUwierzytelniania
{
Sukces = false,
KomunikatBledu = $"Wystąpił błąd podczas uwierzytelniania: {ex.Message}"
};
}
}
}
/// <summary>
/// Model wyniku uwierzytelniania biometrycznego.
/// </summary>
public class WynikUwierzytelniania
{
public bool Sukces { get; set; }
public string? KomunikatBledu { get; set; }
}
A tak wygląda użycie serwisu na stronie logowania:
namespace MojaAplikacja.Views;
public partial class StronaLogowania : ContentPage
{
private readonly SerwisUwierzytelnianiBiometrycznego _serwiBiometrii;
private readonly IBezpiecznyMagazynService _magazyn;
public StronaLogowania(
SerwisUwierzytelnianiBiometrycznego serwisBiometrii,
IBezpiecznyMagazynService magazyn)
{
InitializeComponent();
_serwiBiometrii = serwisBiometrii;
_magazyn = magazyn;
}
protected override async void OnAppearing()
{
base.OnAppearing();
// Sprawdzamy czy biometria jest dostępna i wyświetlamy przycisk
var (dostepne, _) = await _serwiBiometrii.SprawdzDostepnoscAsync();
PrzyciskBiometrii.IsVisible = dostepne;
}
private async void NaPrzyciskBiometriiKliknieto(object sender, EventArgs e)
{
var wynik = await _serwiBiometrii.UwierzytelnijAsync(
"Zaloguj się za pomocą biometrii");
if (wynik.Sukces)
{
// Pobieramy zapisany token i przechodzimy do aplikacji
var token = await _magazyn.PobierzTokenDostepuAsync();
if (token != null)
{
await Shell.Current.GoToAsync("//StronaGlowna");
}
else
{
// Token wygasł — wymagamy pełnego logowania
await DisplayAlert("Sesja wygasła",
"Twoja sesja wygasła. Zaloguj się ponownie.", "OK");
}
}
else
{
await DisplayAlert("Błąd uwierzytelniania",
wynik.KomunikatBledu ?? "Nieznany błąd", "OK");
}
}
}
Bezpieczne zarządzanie tokenami — OAuth2/OIDC z PKCE
Zarządzanie tokenami to jeden z tych tematów, które potrafią spędzać sen z powiek. A jednocześnie jest to absolutnie kluczowy element bezpieczeństwa każdej aplikacji mobilnej. Współczesne aplikacje powinny korzystać z OAuth 2.0 z rozszerzeniem OIDC i mechanizmem PKCE (Proof Key for Code Exchange) — zaprojektowanym specjalnie z myślą o klientach publicznych, takich jak aplikacje mobilne.
Konfiguracja IdentityModel.OidcClient
Biblioteka IdentityModel.OidcClient to sprawdzone rozwiązanie do implementacji przepływu OAuth2/OIDC w .NET MAUI. Instalacja jest prosta:
<ItemGroup>
<PackageReference Include="IdentityModel.OidcClient" Version="6.0.0" />
</ItemGroup>
Kluczowy element integracji to implementacja przeglądarki obsługującej przekierowania uwierzytelniania. W .NET MAUI korzystamy z klasy WebAuthenticator:
using IdentityModel.OidcClient.Browser;
using IBrowser = IdentityModel.OidcClient.Browser.IBrowser;
namespace MojaAplikacja.Auth;
/// <summary>
/// Implementacja przeglądarki OIDC wykorzystująca WebAuthenticator
/// z .NET MAUI. Obsługuje przekierowania OAuth2 w bezpieczny sposób.
/// </summary>
public class MauiAuthBrowser : IBrowser
{
public async Task<BrowserResult> InvokeAsync(
BrowserOptions options,
CancellationToken cancellationToken = default)
{
try
{
// WebAuthenticator obsługuje otwarcie przeglądarki systemowej
// oraz przechwycenie URI zwrotnego
var authResult = await WebAuthenticator.Default.AuthenticateAsync(
new Uri(options.StartUrl),
new Uri(options.EndUrl));
// Budujemy pełny URI zwrotny z parametrami
var url = new RequestUrl(options.EndUrl)
.Create(new Parameters(authResult.Properties));
return new BrowserResult
{
Response = url,
ResultType = BrowserResultType.Success
};
}
catch (TaskCanceledException)
{
return new BrowserResult
{
ResultType = BrowserResultType.UserCancel
};
}
catch (Exception ex)
{
return new BrowserResult
{
ResultType = BrowserResultType.UnknownError,
Error = ex.Message
};
}
}
}
/// <summary>
/// Klasa pomocnicza do budowania URL z parametrami.
/// </summary>
public class RequestUrl
{
private readonly string _baseUrl;
public RequestUrl(string baseUrl)
{
_baseUrl = baseUrl;
}
public string Create(Parameters parameters)
{
var query = string.Join("&",
parameters.Select(p =>
$"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(p.Value)}"));
return $"{_baseUrl}?{query}";
}
}
Kompletny serwis uwierzytelniania OAuth2
Teraz najważniejsza część — pełny serwis zarządzający cyklem życia tokenów:
using IdentityModel.OidcClient;
namespace MojaAplikacja.Auth;
/// <summary>
/// Serwis zarządzający pełnym cyklem życia uwierzytelniania OAuth2/OIDC
/// z PKCE, w tym logowaniem, odświeżaniem tokenów i wylogowywaniem.
/// </summary>
public class SerwisUwierzytelnianiaOAuth : ISerwisUwierzytelnianiaOAuth
{
private readonly IBezpiecznyMagazynService _magazyn;
private readonly OidcClient _oidcClient;
public SerwisUwierzytelnianiaOAuth(IBezpiecznyMagazynService magazyn)
{
_magazyn = magazyn;
// Konfiguracja klienta OIDC z PKCE (domyślnie włączone)
var opcje = new OidcClientOptions
{
Authority = "https://auth.mojaaplikacja.pl",
ClientId = "maui-mobile-client",
Scope = "openid profile email offline_access api",
RedirectUri = "mojaaplikacja://callback",
PostLogoutRedirectUri = "mojaaplikacja://callback",
Browser = new MauiAuthBrowser(),
// PKCE jest domyślnie włączone w IdentityModel.OidcClient
// Nie trzeba go jawnie konfigurować
Policy = new Policy
{
Discovery = new IdentityModel.Client.DiscoveryPolicy
{
// W produkcji zawsze wymagaj HTTPS
RequireHttps = true
}
}
};
_oidcClient = new OidcClient(opcje);
}
/// <summary>
/// Przeprowadza logowanie użytkownika przez przepływ OAuth2 z PKCE.
/// </summary>
public async Task<WynikLogowania> ZalogujAsync()
{
try
{
var loginResult = await _oidcClient.LoginAsync(
new LoginRequest());
if (loginResult.IsError)
{
return new WynikLogowania
{
Sukces = false,
Blad = loginResult.Error
};
}
// Bezpiecznie zapisujemy tokeny
await _magazyn.ZapiszTokenDostepuAsync(
loginResult.AccessToken,
loginResult.AccessTokenExpiration.DateTime);
if (!string.IsNullOrEmpty(loginResult.RefreshToken))
{
await _magazyn.ZapiszTokenOdswiezaniaAsync(
loginResult.RefreshToken);
}
return new WynikLogowania
{
Sukces = true,
NazwaUzytkownika = loginResult.User?
.FindFirst("name")?.Value
};
}
catch (Exception ex)
{
return new WynikLogowania
{
Sukces = false,
Blad = $"Błąd logowania: {ex.Message}"
};
}
}
/// <summary>
/// Odświeża token dostępu przy użyciu tokenu odświeżania.
/// </summary>
public async Task<string?> OdswiezTokenAsync()
{
var refreshToken = await _magazyn.PobierzTokenOdswiezaniaAsync();
if (string.IsNullOrEmpty(refreshToken))
return null;
try
{
var result = await _oidcClient.RefreshTokenAsync(refreshToken);
if (result.IsError)
{
// Token odświeżania wygasł — wymagamy ponownego logowania
_magazyn.UsunTokeny();
return null;
}
// Zapisujemy nowe tokeny
await _magazyn.ZapiszTokenDostepuAsync(
result.AccessToken,
result.AccessTokenExpiration.DateTime);
if (!string.IsNullOrEmpty(result.RefreshToken))
{
// Rotacja tokenów odświeżania — zapisujemy nowy
await _magazyn.ZapiszTokenOdswiezaniaAsync(
result.RefreshToken);
}
return result.AccessToken;
}
catch (Exception)
{
_magazyn.UsunTokeny();
return null;
}
}
/// <summary>
/// Zwraca aktualny, ważny token dostępu. Automatycznie odświeża
/// token, jeśli zbliża się czas jego wygaśnięcia.
/// </summary>
public async Task<string?> PobierzAktualnyTokenAsync()
{
var token = await _magazyn.PobierzTokenDostepuAsync();
if (token != null)
return token;
// Token wygasł lub nie istnieje — próbujemy odświeżyć
return await OdswiezTokenAsync();
}
/// <summary>
/// Wylogowuje użytkownika — czyści tokeny i opcjonalnie
/// kończy sesję na serwerze autoryzacji.
/// </summary>
public async Task WylogujAsync()
{
_magazyn.UsunTokeny();
try
{
await _oidcClient.LogoutAsync(new LogoutRequest());
}
catch
{
// Wylogowanie lokalne zawsze się udaje,
// nawet jeśli wylogowanie z serwera zawiedzie
}
}
}
public class WynikLogowania
{
public bool Sukces { get; set; }
public string? Blad { get; set; }
public string? NazwaUzytkownika { get; set; }
}
Automatyczne dołączanie tokenów do żądań HTTP
Żeby nie musieć ręcznie dodawać tokenu do każdego żądania (co byłoby szaleństwem), korzystamy z mechanizmu DelegatingHandler:
namespace MojaAplikacja.Http;
/// <summary>
/// Handler HTTP automatycznie dołączający token Bearer do żądań.
/// Obsługuje automatyczne odświeżanie wygasłych tokenów.
/// </summary>
public class AutoryzacjaHttpHandler : DelegatingHandler
{
private readonly ISerwisUwierzytelnianiaOAuth _serwisAuth;
public AutoryzacjaHttpHandler(ISerwisUwierzytelnianiaOAuth serwisAuth)
{
_serwisAuth = serwisAuth;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var token = await _serwisAuth.PobierzAktualnyTokenAsync();
if (!string.IsNullOrEmpty(token))
{
request.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue(
"Bearer", token);
}
var response = await base.SendAsync(request, cancellationToken);
// Jeśli serwer zwrócił 401, próbujemy odświeżyć token
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
token = await _serwisAuth.OdswiezTokenAsync();
if (!string.IsNullOrEmpty(token))
{
request.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue(
"Bearer", token);
response = await base.SendAsync(request, cancellationToken);
}
}
return response;
}
}
Szyfrowanie danych lokalnych
SecureStorage sprawdza się przy małych porcjach danych, ale co z większymi zbiorami? Bazy danych SQLite, pliki konfiguracyjne, dane tymczasowe — to wszystko też wymaga ochrony. W tej sekcji omówimy dwa podejścia: szyfrowanie bazy SQLite za pomocą SQLCipher oraz szyfrowanie plików algorytmem AES.
Szyfrowanie bazy SQLite za pomocą SQLCipher
SQLCipher to rozszerzenie SQLite zapewniające transparentne szyfrowanie całej bazy danych algorytmem AES-256. Integracja z .NET MAUI jest naprawdę prosta (co w świecie kryptografii to rzadkość):
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.0" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlcipher" Version="2.1.10" />
</ItemGroup>
Kompletna implementacja zaszyfrowanego repozytorium:
using Microsoft.Data.Sqlite;
namespace MojaAplikacja.Data;
/// <summary>
/// Repozytorium danych z pełnym szyfrowaniem bazy SQLite.
/// Wykorzystuje SQLCipher do transparentnego szyfrowania AES-256.
/// </summary>
public class ZaszyfrowanaBazaDanych : IAsyncDisposable
{
private readonly SqliteConnection _polaczenie;
private readonly string _sciezkaBazy;
public ZaszyfrowanaBazaDanych(ISecureStorage secureStorage)
{
_sciezkaBazy = Path.Combine(
FileSystem.AppDataDirectory, "dane_aplikacji.db");
// Klucz szyfrowania pobieramy lub generujemy przy pierwszym
// uruchomieniu i przechowujemy w SecureStorage
var klucz = PobierzLubUtworzKluczAsync(secureStorage)
.GetAwaiter().GetResult();
var connectionString = new SqliteConnectionStringBuilder
{
DataSource = _sciezkaBazy,
Password = klucz, // SQLCipher użyje tego jako klucza szyfrowania
Mode = SqliteOpenMode.ReadWriteCreate
}.ToString();
_polaczenie = new SqliteConnection(connectionString);
}
/// <summary>
/// Pobiera istniejący klucz szyfrowania bazy danych lub generuje nowy.
/// Klucz jest przechowywany w SecureStorage.
/// </summary>
private static async Task<string> PobierzLubUtworzKluczAsync(
ISecureStorage secureStorage)
{
const string kluczMagazynu = "db_encryption_key";
var klucz = await secureStorage.GetAsync(kluczMagazynu);
if (string.IsNullOrEmpty(klucz))
{
// Generujemy kryptograficznie bezpieczny klucz 256-bitowy
var bajtyKlucza = new byte[32];
using var rng = System.Security.Cryptography
.RandomNumberGenerator.Create();
rng.GetBytes(bajtyKlucza);
klucz = Convert.ToBase64String(bajtyKlucza);
await secureStorage.SetAsync(kluczMagazynu, klucz);
}
return klucz;
}
/// <summary>
/// Inicjalizuje bazę danych — tworzy wymagane tabele.
/// </summary>
public async Task InicjalizujAsync()
{
await _polaczenie.OpenAsync();
var polecenie = _polaczenie.CreateCommand();
polecenie.CommandText = @"
CREATE TABLE IF NOT EXISTS WrazliweDane (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
Klucz TEXT NOT NULL UNIQUE,
Wartosc TEXT NOT NULL,
DataUtworzenia TEXT NOT NULL DEFAULT (datetime('now')),
DataModyfikacji TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_wrazliwe_dane_klucz
ON WrazliweDane(Klucz);
";
await polecenie.ExecuteNonQueryAsync();
}
/// <summary>
/// Zapisuje wrażliwe dane w zaszyfrowanej bazie.
/// </summary>
public async Task ZapiszDaneAsync(string klucz, string wartosc)
{
var polecenie = _polaczenie.CreateCommand();
polecenie.CommandText = @"
INSERT INTO WrazliweDane (Klucz, Wartosc, DataModyfikacji)
VALUES (@klucz, @wartosc, datetime('now'))
ON CONFLICT(Klucz) DO UPDATE SET
Wartosc = @wartosc,
DataModyfikacji = datetime('now');
";
polecenie.Parameters.AddWithValue("@klucz", klucz);
polecenie.Parameters.AddWithValue("@wartosc", wartosc);
await polecenie.ExecuteNonQueryAsync();
}
/// <summary>
/// Odczytuje wrażliwe dane z zaszyfrowanej bazy.
/// </summary>
public async Task<string?> PobierzDaneAsync(string klucz)
{
var polecenie = _polaczenie.CreateCommand();
polecenie.CommandText =
"SELECT Wartosc FROM WrazliweDane WHERE Klucz = @klucz;";
polecenie.Parameters.AddWithValue("@klucz", klucz);
var wynik = await polecenie.ExecuteScalarAsync();
return wynik as string;
}
public async ValueTask DisposeAsync()
{
await _polaczenie.DisposeAsync();
}
}
Szyfrowanie plików algorytmem AES
Do szyfrowania pojedynczych plików — dokumentów, obrazów czy eksportowanych danych — najlepiej sprawdza się AES-256 w trybie GCM. Zapewnia zarówno poufność, jak i integralność danych (co jest często pomijane przy wyborze trybu szyfrowania):
using System.Security.Cryptography;
namespace MojaAplikacja.Crypto;
/// <summary>
/// Serwis do szyfrowania i deszyfrowania plików algorytmem AES-256-GCM.
/// Zapewnia poufność i integralność danych.
/// </summary>
public class SerwisZaszyfrowanychPlikow
{
private const int RozmiarKlucza = 32; // 256 bitów
private const int RozmiarNonce = 12; // 96 bitów (zalecany dla GCM)
private const int RozmiarTagu = 16; // 128 bitów
/// <summary>
/// Szyfruje tablicę bajtów algorytmem AES-256-GCM.
/// Zwraca zaszyfrowane dane z dołączonym nonce i tagiem.
/// Format: [nonce 12B][tag 16B][zaszyfrowane dane]
/// </summary>
public byte[] Zaszyfruj(byte[] daneWejsciowe, byte[] klucz)
{
if (klucz.Length != RozmiarKlucza)
throw new ArgumentException(
$"Klucz musi mieć {RozmiarKlucza} bajtów (256 bitów).");
var nonce = new byte[RozmiarNonce];
RandomNumberGenerator.Fill(nonce);
var zaszyfrowaneDane = new byte[daneWejsciowe.Length];
var tag = new byte[RozmiarTagu];
using var aesGcm = new AesGcm(klucz, RozmiarTagu);
aesGcm.Encrypt(nonce, daneWejsciowe, zaszyfrowaneDane, tag);
// Łączymy nonce + tag + zaszyfrowane dane w jeden bufor
var wynik = new byte[RozmiarNonce + RozmiarTagu
+ zaszyfrowaneDane.Length];
Buffer.BlockCopy(nonce, 0, wynik, 0, RozmiarNonce);
Buffer.BlockCopy(tag, 0, wynik, RozmiarNonce, RozmiarTagu);
Buffer.BlockCopy(zaszyfrowaneDane, 0, wynik,
RozmiarNonce + RozmiarTagu, zaszyfrowaneDane.Length);
return wynik;
}
/// <summary>
/// Deszyfruje dane zaszyfrowane metodą Zaszyfruj.
/// </summary>
public byte[] Odszyfruj(byte[] zaszyfrowaneDane, byte[] klucz)
{
if (klucz.Length != RozmiarKlucza)
throw new ArgumentException(
$"Klucz musi mieć {RozmiarKlucza} bajtów (256 bitów).");
// Wyodrębniamy nonce, tag i dane
var nonce = new byte[RozmiarNonce];
var tag = new byte[RozmiarTagu];
var dane = new byte[zaszyfrowaneDane.Length
- RozmiarNonce - RozmiarTagu];
Buffer.BlockCopy(zaszyfrowaneDane, 0, nonce, 0, RozmiarNonce);
Buffer.BlockCopy(zaszyfrowaneDane, RozmiarNonce, tag, 0,
RozmiarTagu);
Buffer.BlockCopy(zaszyfrowaneDane, RozmiarNonce + RozmiarTagu,
dane, 0, dane.Length);
var odszyfrowaneDane = new byte[dane.Length];
using var aesGcm = new AesGcm(klucz, RozmiarTagu);
aesGcm.Decrypt(nonce, dane, tag, odszyfrowaneDane);
return odszyfrowaneDane;
}
/// <summary>
/// Szyfruje plik i zapisuje wynik pod wskazaną ścieżką.
/// </summary>
public async Task ZaszyfrujPlikAsync(
string sciezkaZrodlowa, string sciezkaDocelowa, byte[] klucz)
{
var daneWejsciowe = await File.ReadAllBytesAsync(sciezkaZrodlowa);
var zaszyfrowane = Zaszyfruj(daneWejsciowe, klucz);
await File.WriteAllBytesAsync(sciezkaDocelowa, zaszyfrowane);
}
/// <summary>
/// Generuje kryptograficznie bezpieczny klucz AES-256.
/// </summary>
public static byte[] GenerujKlucz()
{
var klucz = new byte[RozmiarKlucza];
RandomNumberGenerator.Fill(klucz);
return klucz;
}
}
Bezpieczeństwo komunikacji sieciowej
Zabezpieczenie komunikacji między aplikacją a serwerem API to absolutny fundament. Możesz mieć idealnie zaszyfrowane dane lokalne, ale jeśli przesyłasz je niezabezpieczonym kanałem — cała ta praca idzie na marne. Omówmy konfigurację HTTPS, przypinanie certyfikatów i konfigurację bezpieczeństwa sieciowego na Androidzie.
Konfiguracja HttpClient z najlepszymi praktykami
namespace MojaAplikacja.Http;
/// <summary>
/// Fabryka klientów HTTP z wbudowanymi mechanizmami bezpieczeństwa.
/// </summary>
public static class BezpiecznyHttpClientFactory
{
/// <summary>
/// Tworzy skonfigurowanego klienta HTTP z przypinaniem certyfikatów
/// i nagłówkami bezpieczeństwa.
/// </summary>
public static HttpClient UtworzBezpiecznegoKlienta(
ISerwisUwierzytelnianiaOAuth serwisAuth)
{
var handler = new AutoryzacjaHttpHandler(serwisAuth)
{
InnerHandler = new SocketsHttpHandler
{
// Wymuszamy TLS 1.2 lub wyższy
SslOptions = new System.Net.Security.SslClientAuthenticationOptions
{
EnabledSslProtocols =
System.Security.Authentication.SslProtocols.Tls12 |
System.Security.Authentication.SslProtocols.Tls13,
},
// Ograniczamy czas życia połączeń — wymusza odświeżenie DNS
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
ConnectTimeout = TimeSpan.FromSeconds(10)
}
};
var client = new HttpClient(handler)
{
BaseAddress = new Uri("https://api.mojaaplikacja.pl/"),
Timeout = TimeSpan.FromSeconds(30)
};
// Nagłówki bezpieczeństwa
client.DefaultRequestHeaders.Add("X-Content-Type-Options", "nosniff");
client.DefaultRequestHeaders.Add("X-Request-Id",
Guid.NewGuid().ToString());
return client;
}
}
Przypinanie certyfikatów (Certificate Pinning)
Certificate pinning to technika chroniąca przed atakami Man-in-the-Middle. Polega na weryfikacji, czy certyfikat serwera odpowiada oczekiwanemu odciskowi. Muszę tu jednak powiedzieć wprost — w 2026 roku temat jest nieco kontrowersyjny. Z jednej strony znacząco podnosi bezpieczeństwo, z drugiej utrudnia aktualizację certyfikatów i może sprawić, że aplikacja przestanie działać po wygaśnięciu certyfikatu (a użytkownicy nie zaktualizowali apki).
Zalecane podejście to przypinanie klucza publicznego SPKI, które przetrwa odnowienie certyfikatu, o ile klucz prywatny pozostanie ten sam:
using System.Net.Security;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
namespace MojaAplikacja.Http;
/// <summary>
/// Handler HTTP z przypinaniem klucza publicznego certyfikatu (SPKI pinning).
/// Zapewnia ochronę przed atakami Man-in-the-Middle.
/// </summary>
public class CertificatePinningHandler : HttpClientHandler
{
// Skrót SHA-256 klucza publicznego SPKI serwera (Base64)
private static readonly HashSet<string> ZaufaneOdciskiKluczy = new()
{
"YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=", // Klucz główny
"sRHdihwgkaib1P1gN7akKJSMHAjF65OEcqb8C30JYwI=" // Klucz zapasowy
};
public CertificatePinningHandler()
{
ServerCertificateCustomValidationCallback = WeryfikujCertyfikat;
}
/// <summary>
/// Weryfikuje certyfikat serwera, porównując skrót klucza publicznego
/// z listą zaufanych odcisków.
/// </summary>
private static bool WeryfikujCertyfikat(
HttpRequestMessage request,
X509Certificate2? certyfikat,
X509Chain? lancuch,
SslPolicyErrors bladyPolityki)
{
if (certyfikat == null)
return false;
// Najpierw sprawdzamy standardową walidację
if (bladyPolityki != SslPolicyErrors.None)
return false;
// Obliczamy skrót SPKI certyfikatu
var kluczPubliczny = certyfikat.GetPublicKey();
var skrotSpki = Convert.ToBase64String(
SHA256.HashData(kluczPubliczny));
// Sprawdzamy, czy skrót pasuje do któregokolwiek z zaufanych
return ZaufaneOdciskiKluczy.Contains(skrotSpki);
}
}
Ważna uwaga: Coraz więcej organizacji odchodzi od przypinania certyfikatów na rzecz Certificate Transparency (CT) i mechanizmów CAA. Pinning wciąż ma sens w aplikacjach o wysokim profilu bezpieczeństwa (bankowość, opieka zdrowotna), ale wymaga solidnego procesu aktualizacji i obowiązkowego klucza zapasowego.
Konfiguracja bezpieczeństwa sieciowego na Androidzie
Android wymaga jawnej konfiguracji bezpieczeństwa sieciowego. Plik network_security_config.xml umieszczamy w katalogu Platforms/Android/Resources/xml/:
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Blokujemy cały ruch nieszyfrowany (HTTP) -->
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<!-- Konfiguracja dla naszego serwera API -->
<domain-config cleartextTrafficPermitted="false">
<domain includeSubdomains="true">api.mojaaplikacja.pl</domain>
<pin-set expiration="2027-01-01">
<pin digest="SHA-256">
YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=
</pin>
<!-- Zawsze dodawaj zapasowy pin! -->
<pin digest="SHA-256">
sRHdihwgkaib1P1gN7akKJSMHAjF65OEcqb8C30JYwI=
</pin>
</pin-set>
</domain-config>
<!-- Konfiguracja debugowa — tylko dla buildów debugowych -->
<debug-overrides>
<trust-anchors>
<certificates src="user" />
</trust-anchors>
</debug-overrides>
</network-security-config>
Następnie odwołujemy się do tej konfiguracji w AndroidManifest.xml, dodając atrybut android:networkSecurityConfig do elementu <application> (co zresztą widać we wcześniejszym przykładzie konfiguracji Androida).
Dobre praktyki bezpieczeństwa
Techniki opisane poniżej to dodatkowe warstwy obrony, które powinny uzupełniać omówione wcześniej mechanizmy. Podejście „defense in depth" (obrona w głąb) zakłada coś, co z mojego doświadczenia potwierdza się przy każdym audycie bezpieczeństwa — żaden pojedynczy mechanizm nie jest wystarczający. Dopiero ich kombinacja daje realną ochronę.
Obfuskacja kodu
Aplikacje .NET MAUI kompilują się do kodu pośredniego (IL), który jest — powiedzmy to wprost — stosunkowo łatwy do dekompilacji. Obfuskacja utrudnia inżynierię odwrotną, choć nie czyni jej niemożliwą (nic nie czyni, ale podnosi poprzeczkę). W ekosystemie .NET warto zacząć od włączenia trymowania i kompilacji AOT:
<!-- W pliku .csproj — konfiguracja dla wydania produkcyjnego -->
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<!-- Kompilacja AOT utrudnia dekompilację -->
<RunAOTCompilation>true</RunAOTCompilation>
<!-- Usuwanie nieużywanego kodu -->
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>full</TrimMode>
<!-- Usuwanie symboli debugowania -->
<DebugSymbols>false</DebugSymbols>
<DebugType>none</DebugType>
</PropertyGroup>
Dodatkowo warto rozważyć komercyjne narzędzia do obfuskacji, takie jak Dotfuscator czy Babel Obfuscator. Oferują zaawansowane techniki transformacji kodu: zmianę nazw symboli, wstawianie martwego kodu, szyfrowanie ciągów znaków i kontroli przepływu.
Wykrywanie root/jailbreak
Urządzenia z rootem (Android) lub jailbreakiem (iOS) to zwiększone ryzyko — atakujący może obejść mechanizmy izolacji aplikacji. Poniższy serwis pomoże wykryć takie urządzenia, choć (i to ważne zastrzeżenie) zaawansowany atakujący może te sprawdzenia obejść:
namespace MojaAplikacja.Security;
/// <summary>
/// Serwis wykrywający, czy urządzenie ma dostęp root/jailbreak.
/// Uwaga: wykrywanie nie jest niezawodne — zaawansowany atakujący
/// może je obejść. Stanowi jednak dodatkową warstwę obrony.
/// </summary>
public class SerwisWykrywaniaModyfikacji
{
/// <summary>
/// Sprawdza, czy urządzenie wykazuje oznaki modyfikacji
/// (root/jailbreak).
/// </summary>
public bool CzyUrzadzenieZmodyfikowane()
{
#if ANDROID
return SprawdzRootAndroid();
#elif IOS
return SprawdzJailbreakiOS();
#else
return false;
#endif
}
#if ANDROID
private bool SprawdzRootAndroid()
{
// Sprawdzamy typowe ścieżki binarne su
string[] sciezkiRoot =
{
"/system/bin/su",
"/system/xbin/su",
"/sbin/su",
"/data/local/xbin/su",
"/data/local/bin/su",
"/system/sd/xbin/su",
"/system/bin/failsafe/su",
"/data/local/su"
};
foreach (var sciezka in sciezkiRoot)
{
if (Java.IO.File.Exists(sciezka))
return true;
}
// Sprawdzamy znane pakiety narzędzi do rootowania
string[] pakietyRoot =
{
"com.topjohnwu.magisk",
"eu.chainfire.supersu",
"com.koushikdutta.superuser",
"com.thirdparty.superuser"
};
var context = Android.App.Application.Context;
var pm = context.PackageManager;
foreach (var pakiet in pakietyRoot)
{
try
{
pm?.GetPackageInfo(pakiet,
Android.Content.PM.PackageInfoFlags.MatchAll);
return true; // Pakiet znaleziony — urządzenie zrootowane
}
catch (Android.Content.PM.PackageManager.NameNotFoundException)
{
// Pakiet nie znaleziony — kontynuujemy sprawdzanie
}
}
// Sprawdzamy właściwość systemową
try
{
var process = Java.Lang.Runtime.GetRuntime()?
.Exec(new[] { "getprop", "ro.build.tags" });
if (process != null)
{
using var reader = new Java.IO.BufferedReader(
new Java.IO.InputStreamReader(process.InputStream));
var wynik = reader.ReadLine();
if (wynik?.Contains("test-keys") == true)
return true;
}
}
catch
{
// Ignorujemy błędy — brak dostępu nie oznacza roota
}
return false;
}
#endif
#if IOS
private bool SprawdzJailbreakiOS()
{
// Sprawdzamy typowe ścieżki plików jailbreak
string[] sciezkiJailbreak =
{
"/Applications/Cydia.app",
"/Applications/Sileo.app",
"/Library/MobileSubstrate/MobileSubstrate.dylib",
"/bin/bash",
"/usr/sbin/sshd",
"/etc/apt",
"/private/var/lib/apt/",
"/usr/bin/ssh"
};
foreach (var sciezka in sciezkiJailbreak)
{
if (File.Exists(sciezka))
return true;
}
// Próbujemy zapisać plik poza sandboxem
try
{
File.WriteAllText("/private/test_jailbreak.txt", "test");
File.Delete("/private/test_jailbreak.txt");
return true; // Jeśli zapis się udał, urządzenie ma jailbreak
}
catch
{
// Brak dostępu — urządzenie prawdopodobnie nie ma jailbreaka
}
return false;
}
#endif
}
Bezpieczne logowanie
To temat, który wielu deweloperów traktuje po macoszemu, a potem zdziwienie, gdy w logach produkcyjnych wyciekają tokeny czy adresy email. Logi nie mogą zawierać wrażliwych danych. Kropka. Oto implementacja bezpiecznego loggera:
using Microsoft.Extensions.Logging;
namespace MojaAplikacja.Security;
/// <summary>
/// Bezpieczny logger, który automatycznie filtruje wrażliwe dane
/// z komunikatów logów. W trybie Release wyłącza logowanie szczegółowe.
/// </summary>
public class BezpiecznyLogger<T> : ILogger<T>
{
private readonly ILogger<T> _wewnetrznyLogger;
// Wzorce wrażliwych danych do filtrowania
private static readonly (string Wzorzec, string Zamiennik)[]
FiltryWrazliwychDanych =
{
("Bearer [A-Za-z0-9\\-._~+/]+=*", "Bearer [UKRYTO]"),
("password[\"']?\\s*[:=]\\s*[\"'][^\"']+[\"']",
"password: [UKRYTO]"),
("\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Z|a-z]{2,}\\b",
"[EMAIL_UKRYTO]")
};
public BezpiecznyLogger(ILogger<T> wewnetrznyLogger)
{
_wewnetrznyLogger = wewnetrznyLogger;
}
public void Log<TState>(LogLevel logLevel, EventId eventId,
TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
#if RELEASE
// W trybie Release logujemy tylko ostrzeżenia i błędy
if (logLevel < LogLevel.Warning)
return;
#endif
// Filtrujemy wrażliwe dane z komunikatu
var komunikat = formatter(state, exception);
foreach (var (wzorzec, zamiennik) in FiltryWrazliwychDanych)
{
komunikat = System.Text.RegularExpressions.Regex.Replace(
komunikat, wzorzec, zamiennik);
}
_wewnetrznyLogger.Log(logLevel, eventId, komunikat, exception,
(s, e) => s?.ToString() ?? string.Empty);
}
public bool IsEnabled(LogLevel logLevel) =>
_wewnetrznyLogger.IsEnabled(logLevel);
public IDisposable? BeginScope<TState>(TState state)
where TState : notnull =>
_wewnetrznyLogger.BeginScope(state);
}
Sprawdzanie integralności w czasie wykonywania
Żeby wykryć próby manipulacji aplikacją (podmiany bibliotek, podłączanie debuggerów itp.), warto zaimplementować sprawdzanie integralności:
namespace MojaAplikacja.Security;
/// <summary>
/// Serwis sprawdzający integralność aplikacji w czasie wykonywania.
/// Wykrywa debuggery, modyfikacje zestawów i inne anomalie.
/// </summary>
public static class SprawdzanieIntegralnosci
{
/// <summary>
/// Sprawdza, czy do procesu aplikacji jest podłączony debugger.
/// </summary>
public static bool CzyDebuggerPodlaczony()
{
return System.Diagnostics.Debugger.IsAttached;
}
/// <summary>
/// Weryfikuje integralność głównego zestawu aplikacji,
/// porównując jego skrót z oczekiwaną wartością.
/// </summary>
public static bool ZweryfikujIntegralnoscZestawu(
string oczekiwanySkrotBase64)
{
try
{
var zestawGlowny = System.Reflection.Assembly
.GetExecutingAssembly();
var sciezka = zestawGlowny.Location;
if (string.IsNullOrEmpty(sciezka))
return true; // Na niektórych platformach lokalizacja
// nie jest dostępna
var bajty = File.ReadAllBytes(sciezka);
var skrot = Convert.ToBase64String(
System.Security.Cryptography.SHA256.HashData(bajty));
return skrot == oczekiwanySkrotBase64;
}
catch
{
return false;
}
}
/// <summary>
/// Wykonuje pełny zestaw kontroli bezpieczeństwa przy starcie
/// aplikacji. Zwraca listę wykrytych zagrożeń.
/// </summary>
public static List<string> WykonajKontroleBezpieczenstwa(
SerwisWykrywaniaModyfikacji serwisModyfikacji)
{
var zagrozenia = new List<string>();
#if RELEASE
if (CzyDebuggerPodlaczony())
zagrozenia.Add("Wykryto podłączony debugger");
#endif
if (serwisModyfikacji.CzyUrzadzenieZmodyfikowane())
zagrozenia.Add("Wykryto zmodyfikowane urządzenie (root/jailbreak)");
return zagrozenia;
}
}
OWASP Mobile Top 10 — krótka lista kontrolna
Standard OWASP Mobile Top 10 definiuje najważniejsze zagrożenia bezpieczeństwa aplikacji mobilnych. Przejdźmy przez nie w kontekście tego, czego nauczyliśmy się w tym przewodniku:
- M1 — Niewłaściwe użycie poświadczeń — nigdy nie zapisuj haseł w postaci jawnej; korzystaj z SecureStorage i OAuth2 z PKCE zamiast przechowywania haseł.
- M2 — Niedostateczne bezpieczeństwo łańcucha dostaw — regularnie aktualizuj pakiety NuGet, weryfikuj ich podpisy, monitoruj podatności za pomocą
dotnet list package --vulnerable. - M3 — Niebezpieczne uwierzytelnianie/autoryzacja — stosuj biometrię jako drugi czynnik, implementuj PKCE w przepływach OAuth2, weryfikuj tokeny po stronie serwera.
- M4 — Niewystarczająca walidacja danych wejściowych/wyjściowych — waliduj dane od użytkownika, używaj zapytań parametryzowanych, sanityzuj dane w WebView.
- M5 — Niebezpieczna komunikacja — wymuszaj HTTPS, rozważ certificate pinning, konfiguruj
network_security_config.xml. - M6 — Niewystarczająca kontrola prywatności — minimalizuj zbieranie danych, szyfruj dane wrażliwe, implementuj prawo do usunięcia danych (RODO).
- M7 — Niewystarczające zabezpieczenie binariów — stosuj obfuskację, kompilację AOT, wykrywanie debuggerów.
- M8 — Błędna konfiguracja bezpieczeństwa — wyłącz kopie zapasowe wrażliwych danych, stosuj zasadę minimalnych uprawnień, wyłącz debug w produkcji.
- M9 — Niebezpieczne przechowywanie danych — szyfruj bazy SQLCipher, używaj SecureStorage, czyść dane tymczasowe.
- M10 — Niewystarczająca kryptografia — korzystaj ze standardowych algorytmów (AES-256-GCM, RSA-2048+), nie implementuj własnej kryptografii.
Podsumowanie i lista kontrolna bezpieczeństwa
Bezpieczeństwo aplikacji .NET MAUI to złożony temat i nie ma co ukrywać — wymaga systematycznego podejścia. Nie istnieje jedno magiczne rozwiązanie, które załatwi wszystko. Kluczem jest konsekwentna implementacja wielu uzupełniających się mechanizmów.
Lista kontrolna — minimum dla produkcji
- Przechowywanie danych: Wrażliwe dane (tokeny, klucze) wyłącznie w SecureStorage. Bazy danych lokalne zaszyfrowane SQLCipher. Żadnych wrażliwych danych w logach i plikach tymczasowych.
- Uwierzytelnianie: OAuth2 z PKCE jako główny mechanizm. Biometria jako wygodna opcja uzupełniająca. Automatyczne odświeżanie tokenów z obsługą rotacji.
- Komunikacja sieciowa: HTTPS wszędzie. Konfiguracja
network_security_config.xmlblokująca ruch nieszyfrowany. Certificate pinning tam, gdzie uzasadnia to profil ryzyka. - Ochrona kodu: Kompilacja AOT i trimming w trybie Release. Wykrywanie debuggerów i root/jailbreak. Brak symboli debugowania w buildach produkcyjnych.
- Bezpieczne logowanie: Automatyczne filtrowanie wrażliwych danych. Ograniczenie logowania w produkcji do ostrzeżeń i błędów. Nigdy nie loguj tokenów, haseł ani danych osobowych.
- Zarządzanie zależnościami: Regularne aktualizacje pakietów NuGet. Monitorowanie podatności (
dotnet list package --vulnerable). Weryfikacja źródeł pakietów. - Konfiguracja platformowa: Właściwe uprawnienia w
AndroidManifest.xml. Opisy użycia biometrii wInfo.plist. Wyłączone kopie zapasowe wrażliwych danych. - Szyfrowanie: AES-256-GCM dla danych lokalnych. TLS 1.2+ dla komunikacji. Klucze generowane kryptograficznie bezpiecznym generatorem i przechowywane w SecureStorage.
Bezpieczeństwo to proces ciągły, nie jednorazowe działanie. Regularne audyty, testy penetracyjne i śledzenie nowych zagrożeń powinny być stałym elementem cyklu życia Twojej aplikacji. W 2026 roku inwestycja w bezpieczeństwo to nie opcja — to wymóg budowania zaufania użytkowników i ochrony danych, które Ci powierzyli.
Wdrożenie opisanych technik — od SecureStorage i biometrii, przez szyfrowanie baz danych i bezpieczną komunikację, aż po obfuskację i wykrywanie manipulacji — zapewni Twojej aplikacji .NET MAUI solidny fundament bezpieczeństwa, zgodny ze standardami OWASP Mobile Security i najlepszymi praktykami branżowymi.