Wilson Vargas
Wilson Vargas

Un programador enamorado de su código.

Wilson Vargas
Author

Share


Suscribete


Mantente al tanto de mis nuevos artículos

Tags


Featured on Planet Xamarin badge

Twitter


Cómo migrar SecureStorage de Xamarin.Essentials a .NET MAUI sin perder datos

Wilson VargasWilson Vargas

¿Tienes una app en Xamarin.Forms que ya guarda datos sensibles usando SecureStorage, y no quieres que tus usuarios reingresen todo al migrar a .NET MAUI? Aquí te cuento paso a paso cómo hacerlo de forma segura y fluida.

Por qué se daña la migración? diferencias clave entre plataformas

Tanto Xamarin.Essentials como .NET MAUI tienen la clase SecureStorage para guardar pares de clave/valor de forma segura. Pero ojo: no funcionan igual a nivel interno en Android ni iOS.

Plataforma Xamarin.Essentials .NET MAUI
Android Se usa Android KeyStore para almacenar la clave de cifrado utilizada al encriptar un valor antes de guardarlo en un objeto SharedPreferences con el nombre {tu-app-package-id}.xamarinessentials. Los datos se encriptan con la clase EncryptedSharedPreferences, que encapsula a SharedPreferences y cifra automáticamente claves y valores. El nombre utilizado es {tu-app-package-id}.microsoft.maui.essentials.preferences.
iOS Se usa KeyChain para almacenar valores de forma segura. El SecRecord utilizado tiene el campo Service configurado como {tu-app-package-id}.xamarinessentials. Se usa KeyChain para almacenar valores de forma segura. El SecRecord utilizado tiene el campo Service configurado como {tu-app-package-id}.microsoft.maui.essentials.preferences.

El resultado: si simplemente cambias el código, tu app .NET MAUI no encontrará lo que guardaste antes en Xamarin, porque está buscando en otro sitio.

Solución paso a paso: usar LegacySecureStorage

#if ANDROID || IOS
using System;
using System.Threading.Tasks;
using Microsoft.Maui.ApplicationModel;
#if ANDROID
using Android.Content;
using Android.Security.Keystore;
using AndroidX.Security.Crypto;
using Java.Security;
using Javax.Crypto;
using Android.Preferences;
#endif
#if IOS
using Security;
using Foundation;
#endif

namespace TuApp.Helpers
{
    public static class LegacySecureStorage
    {
        internal static readonly string Alias = $"{AppInfo.PackageName}.xamarinessentials";

#if ANDROID
        static readonly object locker = new object();

        public static Task GetAsync(string key)
        {
            lock (locker)
            {
                var context = Android.App.Application.Context;
                var sharedPrefs = context.GetSharedPreferences(Alias, FileCreationMode.Private);
                var encryptedValue = sharedPrefs.GetString(key, null);
                return Task.FromResult(encryptedValue);
            }
        }

        public static bool Remove(string key)
        {
            lock (locker)
            {
                var context = Android.App.Application.Context;
                var sharedPrefs = context.GetSharedPreferences(Alias, FileCreationMode.Private);
                var editor = sharedPrefs.Edit();
                editor.Remove(key);
                return editor.Commit();
            }
        }

        public static void RemoveAll()
        {
            lock (locker)
            {
                var context = Android.App.Application.Context;
                var sharedPrefs = context.GetSharedPreferences(Alias, FileCreationMode.Private);
                var editor = sharedPrefs.Edit();
                editor.Clear();
                editor.Commit();
            }
        }
#endif

#if IOS
        public static Task GetAsync(string key)
        {
            var query = new SecRecord(SecKind.GenericPassword)
            {
                Service = Alias,
                Account = key
            };

            var match = SecKeyChain.QueryAsRecord(query, out var resultCode);
            if (resultCode == SecStatusCode.Success && match?.ValueData != null)
            {
                var value = NSString.FromData(match.ValueData, NSStringEncoding.UTF8);
                return Task.FromResult(value);
            }

            return Task.FromResult(null);
        }

        public static bool Remove(string key)
        {
            var query = new SecRecord(SecKind.GenericPassword)
            {
                Service = Alias,
                Account = key
            };

            var result = SecKeyChain.Remove(query);
            return result == SecStatusCode.Success;
        }

        public static void RemoveAll()
        {
            var query = new SecRecord(SecKind.GenericPassword)
            {
                Service = Alias
            };
            SecKeyChain.Remove(query);
        }
#endif
    }
}
#endif

Android

En Android, la clase LegacySecureStorage utiliza la clase AndroidKeyStore para almacenar la clave de cifrado que se usa al encriptar un valor antes de guardarlo en un objeto SharedPreferences con el nombre {tu-app-package-id}.xamarinessentials.

Agrega la clase llamada AndroidKeyStore dentro de la carpeta Platforms\Android de tu proyecto .NET MAUI.
using Android.Content;
using Android.OS;
using Android.Runtime;
using Android.Security;
using Android.Security.Keystore;
using Java.Security;
using Javax.Crypto;
using Javax.Crypto.Spec;
using System.Text;

namespace MigrationHelpers;

class AndroidKeyStore
{
    const string androidKeyStore = "AndroidKeyStore"; // this is an Android const value
    const string aesAlgorithm = "AES";
    const string cipherTransformationAsymmetric = "RSA/ECB/PKCS1Padding";
    const string cipherTransformationSymmetric = "AES/GCM/NoPadding";
    const string prefsMasterKey = "SecureStorageKey";
    const int initializationVectorLen = 12; // Android supports an IV of 12 for AES/GCM

    internal AndroidKeyStore(Context context, string keystoreAlias, bool alwaysUseAsymmetricKeyStorage)
    {
        alwaysUseAsymmetricKey = alwaysUseAsymmetricKeyStorage;
        appContext = context;
        alias = keystoreAlias;

        keyStore = KeyStore.GetInstance(androidKeyStore);
        keyStore.Load(null);
    }

    readonly Context appContext;
    readonly string alias;
    readonly bool alwaysUseAsymmetricKey;
    readonly string useSymmetricPreferenceKey = "essentials_use_symmetric";

    KeyStore keyStore;
    bool useSymmetric = false;

    ISecretKey GetKey()
    {
        // check to see if we need to get our key from past-versions or newer versions.
        // we want to use symmetric if we are >= 23 or we didn't set it previously.
        var hasApiLevel = Build.VERSION.SdkInt >= BuildVersionCodes.M;

        useSymmetric = Preferences.Get(useSymmetricPreferenceKey, hasApiLevel, alias);

        // If >= API 23 we can use the KeyStore's symmetric key
        if (useSymmetric && !alwaysUseAsymmetricKey)
            return GetSymmetricKey();

        // NOTE: KeyStore in < API 23 can only store asymmetric keys
        // specifically, only RSA/ECB/PKCS1Padding
        // So we will wrap our symmetric AES key we just generated
        // with this and save the encrypted/wrapped key out to
        // preferences for future use.
        // ECB should be fine in this case as the AES key should be
        // contained in one block.

        // Get the asymmetric key pair
        var keyPair = GetAsymmetricKeyPair();

        var existingKeyStr = Preferences.Get(prefsMasterKey, null, alias);

        if (!string.IsNullOrEmpty(existingKeyStr))
        {
            try
            {
                var wrappedKey = Convert.FromBase64String(existingKeyStr);

                var unwrappedKey = UnwrapKey(wrappedKey, keyPair.Private);
                var kp = unwrappedKey.JavaCast();

                return kp;
            }
            catch (InvalidKeyException ikEx)
            {
                System.Diagnostics.Debug.WriteLine($"Unable to unwrap key: Invalid Key. This may be caused by system backup or upgrades. All secure storage items will now be removed. {ikEx.Message}");
            }
            catch (IllegalBlockSizeException ibsEx)
            {
                System.Diagnostics.Debug.WriteLine($"Unable to unwrap key: Illegal Block Size. This may be caused by system backup or upgrades. All secure storage items will now be removed. {ibsEx.Message}");
            }
            catch (BadPaddingException paddingEx)
            {
                System.Diagnostics.Debug.WriteLine($"Unable to unwrap key: Bad Padding. This may be caused by system backup or upgrades. All secure storage items will now be removed. {paddingEx.Message}");
            }
            LegacySecureStorage.RemoveAll();
        }

        var keyGenerator = KeyGenerator.GetInstance(aesAlgorithm);
        var defSymmetricKey = keyGenerator.GenerateKey();

        var newWrappedKey = WrapKey(defSymmetricKey, keyPair.Public);

        Preferences.Set(prefsMasterKey, Convert.ToBase64String(newWrappedKey), alias);

        return defSymmetricKey;
    }

    // API 23+ Only
#pragma warning disable CA1416
    ISecretKey GetSymmetricKey()
    {
        Preferences.Set(useSymmetricPreferenceKey, true, alias);

        var existingKey = keyStore.GetKey(alias, null);

        if (existingKey != null)
        {
            var existingSecretKey = existingKey.JavaCast();
            return existingSecretKey;
        }

        var keyGenerator = KeyGenerator.GetInstance(KeyProperties.KeyAlgorithmAes, androidKeyStore);
        var builder = new KeyGenParameterSpec.Builder(alias, KeyStorePurpose.Encrypt | KeyStorePurpose.Decrypt)
            .SetBlockModes(KeyProperties.BlockModeGcm)
            .SetEncryptionPaddings(KeyProperties.EncryptionPaddingNone)
            .SetRandomizedEncryptionRequired(false);

        keyGenerator.Init(builder.Build());

        return keyGenerator.GenerateKey();
    }
#pragma warning restore CA1416

    KeyPair GetAsymmetricKeyPair()
    {
        // set that we generated keys on pre-m device.
        Preferences.Set(useSymmetricPreferenceKey, false, alias);

        var asymmetricAlias = $"{alias}.asymmetric";

        var privateKey = keyStore.GetKey(asymmetricAlias, null)?.JavaCast();
        var publicKey = keyStore.GetCertificate(asymmetricAlias)?.PublicKey;

        // Return the existing key if found
        if (privateKey != null && publicKey != null)
            return new KeyPair(publicKey, privateKey);

        var originalLocale = Java.Util.Locale.Default;
        try
        {
            // Force to english for known bug in date parsing:
            // https://issuetracker.google.com/issues/37095309
            SetLocale(Java.Util.Locale.English);

            // Otherwise we create a new key
#pragma warning disable CA1416
            var generator = KeyPairGenerator.GetInstance(KeyProperties.KeyAlgorithmRsa, androidKeyStore);
#pragma warning restore CA1416

            var end = DateTime.UtcNow.AddYears(20);
            var startDate = new Java.Util.Date();
#pragma warning disable CS0618 // Type or member is obsolete
            var endDate = new Java.Util.Date(end.Year, end.Month, end.Day);
#pragma warning restore CS0618 // Type or member is obsolete

#pragma warning disable CS0618
            var builder = new KeyPairGeneratorSpec.Builder(Platform.AppContext)
                .SetAlias(asymmetricAlias)
                .SetSerialNumber(Java.Math.BigInteger.One)
                .SetSubject(new Javax.Security.Auth.X500.X500Principal($"CN={asymmetricAlias} CA Certificate"))
                .SetStartDate(startDate)
                .SetEndDate(endDate);

            generator.Initialize(builder.Build());
#pragma warning restore CS0618

            return generator.GenerateKeyPair();
        }
        finally
        {
            SetLocale(originalLocale);
        }
    }

    byte[] WrapKey(IKey keyToWrap, IKey withKey)
    {
        var cipher = Cipher.GetInstance(cipherTransformationAsymmetric);
        cipher.Init(CipherMode.WrapMode, withKey);
        return cipher.Wrap(keyToWrap);
    }

#pragma warning disable CA1416
    IKey UnwrapKey(byte[] wrappedData, IKey withKey)
    {
        var cipher = Cipher.GetInstance(cipherTransformationAsymmetric);
        cipher.Init(CipherMode.UnwrapMode, withKey);
        var unwrapped = cipher.Unwrap(wrappedData, KeyProperties.KeyAlgorithmAes, KeyType.SecretKey);
        return unwrapped;
    }
#pragma warning restore CA1416

    internal string Decrypt(byte[] data)
    {
        if (data.Length < initializationVectorLen)
            return null;

        var key = GetKey();

        // IV will be the first 16 bytes of the encrypted data
        var iv = new byte[initializationVectorLen];
        Buffer.BlockCopy(data, 0, iv, 0, initializationVectorLen);

        Cipher cipher;

        // Attempt to use GCMParameterSpec by default
        try
        {
            cipher = Cipher.GetInstance(cipherTransformationSymmetric);
            cipher.Init(CipherMode.DecryptMode, key, new GCMParameterSpec(128, iv));
        }
        catch (InvalidAlgorithmParameterException)
        {
            // If we encounter this error, it's likely an old bouncycastle provider version
            // is being used which does not recognize GCMParameterSpec, but should work
            // with IvParameterSpec, however we only do this as a last effort since other
            // implementations will error if you use IvParameterSpec when GCMParameterSpec
            // is recognized and expected.
            cipher = Cipher.GetInstance(cipherTransformationSymmetric);
            cipher.Init(CipherMode.DecryptMode, key, new IvParameterSpec(iv));
        }

        // Decrypt starting after the first 16 bytes from the IV
        var decryptedData = cipher.DoFinal(data, initializationVectorLen, data.Length - initializationVectorLen);

        return Encoding.UTF8.GetString(decryptedData);
    }

    internal void SetLocale(Java.Util.Locale locale)
    {
        Java.Util.Locale.Default = locale;
        var resources = appContext.Resources;
        var config = resources.Configuration;

        if (Build.VERSION.SdkInt >= BuildVersionCodes.N)
            config.SetLocale(locale);
        else
#pragma warning disable CS0618 // Type or member is obsolete
            config.Locale = locale;
#pragma warning restore CS0618 // Type or member is obsolete

#pragma warning disable CS0618 // Type or member is obsolete
        resources.UpdateConfiguration(config, resources.DisplayMetrics);
#pragma warning restore CS0618 // Type or member is obsolete
    }
}

iOS

En iOS, la clase LegacySecureStorage utiliza la clase KeyChain para almacenar valores de forma segura. El SecRecord que se usa para guardar los valores tiene el campo Service configurado como {tu-app-package-id}.xamarinessentials.

Agrega la clase llamada KeyChain dentro de la carpeta Platforms\iOS de tu proyecto .NET MAUI.
using Foundation;
using Security;

namespace MigrationHelpers;

class KeyChain
{
    SecRecord ExistingRecordForKey(string key, string service)
    {
        return new SecRecord(SecKind.GenericPassword)
        {
            Account = key,
            Service = service
        };
    }

    internal string ValueForKey(string key, string service)
    {
        using (var record = ExistingRecordForKey(key, service))
        using (var match = SecKeyChain.QueryAsRecord(record, out var resultCode))
        {
            if (resultCode == SecStatusCode.Success)
                return NSString.FromData(match.ValueData, NSStringEncoding.UTF8);
            else
                return null;
        }
    }

    internal bool Remove(string key, string service)
    {
        using (var record = ExistingRecordForKey(key, service))
        using (var match = SecKeyChain.QueryAsRecord(record, out var resultCode))
        {
            if (resultCode == SecStatusCode.Success)
            {
                RemoveRecord(record);
                return true;
            }
        }
        return false;
    }

    internal void RemoveAll(string service)
    {
        using (var query = new SecRecord(SecKind.GenericPassword) { Service = service })
        {
            SecKeyChain.Remove(query);
        }
    }

    bool RemoveRecord(SecRecord record)
    {
        var result = SecKeyChain.Remove(record);
        if (result != SecStatusCode.Success && result != SecStatusCode.ItemNotFound)
            throw new Exception($"Error removing record: {result}");

        return true;
    }
}

Finalmente, en tu código, lee y migra datos de la siguiente manera:

// Ejemplo: migrar el "username"
string? username = await LegacySecureStorage.GetAsync("username");

if (!string.IsNullOrEmpty(username))
{
    // Guardar en MAUI SecureStorage
    await SecureStorage.Default.SetAsync("username", username);

    // Eliminar del almacenamiento antiguo
    LegacySecureStorage.Remove("username");
}

En resumen

MAUI y Xamarin.Essentials usan almacenamiento seguro con nombres/identificadores distintos a nivel interno.
Lo que guardaste con Xamarin no se encuentra automáticamente al pasar a MAUI.

Qué hacemos
1. Leemos lo antiguo con LegacySecureStorage (apunta a los mismos contenedores/alias de Xamarin).
2. Lo guardamos de nuevo con SecureStorage.Default (el formato/ubicación que entiende MAUI).
3. Limpiamos lo viejo para no dejar basura y evitar inconsistencias.
4. Ejecutamos la migración al iniciar la app para que el usuario ni lo note.

Recomendación extra

Con esto, la migración queda transparente para el usuario (sin re-login ni reconfiguraciones) y tu base de datos segura queda alineada al modelo de MAUI. Nos leemos pronto!

Comentarios