Xamarin.Essentials 보안 스토리지에서 .NET MAUI 보안 스토리지로 마이그레이션

Xamarin.Essentials 및 .NET 다중 플랫폼 앱 UI(.NET MAUI)에는 SecureStorage 모두 간단한 키/값 쌍을 안전하게 저장하는 데 도움이 되는 클래스가 있습니다. 그러나 Xamarin.Essentials와 .NET MAUI의 클래스 간에 SecureStorage 는 구현 차이점이 있습니다.

플랫폼 Xamarin.Essentials .NET MAUI
Android Android KeyStore는 값이 {your-app-package-id}.xamarinessentials의 이름으로 공유 기본 설정 개체에 저장되기 전에 값을 암호화하는 데 사용되는 암호화 키를 저장하는 데 사용됩니다. 데이터는 클래스를 EncryptedSharedPreferences 래핑 SharedPreferences 하고 키와 값을 자동으로 암호화하는 클래스로 암호화됩니다. 사용되는 이름은 {your-app-package-id}.microsoft.maui.essentials.preferences입니다.
iOS KeyChain은 값을 안전하게 저장하는 데 사용됩니다. SecRecord 값을 저장하는 데 사용되는 값은 Service {your-app-package-id}.xamarinessentials로 설정됩니다. KeyChain은 값을 안전하게 저장하는 데 사용됩니다. SecRecord 값을 저장하는 데 사용되는 값은 Service {your-app-package-id}.microsoft.maui.essentials.preferences로 설정됩니다.

Xamarin.Essentials의 SecureStorage 클래스에 대한 자세한 내용은 Xamarin.Essentials: Secure Storage를 참조하세요. .NET MAUI의 SecureStorage 클래스에 대한 자세한 내용은 Secure Storage를 참조하세요.

클래스를 사용하는 SecureStorage Xamarin.Forms 앱을 .NET MAUI로 마이그레이션하는 경우 사용자에게 원활한 업그레이드 환경을 제공하기 위해 이러한 구현 차이점을 처리해야 합니다. 이 문서에서는 클래스 및 도우미 클래스를 LegacySecureStorage 사용하여 구현 차이점을 처리하는 방법을 설명합니다. 이 LegacySecureStorage 클래스를 사용하면 Android 및 iOS의 .NET MAUI 앱이 이전 Xamarin.Forms 버전의 앱으로 만든 보안 스토리지 데이터를 읽을 수 있습니다.

레거시 보안 스토리지 데이터 액세스

다음 코드는 Xamarin.Essentials에서 보안 스토리지 구현을 제공하는 클래스를 보여 LegacySecureStorage 줍니다.

참고 항목

이 코드를 사용하려면 .NET MAUI 앱 프로젝트에서 명명된 LegacySecureStorage 클래스에 추가합니다.

#nullable enable
#if ANDROID || IOS

namespace MigrationHelpers;

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

    public static Task<string> GetAsync(string key)
    {
        if (string.IsNullOrWhiteSpace(key))
            throw new ArgumentNullException(nameof(key));

        string result = string.Empty;

#if ANDROID
        object locker = new object();
        string? encVal = Preferences.Get(key, null, Alias);

        if (!string.IsNullOrEmpty(encVal))
        {
            byte[] encData = Convert.FromBase64String(encVal);
            lock (locker)
            {
                AndroidKeyStore keyStore = new AndroidKeyStore(Platform.AppContext, Alias, false);
                result = keyStore.Decrypt(encData);
            }
        }
#elif IOS
        KeyChain keyChain = new KeyChain();
        result = keyChain.ValueForKey(key, Alias);
#endif
        return Task.FromResult(result);
    }

    public static bool Remove(string key)
    {
        bool result = false;

#if ANDROID
        Preferences.Remove(key, Alias);
        result = true;
#elif IOS
        KeyChain keyChain = new KeyChain();
        result = keyChain.Remove(key, Alias);
#endif
        return result;
    }

    public static void RemoveAll()
    {
#if ANDROID
        Preferences.Clear(Alias);
#elif IOS
        KeyChain keyChain = new KeyChain();
        keyChain.RemoveAll(Alias);
#endif
    }
}
#endif

Android

Android에서 클래스는 LegacySecureStorage {your-app-package-id}.xamarinessentials라는 이름의 공유 기본 설정 개체에 저장되기 전에 값을 암호화하는 데 사용되는 암호 키를 저장하는 데 클래스를 사용합니다 AndroidKeyStore . 다음 코드에서는 AndroidKeyStore 클래스를 보여 줍니다.

참고 항목

이 코드를 사용하려면 .NET MAUI 앱 프로젝트의 Platforms\Android 폴더에 있는 AndroidKeyStore 클래스에 추가합니다.

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<ISecretKey>();

                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<ISecretKey>();
            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<IPrivateKey>();
        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
    }
}

Android KeyStore는 값이 {your-app-package-id}.xamarinessentials의 이름으로 공유 기본 설정파일에 저장되기 전에 값을 암호화하는 데 사용되는 암호 키를 저장하는 데 사용됩니다. 공유 기본 설정 파일에 사용되는 키(암호화 키가 아닌 값)는 APISecureStorage 전달된 키의 MD5 해시입니다.

API 23 이상에서 AES 키는 Android KeyStore에서 가져오고 AES/GCM/NoPadding 암호와 함께 사용하여 공유 기본 설정 파일에 저장되기 전에 값을 암호화합니다. API 22 이하에서 Android KeyStore는 RSA 키만 저장하도록 지원합니다. RSA 키는 RSA/ECB/PKCS1Padding 암호화와 함께 AES 키를 암호화하는 데 사용되며(런타임에 임의로 생성됨) 아직 생성되지 않은 경우 SecureStorageKey아래의 공유 기본 설정 파일에 저장됩니다.

iOS

iOS에서 클래스는 LegacySecureStorage 클래스를 KeyChain 사용하여 값을 안전하게 저장합니다. SecRecord 값을 저장하는 데 사용되는 값은 Service {your-app-package-id}.xamarinessentials로 설정됩니다. 다음 코드에서는 KeyChain 클래스를 보여 줍니다.

참고 항목

이 코드를 사용하려면 .NET MAUI 앱 프로젝트의 Platforms\iOS 폴더에 명명된 KeyChain 클래스에 추가합니다.

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;
    }
}

이 코드를 사용하려면 키 집합 자격 집합이 있는 iOS 앱에 대한 Entitlements.plist 파일이 있어야 합니다.

<key>keychain-access-groups</key>
<array>
  <string>$(AppIdentifierPrefix)$(CFBundleIdentifier)</string>
</array>

또한 Entitlements.plist 파일이 앱의 번들 서명 설정에서 사용자 지정 권한 필드로 설정되어 있는지 확인해야 합니다. 자세한 내용은 iOS 자격을 참조 하세요.

레거시 보안 스토리지 데이터 사용

이 클래스는 LegacySecureStorage 이전 Xamarin.Forms 버전의 앱으로 만든 Android 및 iOS의 레거시 보안 스토리지 데이터를 사용하는 데 사용할 수 있습니다.

#if ANDROID || IOS
using MigrationHelpers;
...

string username = await LegacySecureStorage.GetAsync("username");
bool result = LegacySecureStorage.Remove("username");
await SecureStorage.SetAsync("username", username);
#endif

이 예제에서는 클래스를 LegacySecureStorage 사용하여 레거시 보안 스토리지에서 값을 읽고 제거한 다음.NET MAUI 보안 스토리지에 값을 쓰는 방법을 보여 줍니다.