VBAでAIを使いたい人必見!APIキーを安全に保管する一番優れた手順
世は生成AI時代到来で、昨今のセキュリティ要求により制限が生まれつつあるVBAですが、そんなVBAでもGemini APIやChatGPT APIといった生成AIを使いたい。ローカル環境でExcel等と連携して動かせるというのは大きな魅力があります。
しかし、如何せん古い仕組みゆえ、APIキーやClient IDなどを安全に保管する手段を持っていません。そこで、この弱点部分を克服して、さらにVBAを一歩先に進めて、生成AIと連携する前準備を今回の取組みで実現してみたいと思います。
目次
今回利用する素材
- VBAから資格情報を安全管理 - Excel
- PowerShell
- DPAPI
- Credential Manager
- Microsoft.PowerShell.SecretManagement
- Microsoft.PowerShell.SecretStore
VBA自身はWindowsの資格情報マネージャにアクセスすることは出来ません。しかし、PowerShellはモジュールを通して読み書きをすることができるので、コマンドラインを通じてやり取りが可能です。今回はWindowsにデフォルト搭載されてる旧版PowerShellと、別途インストールが必要な新版PowerShell 7.x系の2つを利用します。
旧版では資格情報マネージャの読み書き、DPAPI読み書きを行い、新版ではよりモダンでマルチプラットフォームで利用できる暗号化コンテナを使った安全な情報の保存の2パターンを扱います。
※資格情報マネージャはID/PW保存に向いていますが、Windowsのみ且つ512バイトしか長さが無いので、Access Tokenのような長いデータは保管できないので、そちらはDPAPIを使う。またmacOSのような別プラットフォームの場合は、PowerShell 7.x系利用で暗号化コンテナにてID/PWもAccess Tokenも管理するといったような、使い分けや環境の差があるので、状況に応じて利用する必要があります。
※過去にNode.js + KeytarをEXE化したもので資格情報マネージャ読み書きプログラムを以下のエントリーで作っています。
モジュールの追加
PowerShellに対してモジュールを追加することで資格情報マネージャやDPAPI、暗号化コンテナによる安全なAPIキーの保管を実現することが出来ます。しかし、旧版PowerShellと新版PowerShellとでちょっと異なる点があり、 VBAから呼び出す際のコードでも両者で差があります。
資格情報マネージャ読み書き用
Windowsの古来よりあるIDやPWを安全に管理する場所として、資格情報マネージャがあります。macOSで言うkeychainのようなもので、この領域の読み書きの為のモジュールを追加する必要があります。今回は全ユーザではなくカレントユーザーに対して追加を行います。
また、このモジュールは旧版のPowerShell(つまり、現在のWindowsにデフォルトで搭載済みのもの)に対してインストールするもので、7.x系では互換性が無い為、VBAからの呼び出し時にはエラーになりますので注意(厳密に言うと7.x系は後述の暗号化コンテナ利用に移行してるので資格情報マネージャを使わない)。
まずは、旧版のPowerShellを起動します。ファイル名を指定して実行等で以下のコマンドを実行します(powershell.exeです)。
%SystemRoot%\SysWOW64\WindowsPowerShell\v1.0\powershell.exe
PowerShellが起動したら、以下のコマンドを実行してモジュールをインストールします。
Install-Module CredentialManager -Scope CurrentUser -Force Import-Module CredentialManager Get-Command -Module CredentialManager
以下のスクショのような画面が出てきたら、導入完了です。
図:Credential Managerの追加
暗号化コンテナを利用
PowerShell 7.xからはWindowsだけじゃなくmacOS等もサポートされるマルチプラットフォームアプリとなっているため、APIキーの保管などの為の場所は、Windows専用の資格情報マネージャではなく、暗号化コンテナを利用した保管庫を使う方式がモダンな手法となってるようです。
この保管庫を利用する為のモジュールをインストールして利用することになるため、APIキー等は資格情報マネージャ上で管理されません。また、旧版のPowerShellではこの機能は利用することが出来ません。
まずは新版のPowerShell 7.xを以降を起動します(pwsh.exeとなってる)
Install-Module Microsoft.PowerShell.SecretManagement -Scope CurrentUser -Force Install-Module Microsoft.PowerShell.SecretStore -Scope CurrentUser -Force Register-SecretVault -Name SecretStore -ModuleName Microsoft.PowerShell.SecretStore -DefaultVault Set-SecretStoreConfiguration -Interaction None Set-SecretStoreConfiguration -Authentication None Get-InstalledModule Microsoft.PowerShell.SecretManagement
これにより、Windowsの場合であれば以下の場所に暗号化された状態で情報が保存されます。
- C:\Users\あなたのユーザー名\.secretmanagement\secretvault.xml (設定ファイル)
- C:\Users\あなたのユーザー名\.secretstore\secretstore.win.dat (暗号化されたストアファイル)
さらにこの保管庫に2番目のパスワード設定を加えてセキュリティ強化もできるのですが、これをしてしまうとVBA側から毎回情報を読み書きする際のパスワード入力用のダイアログ表示と、受け渡しのコードを組み立てる必要があります。
# VBA内で組み立てるコマンドのイメージ & { $password = ('ここに保管庫のパスワードを入力' | ConvertTo-SecureString -AsPlainText -Force); Unlock-SecretStore -Password $password; Get-Secret -Name 'MyVbaApp/ApiKey' -AsPlainText }
ノーマルの状態でも十分扱えるので、今回二番目のパスワードをセットして強化せず暗号化コンテナのまま使うことで進めます。
図:無事にモジュールが導入できました
ソースコード
注意点
PowerShellを利用しているため、実行するたびにPowerShellの黒い画面が一瞬表示して消えるという動作をします。非表示や最小化を指定して実行もできますが、その場合返り値を受け取れなくなるため実質この動作については省略することが出来ません。
また、旧版と新版ではそれぞれでモジュールのインストールが必要で共用していません。そして互換性の面でも乖離があるため、5.x系と7.x系では読み書き対象も変わっています。7.x系は標準装備されていないので別途マシンにインストールも必要です。
また、7.x系のモジュールがOSの.netframeworkを利用している為、時に破損していて実行エラーになることがあります。その場合は、OS側で修復が必要です。以下のコマンドを利用し修復後再起動します。「コンポーネント ストアは修復できます。」というメッセージがチェック時に出たら、破損してる可能性があるので、修復しておきましょう。
sfc /scannow DISM /Online /Cleanup-Image /CheckHealth DISM /Online /Cleanup-Image /RestoreHealth
なお、今自分が起動してるPowershellのバージョンを調べるには以下のコマンドを実行します。
$PSVersionTable.PSVersion
旧版PowerShell用
資格情報マネージャの読み書き
Windows標準装備のID/PW管理をする資格情報マネージャに対して、エントリーを読み書き削除する為のコードです。以下のコードでは、「excelman2/clientid」というエントリーに対して、「your_client_id_here」や「deep-purple2323」といった値を格納するようにしています。
旧版のPowerShell 5.x系で実行することが可能です。これで安全にREST APIで使うClient IDやSecretを保管可能です。但し、512バイト制限があるので、Access Tokenのような長い文字列は次項のDPAPI読み書きを利用します。
図:VBAからPowerShell越しに読み書き出来た
Option Explicit ' PowerShellコマンドを実行し、結果(標準出力)を文字列として返す共通関数 Private Function RunPowerShell(ByVal command As String) As String Dim wsh As Object Set wsh = CreateObject("WScript.Shell") ' PowerShellを非表示ウィンドウで実行し、標準出力を受け取る Dim exec As Object Set exec = wsh.exec("powershell.exe -NoProfile -Command ""& {" & command & "}""") ' 実行が終了するまで待機 Dim startTime As Single startTime = Timer Do While exec.Status = 0 DoEvents '10秒でタイムアウト If Timer - startTime > 10 Then exec.Terminate Exit Do End If Loop ' 標準出力を読み取る If Not exec.StdOut.AtEndOfStream Then RunPowerShell = exec.StdOut.ReadAll() End If ' エラー出力を確認(デバッグ用) If Not exec.StdErr.AtEndOfStream Then Debug.Print "PowerShell Error: " & exec.StdErr.ReadAll() End If End Function ' 資格情報を資格情報マネージャーに保存する Public Sub SaveCredential(ByVal targetName As String, ByVal userName As String, ByVal secret As String) ' PowerShellではシングルクォートが特別な意味を持つため、エスケープする Dim escapedSecret As String escapedSecret = Replace(secret, "'", "''") 'PowerShellコマンドを組み立てる Dim psCommand As String psCommand = "Import-Module CredentialManager; " & _ "$cred = New-Object System.Management.Automation.PSCredential ('" & userName & "', ('" & escapedSecret & "' | ConvertTo-SecureString -AsPlainText -Force)); " & _ "New-StoredCredential -Target '" & targetName & "' -Credential $cred" ' 実行するだけなので戻り値は不要 RunPowerShell psCommand Debug.Print "Credential '" & targetName & "' saved." End Sub ' 資格情報マネージャーからシークレットを読み取る Public Function ReadCredential(ByVal targetName As String) As String 'PowerShellコマンドを組み立てる Dim psCommand As String psCommand = "Import-Module CredentialManager; " & _ "$cred = Get-StoredCredential -Target '" & targetName & "'; " & _ "if ($cred) { $cred.GetNetworkCredential().Password }" '結果を受け取る Dim result As String result = RunPowerShell(psCommand) ' PowerShellが出力する末尾の改行を削除 ReadCredential = Replace(Replace(result, vbCr, ""), vbLf, "") End Function ' 資格情報マネージャーから資格情報を削除する Public Sub DeleteCredential(ByVal targetName As String) 'PowerShellコマンドを組み立てる Dim psCommand As String psCommand = "Import-Module CredentialManager; " & _ "Remove-StoredCredential -Target '" & targetName & "'" RunPowerShell psCommand Debug.Print "Credential '" & targetName & "' deleted." End Sub ' ================================================= ' === 実行サンプル === ' ================================================= Public Sub CredentialManager_Test() ' 資格情報のターゲット名(アプリごとに一意な名前) Const CRED_TARGET As String = "excelman2/clientid" Const CLIENT_ID As String = "your_client_id_here" Dim clientSecret As String clientSecret = "deep-purple2323" ' 資格情報を保存 ' この処理は初回やシークレット変更時に一度だけ実行すればOK SaveCredential CRED_TARGET, CLIENT_ID, clientSecret ' 資格情報を読み取り Dim retrievedSecret As String retrievedSecret = ReadCredential(CRED_TARGET) '読み取り結果を表示 If retrievedSecret <> "" Then Debug.Print "Target: " & CRED_TARGET Debug.Print "User (Client ID): " & CLIENT_ID Debug.Print "Secret read from manager: " & retrievedSecret ' APIリクエストなどで取得したシークレットを利用 ' MsgBox "取得したシークレット: " & retrievedSecret Else ' MsgBox "シークレットの取得に失敗しました。" Debug.Print "Failed to read secret for target: " & CRED_TARGET End If ' 3. (任意) 資格情報を削除 ' Debug.Print "--- Deleting Credential ---" ' DeleteCredential CRED_TARGET End Sub
DPAPI保管庫の読み書き
Access Tokenのような長い文字列を格納するために、Windows2000時代からOS標準で装備されているDPAPIを使って暗号化したファイルに対して読み書きを行います。文字列長の制限はありません。色々なアプリでこの仕組みは利用されています。
PowerShellでこのファイルの読み書きをするには、明確な保管場所指定が必要ですが、AppData以下のフォルダに格納するのが安全であるため、冒頭のTOKEN_FILE_PATHに自身のユーザー名を書き足したフルパスを追記しましょう。longAccessToken変数に格納されたAccess Token文字列を暗号化してこのファイルに格納します。
旧版のPowerShell 5.x系で実行することが可能です。Add-Type -AssemblyName System.Securityがコマンドの中にありますが、これを入れないと読み書きでエラーとなるので注意。
Option Explicit '保存するファイルパスを決定(AppDataフォルダが一般的で安全) Const TOKEN_FILE_PATH As String = "C:\Users\ここにユーザー名\AppData\Roaming\MyVbaApp\token.dat" 'PowerShellコマンドを実行し、結果(標準出力)を文字列として返す Private Function RunPowerShell(ByVal command As String) As String Dim wsh As Object Set wsh = CreateObject("WScript.Shell") Dim exec As Object '標準入力からコマンドを読み込ませる("-Command -") Set exec = wsh.exec("powershell.exe -NoProfile -Command -") '標準入力にコマンドを書き込み、ストリームを閉じる exec.StdIn.WriteLine command exec.StdIn.Close Dim startTime As Single startTime = Timer Do While exec.Status = 0 DoEvents '10秒でタイムアウト If Timer - startTime > 10 Then exec.Terminate Exit Do End If Loop If Not exec.StdOut.AtEndOfStream Then RunPowerShell = exec.StdOut.ReadAll() End If If Not exec.StdErr.AtEndOfStream Then Debug.Print "PowerShell Error: " & exec.StdErr.ReadAll() End If End Function 'アクセストークンを暗号化してファイルに保存する Public Sub SaveAccessToken(ByVal accessToken As String, ByVal filePath As String) 'PowerShellでDPAPIを使い文字列を暗号化し、ファイルに書き込むコマンド 'ToBase64String でバイナリデータをテキスト化してファイルに保存 Dim psCommand As String psCommand = "Add-Type -AssemblyName System.Security; " & _ "$bytes = [System.Text.Encoding]::UTF8.GetBytes('" & accessToken & "'); " & _ "$protectedBytes = [System.Security.Cryptography.ProtectedData]::Protect($bytes, $null, 'CurrentUser'); " & _ "$base64String = [System.Convert]::ToBase64String($protectedBytes); " & _ "[System.IO.File]::WriteAllText('" & filePath & "', $base64String, [System.Text.Encoding]::ASCII)" RunPowerShell psCommand Debug.Print "Access Token encrypted and saved to: " & filePath End Sub 'ファイルからアクセストークンを複合化して読み取る Public Function ReadAccessToken(ByVal filePath As String) As String 'ファイルが存在しない場合は空文字を返す If Dir(filePath) = "" Then ReadAccessToken = "" Exit Function End If 'PowerShellでファイルから暗号化データを読み取り、DPAPIで複合するコマンド Dim psCommand As String psCommand = "Add-Type -AssemblyName System.Security; " & _ "$base64String = [System.IO.File]::ReadAllText('" & filePath & "'); " & _ "$protectedBytes = [System.Convert]::FromBase64String($base64String); " & _ "$bytes = [System.Security.Cryptography.ProtectedData]::Unprotect($protectedBytes, $null, 'CurrentUser'); " & _ "[System.Text.Encoding]::UTF8.GetString($bytes)" Dim result As String result = RunPowerShell(psCommand) ' PowerShellが出力する末尾の改行を削除 ReadAccessToken = Replace(Replace(result, vbCr, ""), vbLf, "") End Function ' ====================================================================== ' === 実行サンプル === ' ====================================================================== Public Sub AccessToken_DPAPI_Test() ' テスト用の長いアクセストークン (例: JWT) Dim longAccessToken As String longAccessToken = "ここにAccess Token文字列を入れる" '長いアクセストークンを暗号化してファイルに保存 SaveAccessToken longAccessToken, TOKEN_FILE_PATH ' 2. ファイルからアクセストークンを複合化して読み取り Dim retrievedToken As String retrievedToken = ReadAccessToken(TOKEN_FILE_PATH) If retrievedToken <> "" Then ' 取得したトークンが元のトークンと一致するか確認 If retrievedToken = longAccessToken Then Debug.Print "成功: 取得したトークンは元のトークンと一致します。 " Else Debug.Print "エラー: トークンが不一致です" End If Else Debug.Print "ファイルからアクセストークンを読み取れませんでした " End If End Sub
新版PowerShell用
新版のPowerShell 7.x系では資格情報マネージャやDPAPIは使わず、暗号化コンテナを利用します。このコンテナはそれそのものも安全な仕組みですが、2つ目のパスワード設定を追加することも出来ます。その場合、実行する度にユーザーが復号化の為のパスワード入力が必要になるので今回は入れていませんが、その結果安全じゃないということではありません。
文字列長の制限がないので、Client IDやSecret、Access Tokenといったもの全てをここ1つで管理することが可能です。但し、powershell.exeからpwsh.exeに実行ファイル名が変わってるので注意が必要。以下のコードでは「MyVbaApp/ApiKey」という場所に「ps7-final-test-key-」という文字列を格納しています。
Option Explicit ' PowerShell 7 (`pwsh.exe`) のコマンドを実行する関数 Private Function RunPowerShell_PS7(ByVal command As String) As String Dim wsh As Object Set wsh = CreateObject("WScript.Shell") Dim exec As Object '引数を使わず、標準入力からコマンドを読み込ませる Set exec = wsh.exec("pwsh.exe -NoProfile -Command -") '変更点: 標準入力にコマンドを書き込む exec.StdIn.WriteLine command exec.StdIn.Close ' 入力の終了を通知 ' 実行完了まで待機 (タイムアウト付き) Dim startTime As Single startTime = Timer Do While exec.Status = 0 DoEvents '少し長めに15秒 If Timer - startTime > 15 Then exec.Terminate Exit Do End If Loop ' 標準出力を読み取る If Not exec.StdOut.AtEndOfStream Then RunPowerShell_PS7 = exec.StdOut.ReadAll() End If ' デバッグ用にエラー出力を表示 If Not exec.StdErr.AtEndOfStream Then Debug.Print "PS7 Error: " & exec.StdErr.ReadAll() End If End Function ' SecretStore保管庫に秘密情報を保存する Public Sub SaveSecret_PS7(ByVal secretName As String, ByVal secretValue As String) ' PowerShellではシングルクォートが特別な意味を持つため、エスケープする Dim escapedSecret As String escapedSecret = Replace(secretValue, "'", "''") Dim psCommand As String 'PowerShellコマンドを組み立てる psCommand = "& { " & _ "$sec = ('" & escapedSecret & "' | ConvertTo-SecureString -AsPlainText -Force); " & _ "Set-Secret -Name '" & secretName & "' -SecureStringSecret $sec" & _ " }" RunPowerShell_PS7 psCommand Debug.Print "PS7: 秘密情報 '" & secretName & "' の保存を試みました。" End Sub ' SecretStore保管庫から秘密情報を読み取る Public Function ReadSecret_PS7(ByVal secretName As String) As String 'PowerShellコマンドを組み立てる Dim psCommand As String psCommand = "& { Get-Secret -Name '" & secretName & "' -AsPlainText }" 'PowerShellコマンドを実行 Dim result As String result = RunPowerShell_PS7(psCommand) ' PowerShellの出力に含まれる末尾の改行を削除 ReadSecret_PS7 = Replace(Replace(result, vbCr, ""), vbLf, "") End Function ' SecretStore保管庫から秘密情報を削除する Public Sub RemoveSecret_PS7(ByVal secretName As String) 'PowerShellコマンドを組み立てる Dim psCommand As String psCommand = "& { Remove-Secret -Name '" & secretName & "' }" 'PowerShellコマンドを実行 RunPowerShell_PS7 psCommand Debug.Print "PS7: 秘密情報 '" & secretName & "' の削除を試みました。" End Sub ' ================================================= ' === 実行サンプル=== ' ================================================= Public Sub SecretManagement_PS7_Test() ' 秘密情報に付ける一意の名前 Const SECRET_NAME As String = "MyVbaApp/ApiKey" Dim myApiSecret As String 'テスト用にランダムな秘密情報を生成 myApiSecret = "ps7-final-test-key-" & Rnd() '新しいPS7用の関数を使って秘密情報を保存 SaveSecret_PS7 SECRET_NAME, myApiSecret '秘密情報を読み戻す Dim retrievedSecret As String retrievedSecret = ReadSecret_PS7(SECRET_NAME) If retrievedSecret <> "" Then Debug.Print "成功! 保管庫から秘密情報を読み取りました: " & retrievedSecret ' 元の情報と一致するか検証 If retrievedSecret = myApiSecret Then Debug.Print "検証成功。" Else Debug.Print "エラー: 秘密情報が一致しません!" End If Else Debug.Print "秘密情報 '" & SECRET_NAME & "' の読み取りに失敗しました。" End If End Sub
なお、手動で所定の場所にPowerShellから暗号化コンテナを作成できるかどうかのテスト用コマンドは以下の通りです。PowerShell上にコピペ強制で実行しエラーがでなければ、問題なく作成できています。
#書き込み用コマンド $token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE3NTAwMDAwMDB9.very-long-signature-string-that-makes-the-token-exceed-the-limit-of-credential-manager" $filePath = "$env:APPDATA\MyVbaApp\token.dat" # フォルダがなければ作成 $dirPath = Split-Path -Path $filePath if (-not (Test-Path -Path $dirPath)) { New-Item -ItemType Directory -Path $dirPath } # 暗号化して保存 $bytes = [System.Text.Encoding]::UTF8.GetBytes($token) $protectedBytes = [System.Security.Cryptography.ProtectedData]::Protect($bytes, $null, 'CurrentUser') $base64String = [System.Convert]::ToBase64String($protectedBytes) [System.IO.File]::WriteAllText($filePath, $base64String, [System.Text.Encoding]::ASCII) Write-Host "保存処理が完了しました。" #読み取り用コマンド <meta charset='utf-8'>$filePath = "$env:APPDATA\MyVbaApp\token.dat" # 読み込んで複合 $base64String = [System.IO.File]::ReadAllText($filePath) $protectedBytes = [System.Convert]::FromBase64String($base64String) $bytes = [System.Security.Cryptography.ProtectedData]::Unprotect($protectedBytes, $null, 'CurrentUser') $token = [System.Text.Encoding]::UTF8.GetString($bytes) # 結果を出力 Write-Host "読み取ったトークン:" Write-Output $token
関連リンク
- 新たなコードインジェクション手法が発見される--Windowsの全バージョンに影響
- 秘密鍵をローカルに暗号化して保存する方法[C# Windows]
- PowerShell で Windows の 資格情報マネージャー を利用する (Jenkins などでの Git Credentialなど)
- PowerShell 資格情報の作成/表示/削除
- 【PowerShell】平文を暗号化・暗号文を復号する
- 🔰Windows PowerShellで文字列の暗号化と復号化
- PowerShell5.1と7.xって何が違うの?
- 「Windows PowerShell 2.0」は非推奨に ~「Windows 10 Fall Creators Update」から
- PowerShellでHello World
- Windows 版 Chrome で Cookie のセキュリティを向上させる
- Chromeに保存されているパスワードを解読してみる