Xamarin.Essentials のセキュリティで保護されたストレージから .NET MAUI のセキュリティで保護されたストレージに移行する

Xamarin.Essentials と .NET Multi-Platform App UI (.NET MAUI) には、どちらも単純なキーと値のペアを安全に格納するのに役立つ SecureStorage クラスがあります。 ただし、Xamarin.Essentials の SecureStorage クラスと .NET MAUI の間には実装の違いがあります。

プラットフォーム Xamarin.Essentials .NET MAUI
Android Android キーストアは、共有の設定オブジェクトに {your-app-package-id}.xamarinessentials というファイル名で保存する前に、値を暗号化するための暗号キーを格納するために使用されます。 データは、SharedPreferences クラスをラップする EncryptedSharedPreferences クラスで暗号化され、キーと値が自動的に暗号化されます。 使用される名前は {your-app-package-id}.microsoft.maui.essentials.preferences です。
iOS キーチェーンは、値を安全に格納するために使用されます。 値を格納するために使用される SecRecord は、{your-app-package-id}.xamarinessentials に設定された Service 値を持ちます。 キーチェーンは、値を安全に格納するために使用されます。 値の格納に使用される SecRecord には、Service 値が {your-app-package-id}.microsoft.maui.essentials.preferences に設定されています。

Xamarin.Essentials の SecureStorage クラスの詳細については、「Xamarin.Essentials: セキュリティで保護されたストレージ」をご覧ください。 .NET MAUI の SecureStorage クラスの詳細については、「セキュリティで保護されたストレージ」をご覧ください。

SecureStorage クラスを使用する Xamarin.Forms アプリを .NET MAUI に移行する場合は、これらの実装の違いに対処して、ユーザーにスムーズなアップグレード エクスペリエンスを提供する必要があります。 この記事では、LegacySecureStorage クラスとヘルパー クラスを使用して実装の違いに対処する方法について説明します。 LegacySecureStorage クラスを使用すると、Android および iOS 上の .NET MAUI アプリが、アプリの以前の Xamarin.Forms バージョンで作成されたセキュリティで保護されたストレージ データを読み取ることができます。

従来のセキュリティで保護されたストレージ データにアクセスする

次のコードは、Xamarin.Essentials からのセキュリティで保護されたストレージ実装を提供する LegacySecureStorage クラスを示しています。

Note

このコードを使用するには、.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 クラスは AndroidKeyStore クラスを使用して、共有の設定オブジェクトに {your-app-package-id}.xamarinessentials という名前で保存される前に、値を暗号化するための暗号キーを格納します。 次のコードは AndroidKeyStore クラスを示しています。

Note

このコードを使用するには、.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 キーストアは、共有設定{your-app-package-id}.xamarinessentials というファイル名で保存する前に、値を暗号化するための暗号キーを格納するために使用されます。 共有設定ファイルで使用されるキー (暗号化キーではなくキー) は、SecureStorage API に渡されるキーの MD5 ハッシュです。

API 23 以上では、Android キーストアから AES キーを取得し、AES/GCM/NoPadding 暗号と共に使用して、共有設定ファイルに格納する前に値を暗号化します。 API 22 以前では、Android キーストアは RSA キーの格納のみをサポートしています。これは RSA/ECB/PKCS1Padding 暗号と共に使用され、AES キー (実行時にランダムで生成されます) を暗号化し、キー SecureStorageKey の下で共有設定ファイルに格納されます (まだ作成されていない場合)。

iOS

iOS では、LegacySecureStorage クラスは KeyChain クラスを使用して値を安全に格納します。 値を格納するために使用される SecRecord は、{your-app-package-id}.xamarinessentials に設定された Service 値を持ちます。 次のコードは KeyChain クラスを示しています。

Note

このコードを使用するには、.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 Entitlements」をご覧ください。

従来のセキュリティで保護されたストレージ データを使用する

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 セキュリティで保護されたストレージに書き込む方法を示します。