【実装】LockerPoCApp
概要
LockerPoCApp は、Windows デスクトップアプリで API キーをどう保持するかを確認するために作った Credential Locker 版 PoC です。DPAPI 版の検証を踏まえて、「保存先を OS の資格情報保管庫へ寄せたら実装と UX がどう変わるか」を切り出して確認しました。12
記事の要点だけ先に書くと、PasswordVault で保存、読込、削除は問題なく実装でき、WinForms 側の操作感も大きく崩さずに維持できました。一方で、DPAPI 版よりも WinRT 前提、識別子設計、実行要件整理が増えます。

この記事で確認したこと
- API キーを
Windows.Security.Credentials.PasswordVaultへ保存する - 未登録時のみ入力を求め、通常時は API キー自体を再表示しない
- 認証エラー時のみ再入力へ誘導し、通信エラーでは再入力を求めない
win-x64の self-contained single-file publish を前提に整理する資格情報マネージャー > Web 資格情報でLockerPocApp.ApiKeyを確認する
構成
今回の構成は次の通りです。
- .NET 8
- WinForms
- Target Framework:
net8.0-windows10.0.17763.0 - Credential Locker API:
Windows.Security.Credentials.PasswordVault - publish 方式:
win-x64/self-contained/single-file
責務は次の 3 層に分けました。
| 役割 | 責務 |
|---|---|
PasswordVaultService | PasswordVault を直接呼び、保存、読込、削除を担当する |
ApiKeyStoreService | UI 向けに Success / NotFound / Unavailable へ正規化する |
Form1 | 起動時チェック、登録導線、読込確認、疑似 API 実行を担当する |
DPAPI 版では「ファイルへ暗号化済みデータを保存する」という整理でしたが、Credential Locker 版では「保存先を OS に寄せる」ため、UI に出す情報も保存パスではなく保存状態と識別子に寄ります。
プロジェクト設定
WinRT API を使う都合上、Target Framework は Windows バージョン付きにしています。あわせて、配布を exe 1 本に寄せるため PublishSingleFile と IncludeNativeLibrariesForSelfExtract を有効化しました。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows10.0.17763.0</TargetFramework>
<AssemblyName>LockerPocApp</AssemblyName>
<RootNamespace>LockerPocApp</RootNamespace>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
</PropertyGroup>
</Project>
ここで効いているポイントは 2 つです。
net8.0-windows10.0.17763.0にして、WinForms と WinRT API の前提を明示するPublishSingleFile=trueにして、publish 成果物を単体実行向けにまとめる
Credential Locker の実装
Credential Locker 側では、resource と userName を固定して 1 件だけを扱うようにしました。
resource:LockerPocApp.ApiKeyuserName:default
こうしておくと、保存、読込、削除のどの操作でも対象がぶれません。複数 credential を許すと、再読込と削除の分岐が一気に重くなるため、PoC では単一キー運用に寄せています。
using System;
using System.Collections.Generic;
using Windows.Security.Credentials;
namespace LockerPocApp.Services
{
public static class PasswordVaultService
{
private const string ResourceName = "LockerPocApp.ApiKey";
private const string UserName = "default";
private const int ErrorNotFound = unchecked((int)0x80070490);
public static string GetResourceName()
{
return ResourceName;
}
public static void Save(string password)
{
try
{
Delete();
PasswordVault vault = new PasswordVault();
vault.Add(new PasswordCredential(ResourceName, UserName, password));
}
catch (InvalidOperationException)
{
throw;
}
catch (Exception ex)
{
throw new InvalidOperationException(
"Credential Locker への保存に失敗しました。",
ex
);
}
}
public static bool TryRead(out string? password)
{
try
{
PasswordVault vault = new PasswordVault();
PasswordCredential credential = vault.Retrieve(ResourceName, UserName);
credential.RetrievePassword();
password = credential.Password;
return true;
}
catch (Exception ex) when (IsCredentialNotFound(ex))
{
password = null;
return false;
}
catch (Exception ex)
{
throw new InvalidOperationException(
"Credential Locker からの読込に失敗しました。",
ex
);
}
}
public static void Delete()
{
try
{
PasswordVault vault = new PasswordVault();
IReadOnlyList<PasswordCredential> credentials = FindByResource(vault);
foreach (PasswordCredential credential in credentials)
{
if (!string.Equals(credential.UserName, UserName, StringComparison.Ordinal))
{
continue;
}
vault.Remove(credential);
}
}
catch (InvalidOperationException)
{
throw;
}
catch (Exception ex)
{
throw new InvalidOperationException(
"Credential Locker からの削除に失敗しました。",
ex
);
}
}
private static IReadOnlyList<PasswordCredential> FindByResource(PasswordVault vault)
{
try
{
return vault.FindAllByResource(ResourceName);
}
catch (Exception ex) when (IsCredentialNotFound(ex))
{
return Array.Empty<PasswordCredential>();
}
catch (Exception ex)
{
throw new InvalidOperationException(
"Credential Locker 上の登録検索に失敗しました。",
ex
);
}
}
private static bool IsCredentialNotFound(Exception ex)
{
return ex.HResult == ErrorNotFound;
}
}
}
実装上の要点は次の通りです。
- 保存時は
Delete()を先に呼び、同じ識別子の古い登録を掃除する - 読込時は
Retrieve()の後にRetrievePassword()を呼んで本文を展開する - 未登録だけを
falseに落とし、それ以外の異常はInvalidOperationExceptionに寄せる
状態モデル
Credential Locker 版では、DPAPI 版の Corrupted という表現があまりしっくり来ません。そこで、UI に返す状態は Success / NotFound / Unavailable に整理しました。
using System;
namespace LockerPocApp.Services
{
public enum StoredApiKeyStatus
{
Success,
NotFound,
Unavailable
}
public sealed class StoredApiKeyResult
{
private StoredApiKeyResult(StoredApiKeyStatus status, string? apiKey)
{
Status = status;
ApiKey = apiKey;
}
public StoredApiKeyStatus Status { get; }
public string? ApiKey { get; }
public static StoredApiKeyResult Success(string apiKey)
{
return new StoredApiKeyResult(StoredApiKeyStatus.Success, apiKey);
}
public static StoredApiKeyResult NotFound()
{
return new StoredApiKeyResult(StoredApiKeyStatus.NotFound, null);
}
public static StoredApiKeyResult Unavailable()
{
return new StoredApiKeyResult(StoredApiKeyStatus.Unavailable, null);
}
}
public static class ApiKeyStoreService
{
public static StoredApiKeyResult TryLoad()
{
try
{
if (!PasswordVaultService.TryRead(out string? apiKey))
{
return StoredApiKeyResult.NotFound();
}
if (string.IsNullOrWhiteSpace(apiKey))
{
return StoredApiKeyResult.Unavailable();
}
return StoredApiKeyResult.Success(apiKey.Trim());
}
catch (InvalidOperationException)
{
return StoredApiKeyResult.Unavailable();
}
}
public static void Save(string apiKey)
{
string normalizedApiKey = apiKey.Trim();
if (string.IsNullOrWhiteSpace(normalizedApiKey))
{
throw new ArgumentException("API key is required.", nameof(apiKey));
}
PasswordVaultService.Save(normalizedApiKey);
}
}
}
この整理で効くのは、例外と UI 文言を切り離せることです。
- 未登録なら登録ダイアログへ進める
- 利用不能なら一度削除して再登録導線へ戻せる
- 画面側は WinRT 例外の詳細を知らなくてよい
UI フロー
画面の流れは DPAPI 版をほぼ維持しています。
- 起動時に保存済み API キーを確認する
- 未登録なら入力ダイアログを出す
- 読込確認では API キー自体は表示せず、読めることだけを確認する
- 疑似 API 実行では認証エラー時だけ再入力へ進める
疑似 API の判定ルールは次の通りです。
sk-で始まり 10 文字以上なら成功- それ以外は認証エラー
- チェックボックス ON なら通信エラー
再入力制御の本体は次のような形です。コードは長いのでスクロール枠に入れています。
private async Task ExecuteMockApiWithRetryAsync(string apiKey, bool allowRetryAfterAuthError)
{
MockApiCallResult apiResult =
await mockApiService.ExecuteAsync(apiKey, chkSimulateNetworkError.Checked);
if (apiResult.Status == MockApiCallStatus.Success)
{
RefreshStatus("疑似API呼出に成功しました。");
MessageBox.Show(
this,
"疑似API呼出に成功しました。",
"成功",
MessageBoxButtons.OK,
MessageBoxIcon.Information
);
return;
}
if (apiResult.Status == MockApiCallStatus.NetworkError)
{
RefreshStatus("通信エラーが発生しました。APIキー再入力は不要です。");
MessageBox.Show(
this,
"通信エラーが発生しました。時間を置いて再試行してください。",
"通信エラー",
MessageBoxButtons.OK,
MessageBoxIcon.Warning
);
return;
}
if (!allowRetryAfterAuthError)
{
RefreshStatus("再入力後も認証エラーが継続しました。");
MessageBox.Show(
this,
"再入力後も認証エラーです。入力値を確認してください。",
"認証エラー",
MessageBoxButtons.OK,
MessageBoxIcon.Warning
);
return;
}
RefreshStatus("認証エラーを検出しました。APIキーの再入力が必要です。");
MessageBox.Show(
this,
"認証エラーが発生しました。APIキーを再入力してください。",
"認証エラー",
MessageBoxButtons.OK,
MessageBoxIcon.Warning
);
bool saved = PromptForApiKey("APIキー再入力", "認証エラーのため、APIキーを再入力してください。", "再登録");
if (!saved)
{
RefreshStatus("認証エラー後の再入力をキャンセルしました。");
return;
}
StoredApiKeyResult retryLoadResult = ApiKeyStoreService.TryLoad();
if (retryLoadResult.Status != StoredApiKeyStatus.Success || retryLoadResult.ApiKey is null)
{
RefreshStatus("再入力後のAPIキー読込に失敗しました。");
MessageBox.Show(
this,
"再入力後のAPIキー読込に失敗しました。再度登録してください。",
"読込エラー",
MessageBoxButtons.OK,
MessageBoxIcon.Warning
);
return;
}
await ExecuteMockApiWithRetryAsync(
retryLoadResult.ApiKey,
allowRetryAfterAuthError: false
);
}
ここで重要なのは、認証エラーと通信エラーを UI 上で分けている点です。キーが間違っているのか、ネットワークが落ちているのかを分けるだけで、再入力を要求する回数をかなり減らせます。
確認できたこと
アプリ側の 読込確認 と再起動確認だけでも成立しますが、今回は Windows 側でも確認しました。
資格情報マネージャー > Web 資格情報にLockerPocApp.ApiKeyが表示された- 登録した値と保存内容が一致した
- アプリ上でも保存、読込、削除の流れが通った
この確認が取れたので、「見かけ上動いている」だけでなく、Credential Locker に実際に登録できているところまでは確認済みです。
DPAPI 版との違い
差分を整理すると次の通りです。
| 観点 | DPAPI | Credential Locker |
|---|---|---|
| API | ProtectedData | PasswordVault |
| 依存 | .NET 中心 | WinRT 前提 |
| 保存場所 | アプリが管理するファイル | OS の資格情報保管庫 |
| UI 表示 | 保存ファイルパスを出せる | 保存先は Locker 表示になる |
| 状態モデル | Corrupted が自然 | Unavailable が自然 |
| 実装責務 | 暗号化とファイル IO | 資格情報識別子管理と WinRT 呼出 |
単純さでは DPAPI の方が上です。ただし、Credential Locker は保存先を Windows の資格情報管理へ寄せられるので、「ファイルをどこへ置くか」をアプリ側で抱えたくないケースでは意味があります。
配布要件
今回の PoC は win-x64 の self-contained single-file publish を前提にしています。整理すると、実行要件は次の通りです。
- Windows 10 1809 以上
- 64bit Windows
- GUI が使える通常のデスクトップ環境
- 同一 Windows ユーザーでの継続利用
- Credential Locker が通常利用可能
- 一時フォルダへの書き込みが可能
逆に、今回の publish 形態では次は不要です。
- .NET ランタイムの事前導入
- 管理者権限
- Visual Studio
- MSIX パッケージ化
IncludeNativeLibrariesForSelfExtract=true を使っているため、見た目は exe 1 本でも実行時には一時領域の利用が前提になります。完全無依存というより、「配布物を 1 本に寄せた single-file 実行」と理解した方が正確です。
ここで 1 点だけ切り分けが必要です。net8.0-windows10.0.17763.0 は、.NET 6 以降で WinRT API を使うための TFM 設定であり、ビルド時の API 面を固定するためのものです。つまり、これは「技術的にどの Windows API を参照できるか」の話であって、「2026年3月28日時点でどの OS を運用対象にすべきか」とは別です。3
実用化する場合の考慮事項
PoC の API 選定自体は妥当です。PasswordVault と PasswordCredential は Microsoft Learn 上で現在も通常の Windows API として公開されており、PasswordVault のクラス説明でも、AppContainer 外の通常のデスクトップアプリは current user の複数 locker を見える前提とされています。45
実用時に見るべき論点を圧縮すると次の通りです。
| 要素 | 判断 | 実務で見る点 |
|---|---|---|
PasswordVault / PasswordCredential | 採用は妥当 | API 自体よりも、desktop app が見える credential 範囲を絞る設計が重要 |
Add / Retrieve / FindAllByResource / Remove | 採用は妥当 | RetrieveAll() より狭い取得を優先する。Add の 20 件制限は UWP または AppContainer desktop app 側の主な注意点で、今回の unpackaged WinForms の主要論点ではない6 |
| WinForms + .NET 8 | 2026年3月28日時点では妥当 | ただし .NET 8 は LTS でも 2026年11月10日までで、運用は最新 servicing update 追従が前提7 |
net8.0-windows10.0.17763.0 | PoC としては妥当 | API 利用条件としては十分だが、運用上の推奨 OS ベースラインは別に定義すべき |
実装観点で特に重要なのは、今回の設計判断が PasswordVault の性質と合っていることです。
resourceを固定しているuserNameを固定しているRetrieveAll()を使っていない- API キー自体を画面へ再表示していない
Unavailableを UI 向け状態として分離している
この 5 点は、PoC 用の簡略化ではなく、実用側へ寄せてもそのまま残してよい判断です。
2026年3月28日時点の運用前提
技術的な最低条件と、実際の運用前提は分けて考えた方がよいです。
- 技術的には
net8.0-windows10.0.17763.0により Windows 10 1809 以降を前提にビルドできる3 - ただし運用上は、Windows 10 Home / Pro は 2025年10月14日にサポート終了済み8
- Windows 10 Enterprise / Education も、通常の 22H2 系は 2025年10月14日でサポート終了済み9
- 社内 PC が Windows 10 のままなら、少なくとも Windows 10 Enterprise LTSC 2021 のように 2027年1月12日までサポートが残る系統か、Windows 10 22H2 + ESU を明示的に採る必要があります1011
なので、2026年3月28日時点の実運用前提としては、次の整理が現実的です。
- 新規導入なら Windows 11 を基準にする
- 既存社内端末で Windows 10 を継続するなら、LTSC か ESU のどちらで延命しているのかを明文化する
- 単に「PoC が Windows 10 1809 で動く」ことを、そのまま運用許容の根拠にしない
内部ネットのローカルアプリとして見たときの優先順位
ここは公式ドキュメントの直接記載というより、上の API 特性とサポート条件を踏まえた実装判断です。
内部ネット向けのローカルアプリだと、「そこまで重く考えなくてよい点」と「閉域でも外せない点」が分かれます。
閉域前提でも外せない点
- サポート中の Windows と .NET を使うこと
RetrievePassword()後に secret がアプリのメモリへ載る前提で、UI 表示、ログ出力、長時間保持を避けること5- desktop app が広い locker 範囲を見える前提で、
resource/userName/ 検索範囲を絞ること4 - 保存する API キー自体の権限を絞ること
- Credential Locker が読めないときの再登録導線と削除導線を持つこと
閉域だからといって、端末侵害やローカル管理者権限、マルウェア、画面・ログ露出の問題が消えるわけではありません。むしろこの方式では、ネットワーク越しの盗聴より「端末上で secret をどう露出させないか」の方が本質です。
閉域前提なら優先度を下げやすい点
- Microsoft アカウント経由の credential roaming 活用
- 複数アカウント管理 UI
- MSIX 化や package identity 前提の設計
- AppContainer 系の 20 credential 制限対策
Credential Locker の説明では roaming が利点として挙がりますが、domain account では新規 credential は roam しない整理になっています。社内ローカルアプリでは、この roaming を主目的にしなくても困らないことが多いです。12
この前提でも見直した方がよいケース
これは設計判断ですが、保存するキーが「社内 API への限定権限キー」で、利用端末も管理下にあり、ユーザー数も限定されるなら、Credential Locker 保管はかなり現実的です。
逆に、1 本の長期固定 API キーで広い backend 権限を持たせるなら、内部ネットでも見直した方がよいです。配布先端末の数だけ強い secret をばらまく構図になるため、その場合はユーザー認証 + 短命トークンや、端末ごとの証明書、サーバ側の代理実行のような構成の方が筋がよくなります。
向くケース
Credential Locker 版が向くのは次のようなケースです。
- Windows 専用のデスクトップアプリ
- 資格情報の保存を OS の保管庫へ寄せたい
- 保存ファイルを自前管理したくない
- 配布対象が Windows に閉じている
逆に、CLI 中心で軽く作りたい、保存場所も含めて完全に自前制御したい、というなら DPAPI の方が実装は楽です。
まとめ
LockerPoCApp では、API キーを Credential Locker に保存する PoC を WinForms + .NET 8 で組み、保存、読込、削除、再入力導線まで一通り確認できました。WinForms 側の UX は DPAPI 版をかなり流用できた一方で、WinRT API、識別子設計、実行要件整理は確実に増えます。
2026年3月28日時点の実用観点で言えば、PasswordVault 採用自体は妥当です。ただし本当に気にすべきなのは、API の将来性そのものよりも、サポート中の Windows / .NET を前提にすること、desktop app の検索範囲を狭く保つこと、RetrievePassword() 後の secret 露出を抑えることです。
結論としては、「Windows の資格情報ストアへ保存を寄せたい」「内部ネットの管理端末で、限定権限のキーを扱う」という条件なら Credential Locker は十分成立します。一方で、配布先端末へ広い権限の長期固定キーを配るなら、内部ネットであっても別方式を検討した方がよいです。