VBAでAIを使いたい人必見!APIキーを安全に保管する一番優れた手順

世は生成AI時代到来で、昨今のセキュリティ要求により制限が生まれつつあるVBAですが、そんなVBAでもGemini APIやChatGPT APIといった生成AIを使いたい。ローカル環境でExcel等と連携して動かせるというのは大きな魅力があります。

しかし、如何せん古い仕組みゆえ、APIキーやClient IDなどを安全に保管する手段を持っていません。そこで、この弱点部分を克服して、さらにVBAを一歩先に進めて、生成AIと連携する前準備を今回の取組みで実現してみたいと思います。

今回利用する素材

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化したもので資格情報マネージャ読み書きプログラムを以下のエントリーで作っています。

資格情報マネージャ読み書きプログラムをNode.jsで作成する

モジュールの追加

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

関連リンク

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)