デスクトップアプリでシークレットキーを安全に管理する

# 概要
- デスクトップアプリのようなパブリッククライアントで、シークレットキーを安全に取り扱う方法の覚え書きです
- クラウドサービスのAPIを呼び出すためのシークレットキーを、Azure KeyVaultを使って安全に保存します
- KeyVaultからシークレットキーを読み取る際の認証には、Windows統合認証により、デスクトップアプリを利用するユーザーのEntraIDを使用します


# Azure KeyVaultの準備
割愛します。
シークレットキーの登録と、ロールの割り当てにより、対象利用者がシークレットキーを読み取りできるように設定しておきます。


# デスクトップアプリ
C# .NETでデスクトップアプリを作成します。
シンプルにシークレットキーを取得して表示するだけのアプリを例にします。

簡単な要件
- Get Secretボタンを押すとKeyVaultからシークレットキーを取得する
- 認証画面はWindows統合認証画面として、既にログイン済みならIDを選択するだけでログインできる
- トークン情報はディスクキャッシュされ、次回以降は認証画面がでない
- おまけとして、キャッシュ削除機能もつける
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/293667/fdf4fc6b-17fd-cd2f-e572-51342f0291e3.png)



# コード

## KeyVault呼び出し部分



```cs:AzureKeyVault.cs

    public class AzureKeyVault
    {
        private readonly string keyVaultName;

        public AzureKeyVault(string keyVaultName)
        {
            this.keyVaultName = keyVaultName;
        }

        // KeyVaultから指定のシークレットを取得します
        public async Task<string?> GetSecretAsync(string secretName, nint handle)
        {
            var credential = GetInteractiveCredential(handle);
            var secret = await GetKeyVaultSecretAsync(secretName, credential);
            return secret?.Value ?? "";
        }

        // トークンの取得方法を定義します。
        private TokenCredential GetInteractiveCredential(nint handle)
        {
            // ディスクキャッシュ有効化オプション
            TokenCachePersistenceOptions tokenCachePersistenceOptions = new();

            // トークン取得チェーンを定義
            // ディスクキャッシュ→統合認証の順で試行
            ChainedTokenCredential credential = new(


                new SharedTokenCacheCredential(
                    new SharedTokenCacheCredentialBrokerOptions(tokenCachePersistenceOptions) { 
                        TenantId = "Your TenantId"
                    }
                ),
                new InteractiveBrowserCredential(new InteractiveBrowserCredentialBrokerOptions(handle)
                {
                    TenantId = "Your TenantId",
                    TokenCachePersistenceOptions = tokenCachePersistenceOptions
                })

                );
            
            
            return credential;
        }
        
        // KeyVaultに接続してシークレットを取得します。
        private async Task<KeyVaultSecret?> GetKeyVaultSecretAsync(string secretName, TokenCredential credential)
        {

            var kvUri = $"https://{keyVaultName}.vault.azure.net";
            // KeyVaultクライアントを作成
            var client = new SecretClient(new Uri(kvUri), credential);

            // シークレットの取得
            KeyVaultSecret? secret;
            try
            {
                // このタイミングで、credentialで定義した認証が実施される
                // 指定されたシークレットを取得
                secret = await client.GetSecretAsync(secretName);

            }
            catch (AuthenticationFailedException e)
            {
                Debug.WriteLine("");
                secret = null;

            }
            catch (RequestFailedException e)
            {
                Debug.WriteLine("KeyVaultへのリクエストが失敗しました。");
                secret = null;
            }

            return secret;
        }
    }
```


### 解説
- credentialの取得には、ChainedTokenCredentialを使って任意の順番で認証方法を定義
- InteractiveBrowserCredentialのoptionsにInteractiveBrowserCredentialBrokerOptionsを指定することで、Windows統合認証を利用できる。これは拡張ライブラリの機能らしい
- SharedTokenCacheCredentialのoptionsにも、SharedTokenCacheCredential**Broker**Optionsを指定する必要がある
- テナントIDは別途指定してください

## フォーム側


```cs

    public partial class Form1 : Form
    {
        readonly string _keyVaultName = Properties.Settings.Default.KeyVaultName;
        readonly string _secretName = Properties.Settings.Default.SecretName;
        
        string _aipKey = "";
        AzureKeyVault _keyVault;


        public Form1()
        {
            InitializeComponent();
            _keyVault = new AzureKeyVault(_keyVaultName);
        }

        private async void Button1_ClickAsync(object sender, EventArgs e)
        {
            // KeyVault起動
            var secretKey = await _keyVault.GetSecretAsync(_secretName,this.Handle);

            _aipKey = secretKey ?? "";
            textBox1.Text = _aipKey;

        }

    }

```

### 解説
- GetSecretAsyncのときに親ウィンドウのウィンドウハンドルを渡してあげることで、統合認証が子ウィンドウとして表示される

## 考察
認証フローはAzure.Identityが担当するが、その裏側にはMSAL.NETがあり、テナントIDとKeyVaultのアプリケーションIDを使ってパブリッククライアントを作成し、ユーザーのEntraIDで認証させている？と思われる。

TokenCredentialの取得について、標準的なドキュメントでは、AzureDefaultCredential()を使用する例がほとんどですが、Azureサービスから呼び出すことが想定されています。

```cs
string keyVaultName = Environment.GetEnvironmentVariable("KEY_VAULT_NAME");
var kvUri = "https://" + keyVaultName + ".vault.azure.net";

var client = new SecretClient(new Uri(kvUri), new DefaultAzureCredential());
```

> DefaultAzureCredentialは、アプリケーションが最終的に Azure で実行されることを意図しているほとんどのシナリオに適しています。

InteractiveBrowserCredentialは既定では無効で、オプションの指定で有効にできますが、まず他の認証方法を試す関係で、認証画面がでるまで時間がかかります。
なので予め認証方法が決まっている場合は、ChainedTokenCredentialを使った方がいいと思います。

## 疑問だった点と調べた結果

**Azure Entraのアプリ登録を行わず、アプリケーションIDなしで認証できるのはなぜ？**  

Azure Entra IDでは、Azureの各サービスごとにMicrosoftアプリケーションとして既定でアプリ登録されており、サービスごとの固有のアプリケーションIDがある。
KeyVaultのアプリケーションIDやスコープは、どのテナントであっても共通で、SDK内に埋め込まれている？ので省略しても認証できる。
ユーザーにKeyVaultの権限さえあればOKというわけ。

<br>

# 参考
今回のChainedTokenCredentialを使った方法
https://github.com/Azure/azure-sdk-for-net/issues/23896

公式情報、自前でTokenをディスクに保存する方法
https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/identity/Azure.Identity/samples/ClientSideUserAuthentication.md

.NET 用 Azure Identity クライアント ライブラリ
https://learn.microsoft.com/ja-jp/dotnet/api/overview/azure/identity-readme?view=azure-dotnet

Azure.IdentityのTokenCredentialの認証全部試す(その1)
https://takasdev.hatenablog.com/entry/2021/10/31/181647

Azure.Identity.Broker
https://learn.microsoft.com/ja-jp/dotnet/api/overview/azure/identity.broker-readme?view=azure-dotnet


# トークンキャッシュを削除したい場合

今回のSDKで保存したファイルは```AppData\Local\.IdentityServiceのmsal.cache.cae```に保存されるようです。
別のアカウントで認証させたいときはトークンキャッシュを削除する必要があります。  

直接ファイルを消してもいいと思いますが、コードで消す方法も探してみました。
半分以上Copilotさんに助けてもらいました。

AzureのSDKには直接方法がなさそうなので、直接MSALを使って削除します。
MSALを使ってキャッシュを保存したり削除したりするやり方と同じだと思います。

```cs
public async Task  RemoveTokenCacheAsync()
{
    // MSALを使う
    var keyVaultAppId = "cfa8b339-82a2-471a-a3c9-0fc0be7a4093";
    var tenantId = "Your Tenant Id";
    var app = PublicClientApplicationBuilder.Create(keyVaultAppId)
    .WithAuthority(AzureCloudInstance.AzurePublic, tenantId)
    // Windows認証ブローカーを使用した場合は以下オプションが必要
    .WithBroker(new BrokerOptions(BrokerOptions.OperatingSystems.Windows))
    .Build();

    // キャッシュの読み込み前、読み込み後のイベント登録 
    TokenCacheHelper.EnableSerialization(app.UserTokenCache);
    
    // キャッシュからアカウントを取得
    var account = await app.GetAccountsAsync();
    var first = account.FirstOrDefault() ?? null;

    if (first == null) { return; }
 
    // アカウントのキャッシュを削除
    // TokenCacheHelperによって削除した結果が保存される
    await app.RemoveAsync(first);

}

```


TokenCacheHelperはMSALのドキュメントにあるものを貼りつけてキャッシュのパスだけ改造
https://learn.microsoft.com/ja-jp/entra/msal/dotnet/how-to/custom-token-cache-in-public-client-applications

```cs:TokenCacheHelper.cs

    static class TokenCacheHelper
    {
        public static void EnableSerialization(ITokenCache tokenCache)
        {
            tokenCache.SetBeforeAccess(BeforeAccessNotification);
            tokenCache.SetAfterAccess(AfterAccessNotification);
        }

        /// <summary>
        /// Path to the token cache
        /// </summary>
        public static readonly string CacheFilePath = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), ".IdentityService", "msal.cache.cae");

        private static readonly object FileLock = new object();


        private static void BeforeAccessNotification(TokenCacheNotificationArgs args)
        {
            lock (FileLock)
            {
                args.TokenCache.DeserializeMsalV3(File.Exists(CacheFilePath)
                        ? ProtectedData.Unprotect(File.ReadAllBytes(CacheFilePath),
                                                  null,
                                                  DataProtectionScope.CurrentUser)
                        : null);
            }
        }

        private static void AfterAccessNotification(TokenCacheNotificationArgs args)
        {
            // if the access operation resulted in a cache update
            if (args.HasStateChanged)
            {
                lock (FileLock)
                {
                    // reflect changes in the persistent store
                    File.WriteAllBytes(CacheFilePath,
                                        ProtectedData.Protect(args.TokenCache.SerializeMsalV3(),
                                                                null,
                                                                DataProtectionScope.CurrentUser)
                                        );
                }
            }
        }
    }
```



# あとがき
パブリッククライアントを使った事例が少なったので記事にしてみました。
公式情報はWebブラウザを使ったコードが記載されています。Webブラウザの場合は認証後のメッセージが英語なのでユーザー向けに改造する必要があったりして面倒だったので、統合認証を選択しました。