Windows DPAPI PoC
この記事でやったこと
Windows の DPAPI を使って API キーを保護する WinForms アプリを .NET 8 で実装したのだ。
開発そのものは macOS 側で進めつつ、実際の dotnet build と dotnet publish は Windows 仮想環境の PowerShell から実行したのだ。
保存方式の比較や設計の話は Windows実行ファイルでAPIキーをどう保持するか に分けてあるのだ。
なのでこの記事では、実際に動くものを作って、配れる形まで持っていった実装面に絞って書いていくのだ。
今回扱うのは次の 4 点なのだ。
- PowerShell から .NET SDK を導入したところ
- 実際に実行した
build/publishコマンド - DPAPI を使った保存ロジックと WinForms の挙動
exe単体配布に寄せるために入れた設定
1. PowerShell から .NET SDK を入れる
Windows 側で dotnet が使えないと始まらないのだ。
最初に dotnet --info を叩いた時点では返ってこなかったので、今回は winget で .NET SDK を入れて対処したのだ。
winget install --id Microsoft.DotNet.SDK.8 --source winget
dotnet --info
dotnet --info が返る状態になれば、以降の作業は PowerShell 上でそのまま進められるのだ。
2. 実際に使った build / publish コマンド
今回実際に使ったコマンドは次の 2 つなのだ。
dotnet build
dotnet publish -c Release -r win-x64 --self-contained true
build の結果は通常の開発用ビルドで、出力先は bin\Debug\net8.0-windows\ 配下になるのだ。
一方で配布に使うのは publish の成果物で、こちらは bin\Release\net8.0-windows\win-x64\publish\ に出るのだ。
今回の環境では、実際に次のように成功したのだ。
dotnet build
復元対象のすべてのプロジェクトは最新です。
DpapiPocApp -> ...\bin\Debug\net8.0-windows\DpapiPocApp.dll
dotnet publish -c Release -r win-x64 --self-contained true
DpapiPocApp -> ...\bin\Release\net8.0-windows\win-x64\DpapiPocApp.dll
DpapiPocApp -> ...\bin\Release\net8.0-windows\win-x64\publish\
この時点で publish フォルダ配下から起動できることまでは確認できたのだ。

つまり持ち出すべきなのは publish 側に出た DpapiPocApp.exe であって、Debug 側の成果物ではないのだ。
3. 何を作ったのか
作ったのは、API キーを平文で保存しない WinForms アプリなのだ。
保存先は %LocalAppData%\DpapiPocApp\config.dat で、中に入るのは DPAPI で暗号化したバイト列だけなのだ。
アプリの基本フローは次の通りなのだ。
- 起動時に保存済み API キーを確認する
- 未登録または破損なら入力ダイアログを出す
- 入力値を DPAPI で暗号化して
LocalAppDataに保存する - 通常時は保存済みキーを復号して疑似 API を呼ぶ
- 認証エラーのときだけ再入力を求める
- 通信エラーのときは再入力させず再試行を案内する
UI と保存ロジックは分離していて、主な役割はこう分けているのだ。
Services/DpapiService.cs: DPAPI の暗号化・復号Services/ApiKeyStoreService.cs: 保存・読込・削除・破損判定Services/MockApiService.cs: 疑似 API の成功 / 認証エラー / 通信エラーForms/ApiKeyInputForm.cs: API キー入力専用ダイアログForm1.cs: メイン画面と全体フロー
4. 実際の画面とフロー
文章だけだと分かりにくいので、画面遷移も残しておくのだ。
初回起動時は API キー登録を強制する
初回起動時は保存済みキーが無いので、メイン画面の上に入力ダイアログを出して登録を求めるのだ。
未登録のまま通常操作へ進ませないのが今回の意図なのだ。

この画面で入力した値は、保存前に Trim() したうえで DPAPI に渡しているのだ。
登録後は通常画面に戻る
登録に成功すると保存状態が「登録済み」になり、通常の操作画面へ戻るのだ。

常時テキストボックスを表示するのではなく、必要な時だけダイアログを開く形にしたので、通常時の UI はかなりすっきりしたのだ。
正しいキーなら疑似 API は成功する
実 API の代わりに MockApiService を用意し、sk- で始まり 10 文字以上なら成功と判定するようにしたのだ。
これで、保存済みキーを復号して API 呼び出しに使う流れを、外部依存なしで確認できるのだ。

認証エラー時だけ再入力を求める
キーが不正な場合は認証エラーとして扱い、ここでだけ再入力を求めるのだ。
通信エラーと認証エラーを分けて扱うのが、今回の UI 上のポイントなのだ。

削除操作で再登録状態へ戻せる
保存済みキーを明示的に消す導線も入れているのだ。
検証時に状態を戻しやすいだけでなく、ユーザーが自分で資格情報を消せることも重要なのだ。

削除後は保存状態が未登録に戻り、次回の操作時には再度登録フローに入るのだ。
5. DPAPI 実装の中身
今回の肝はここなのだ。
Windows の DPAPI は、.NET では System.Security.Cryptography.ProtectedData から扱えるのだ。
暗号化はかなりシンプルで、文字列を UTF-8 の byte[] にしてから Protect しているのだ。
public static byte[] Protect(string plainText)
{
byte[] plainBytes = Encoding.UTF8.GetBytes(plainText);
return ProtectedData.Protect(
plainBytes,
optionalEntropy: null,
scope: DataProtectionScope.CurrentUser
);
}
ポイントは DataProtectionScope.CurrentUser なのだ。
これによって、同じ Windows ログオンユーザーなら復号できる挙動になるのだ。
逆に言えば、別ユーザーでは復号できないのだ。
復号側は対になる Unprotect を呼ぶだけなのだ。
public static string Unprotect(byte[] encryptedBytes)
{
byte[] plainBytes = ProtectedData.Unprotect(
encryptedBytes,
optionalEntropy: null,
scope: DataProtectionScope.CurrentUser
);
return Encoding.UTF8.GetString(plainBytes);
}
保存処理では、この暗号化済みデータだけをファイルへ書いているのだ。
public static void Save(string apiKey)
{
string normalizedApiKey = apiKey.Trim();
if (string.IsNullOrWhiteSpace(normalizedApiKey))
{
throw new ArgumentException("API key is required.", nameof(apiKey));
}
Directory.CreateDirectory(AppDirectory);
byte[] encrypted = DpapiService.Protect(normalizedApiKey);
File.WriteAllBytes(FilePath, encrypted);
}
ここでやっていることは 3 つだけなのだ。
- 入力値を
Trim()する - DPAPI で暗号化する
- 暗号化済みバイト列を
config.datに保存する
読込時は単純に File.Exists() だけではなく、内容が壊れていないかまで見ているのだ。
復号に失敗した場合は CryptographicException などを握りつぶさず、アプリ側では「破損」として扱うようにしたのだ。
6. self-contained と single-file は別物だった
最初に dotnet publish -c Release -r win-x64 --self-contained true を実行したところ、publish フォルダの中からは起動できたのだ。
ただし、exe ファイルだけを別フォルダへ持っていくと起動しなかったのだ。
ここで引っかかったのは、self-contained と single-file が別物だという点なのだ。
self-contained: .NET ランタイムを同梱するsingle-file: 配布物を 1 本の実行ファイルへまとめる
つまり self-contained だけでは、ランタイム不要にはなるけれど、exe 1 本だけ持ち出せるとは限らないのだ。
単体配布を考えるなら、必ず dotnet publish の出力を見る必要があるのだ。
7. exe 単体配布のために入れた設定
そこで、プロジェクトファイルに単一ファイル publish の設定を追加したのだ。
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
</PropertyGroup>
今回のポイントは次の 2 つなのだ。
PublishSingleFileIncludeNativeLibrariesForSelfExtract
PublishSingleFile は、そのまま単一ファイル publish を有効にする設定なのだ。
Windows デスクトップアプリではネイティブライブラリも絡むので、IncludeNativeLibrariesForSelfExtract も入れておくと、実行時に必要なネイティブ成分を自己展開して動かしやすいのだ。
この設定を入れたうえで、改めて publish するのだ。
dotnet publish -c Release -r win-x64 --self-contained true
最終的に持ち出すべきなのは次のファイルなのだ。
bin\Release\net8.0-windows\win-x64\publish\DpapiPocApp.exe
ここで見るべきなのは Debug 側ではなく、publish 側の exe なのだ。
8. 最終的な実行要件
今回の修正まで含めると、実行要件は次のように整理できるのだ。
- 対象 OS は Windows 10 以上
- 配布ターゲットは
win-x64 - 配布物は
publishフォルダから出たDpapiPocApp.exe - 単体実行のために
PublishSingleFileを有効化済み --self-contained trueで publish しているので、配布先に .NET ランタイムは不要- API キーは
%LocalAppData%\DpapiPocApp\config.datに保存される - 保存済みキーの復号は、保存したのと同じ Windows ユーザーでのみ可能
つまり最終状態としては、Windows 10 以上の x64 環境で、.NET 未導入でも、publish した exe を単体で実行できる構成にかなり近いところまで持っていけたのだ。
ただし、DPAPI の性質上、保存済みデータの復号はユーザー単位で結び付くのだ。この点は、配布互換性とセキュリティのトレードオフとして理解しておく必要があるのだ。
9. まとめ
今回やったことを一言でまとめると、.NET 8 + WinForms + DPAPI で平文保存を避けた API キー管理の最小構成を組み立てて、そのうえで publish 周りを調整して単体配布に寄せた、という話なのだ。
WinForms の画面を実際に触れる形まで持っていくと、DPAPI 自体の使い方だけでなく、いつ入力させるか、どの時だけ再入力させるか、配布物として何を持ち出すべきかまで一気に整理しやすかったのだ。
実装面では ProtectedData を使うだけなので、DPAPI 自体はそこまで難しくないのだ。
むしろ詰まりやすいのは配布形態で、build の exe と publish の exe の違い、self-contained と single-file の違いを理解していないと、今回のようにその場では動くのに持ち出すと動かない、にぶつかりやすいのだ。
今回の実装で、そのあたりまで含めて一通り整理できたのだ。