Bezpieczeństwo aplikacji .NET MAUI: Przewodnik po SecureStorage, biometrii i ochronie danych

Praktyczny przewodnik po bezpieczeństwie aplikacji .NET MAUI — SecureStorage, biometria, OAuth2 z PKCE, szyfrowanie SQLCipher, certificate pinning i zgodność z OWASP Mobile Top 10. Z gotowymi przykładami kodu.

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ę EncryptedSharedPreferences z 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:

  1. 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ł.
  2. M2 — Niedostateczne bezpieczeństwo łańcucha dostaw — regularnie aktualizuj pakiety NuGet, weryfikuj ich podpisy, monitoruj podatności za pomocą dotnet list package --vulnerable.
  3. M3 — Niebezpieczne uwierzytelnianie/autoryzacja — stosuj biometrię jako drugi czynnik, implementuj PKCE w przepływach OAuth2, weryfikuj tokeny po stronie serwera.
  4. M4 — Niewystarczająca walidacja danych wejściowych/wyjściowych — waliduj dane od użytkownika, używaj zapytań parametryzowanych, sanityzuj dane w WebView.
  5. M5 — Niebezpieczna komunikacja — wymuszaj HTTPS, rozważ certificate pinning, konfiguruj network_security_config.xml.
  6. M6 — Niewystarczająca kontrola prywatności — minimalizuj zbieranie danych, szyfruj dane wrażliwe, implementuj prawo do usunięcia danych (RODO).
  7. M7 — Niewystarczające zabezpieczenie binariów — stosuj obfuskację, kompilację AOT, wykrywanie debuggerów.
  8. M8 — Błędna konfiguracja bezpieczeństwa — wyłącz kopie zapasowe wrażliwych danych, stosuj zasadę minimalnych uprawnień, wyłącz debug w produkcji.
  9. M9 — Niebezpieczne przechowywanie danych — szyfruj bazy SQLCipher, używaj SecureStorage, czyść dane tymczasowe.
  10. 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.xml blokują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 w Info.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.

O Autorze Editorial Team

Our team of expert writers and editors.