メインコンテンツまでスキップ

LockerPoCApp

文書番号:2603.00005

概要​

LockerPoCApp は、Windows デスクトップアプリで API キーをどう保持するかを確認するために作った Credential Locker 版 PoC なのだ。DPAPI 版の検証を踏まえて、「保存先を OS の資格情報保管庫へ寄せたら実装と UX がどう変わるか」を切り出して確認した。1

記事の要点だけ先に書くと、PasswordVault で保存、読込、削除は問題なく実装でき、WinForms 側の操作感も大きく崩さずに維持できた。一方で、DPAPI 版よりも WinRT 前提、識別子設計、実行要件整理が増えるのだ。

LockerPoCApp の全体デモ

この記事で確認したこと​

  • 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 層に分けた。

役割責務
PasswordVaultServicePasswordVault を直接呼び、保存、読込、削除を担当する
ApiKeyStoreServiceUI 向けに Success / NotFound / Unavailable へ正規化する
Form1起動時チェック、登録導線、読込確認、疑似 API 実行を担当する

DPAPI 版では「ファイルへ暗号化済みデータを保存する」という整理だったが、Credential Locker 版では「保存先を OS に寄せる」ため、UI に出す情報も保存パスではなく保存状態と識別子に寄るのだ。

プロジェクト設定​

WinRT API を使う都合上、Target Framework は Windows バージョン付きにしている。あわせて、配布を exe 1 本に寄せるため PublishSingleFile と IncludeNativeLibrariesForSelfExtract を有効化したのだ。

LockerPocApp.csproj
<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.ApiKey
  • userName: default

こうしておくと、保存、読込、削除のどの操作でも対象がぶれない。複数 credential を許すと、再読込と削除の分岐が一気に重くなるため、PoC では単一キー運用に寄せているのだ。

PasswordVaultService.cs
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 に整理したのだ。

ApiKeyStoreService.cs
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 版をほぼ維持しているのだ。

  1. 起動時に保存済み API キーを確認する
  2. 未登録なら入力ダイアログを出す
  3. 読込確認では API キー自体は表示せず、読めることだけを確認する
  4. 疑似 API 実行では認証エラー時だけ再入力へ進める

疑似 API の判定ルールは次の通りなのだ。

  • sk- で始まり 10 文字以上なら成功
  • それ以外は認証エラー
  • チェックボックス ON なら通信エラー

再入力制御の本体は次のような形である。コードは長いのでスクロール枠に入れているのだ。

Form1.cs / ExecuteMockApiWithRetryAsync
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 版との違い​

差分を整理すると次の通りなのだ。

観点DPAPICredential Locker
APIProtectedDataPasswordVault
依存.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 を運用対象にすべきか」とは別である。2

実用化する場合の考慮事項​

PoC の API 選定自体は妥当なのだ。PasswordVault と PasswordCredential は Microsoft Learn 上で現在も通常の Windows API として公開されており、PasswordVault のクラス説明でも、AppContainer 外の通常のデスクトップアプリは current user の複数 locker を見える前提とされている。34

実用時に見るべき論点を圧縮すると次の通りである。

要素判断実務で見る点
PasswordVault / PasswordCredential採用は妥当API 自体よりも、desktop app が見える credential 範囲を絞る設計が重要
Add / Retrieve / FindAllByResource / Remove採用は妥当RetrieveAll() より狭い取得を優先する。Add の 20 件制限は UWP または AppContainer desktop app 側の主な注意点で、今回の unpackaged WinForms の主要論点ではない5
WinForms + .NET 82026年3月28日時点では妥当ただし .NET 8 は LTS でも 2026年11月10日までで、運用は最新 servicing update 追従が前提6
net8.0-windows10.0.17763.0PoC としては妥当API 利用条件としては十分だが、運用上の推奨 OS ベースラインは別に定義すべき

実装観点で特に重要なのは、今回の設計判断が PasswordVault の性質と合っていることなのだ。

  • resource を固定している
  • userName を固定している
  • RetrieveAll() を使っていない
  • API キー自体を画面へ再表示していない
  • Unavailable を UI 向け状態として分離している

この 5 点は、PoC 用の簡略化ではなく、実用側へ寄せてもそのまま残してよい判断なのだ。

2026年3月28日時点の運用前提​

技術的な最低条件と、実際の運用前提は分けて考えた方がよいのだ。

  • 技術的には net8.0-windows10.0.17763.0 により Windows 10 1809 以降を前提にビルドできる2
  • ただし運用上は、Windows 10 Home / Pro は 2025年10月14日にサポート終了済み7
  • Windows 10 Enterprise / Education も、通常の 22H2 系は 2025年10月14日でサポート終了済み8
  • 社内 PC が Windows 10 のままなら、少なくとも Windows 10 Enterprise LTSC 2021 のように 2027年1月12日までサポートが残る系統か、Windows 10 22H2 + ESU を明示的に採る必要がある910

なので、2026年3月28日時点の実運用前提としては、次の整理が現実的なのだ。

  • 新規導入なら Windows 11 を基準にする
  • 既存社内端末で Windows 10 を継続するなら、LTSC か ESU のどちらで延命しているのかを明文化する
  • 単に「PoC が Windows 10 1809 で動く」ことを、そのまま運用許容の根拠にしない

内部ネットのローカルアプリとして見たときの優先順位​

ここは公式ドキュメントの直接記載というより、上の API 特性とサポート条件を踏まえた実装判断なのだ。

内部ネット向けのローカルアプリだと、「そこまで重く考えなくてよい点」と「閉域でも外せない点」が分かれる。

閉域前提でも外せない点​

  • サポート中の Windows と .NET を使うこと
  • RetrievePassword() 後に secret がアプリのメモリへ載る前提で、UI 表示、ログ出力、長時間保持を避けること4
  • desktop app が広い locker 範囲を見える前提で、resource / userName / 検索範囲を絞ること3
  • 保存する API キー自体の権限を絞ること
  • Credential Locker が読めないときの再登録導線と削除導線を持つこと

閉域だからといって、端末侵害やローカル管理者権限、マルウェア、画面・ログ露出の問題が消えるわけではない。むしろこの方式では、ネットワーク越しの盗聴より「端末上で secret をどう露出させないか」の方が本質なのだ。

閉域前提なら優先度を下げやすい点​

  • Microsoft アカウント経由の credential roaming 活用
  • 複数アカウント管理 UI
  • MSIX 化や package identity 前提の設計
  • AppContainer 系の 20 credential 制限対策

Credential Locker の説明では roaming が利点として挙がるが、domain account では新規 credential は roam しない整理になっている。社内ローカルアプリでは、この roaming を主目的にしなくても困らないことが多いのだ。11

この前提でも見直した方がよいケース​

これは設計判断だが、保存するキーが「社内 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 は十分成立する。一方で、配布先端末へ広い権限の長期固定キーを配るなら、内部ネットであっても別方式を検討した方がよいのだ。

参考資料(出典)​

Footnotes​

  1. area11.org, Windows DPAPI PoC。先行して作成した DPAPI 版 WinForms アプリの実装記録。./windows-dpapi-winforms-dotnet8.mdx ↩

  2. Microsoft Learn, Call Windows Runtime APIs in desktop apps。.NET 6 以降では Windows バージョン付き TFM で WinRT API を参照でき、net8.0-windows10.0.17763.0 は Windows 10 1809 向けの例として示されている。https://learn.microsoft.com/en-us/windows/apps/desktop/modernize/desktop-to-uwp-enhance ↩ ↩2

  3. Microsoft Learn, PasswordVault Class。PasswordVault の定義、Windows 10 での導入、desktop app と AppContainer app の locker 可視範囲の違いを確認できる。https://learn.microsoft.com/en-us/uwp/api/windows.security.credentials.passwordvault?view=winrt-26100 ↩ ↩2

  4. Microsoft Learn, PasswordCredential Class。Password の利用前に RetrievePassword() が必要であることを確認できる。https://learn.microsoft.com/en-us/uwp/api/windows.security.credentials.passwordcredential?view=winrt-26100 ↩ ↩2

  5. Microsoft Learn, PasswordVault.Add(PasswordCredential) Method。Credential Locker への追加 API。20 credential 制限が UWP または AppContainer desktop app 側の条件である点もここに書かれている。https://learn.microsoft.com/en-us/uwp/api/windows.security.credentials.passwordvault.add?view=winrt-26100 ↩

  6. Microsoft Learn, .NET releases, patches, and support。.NET 8 は LTS で、2026年11月10日までサポートされる。servicing update は月次で提供され、最新 servicing update への追従が前提になる。https://learn.microsoft.com/en-us/dotnet/core/releases-and-support ↩

  7. Microsoft Learn, Windows 10 Home and Pro lifecycle。Windows 10 Home / Pro は 2025年10月14日でサポート終了とされている。https://learn.microsoft.com/en-us/lifecycle/products/windows-10-home-and-pro ↩

  8. Microsoft Learn, Windows 10 Enterprise and Education lifecycle。Enterprise / Education も通常系の 22H2 は 2025年10月14日で終了し、既存 LTSC は個別ライフサイクルに従うとされている。https://learn.microsoft.com/en-us/lifecycle/products/windows-10-enterprise-and-education ↩

  9. Microsoft Learn, Windows 10 Enterprise LTSC 2021 lifecycle。Windows 10 Enterprise LTSC 2021 の Mainstream End Date は 2027年1月12日。https://learn.microsoft.com/en-us/lifecycle/products/windows-10-enterprise-ltsc-2021 ↩

  10. Microsoft Learn, Extended Security Updates (ESU) program for Windows 10。Windows 10 は 2025年10月14日のサポート終了後も、22H2 を対象に年単位の有償 ESU で最長 3 年のセキュリティ更新を受けられる。ただし一般サポートは含まれない。https://learn.microsoft.com/en-us/windows/whats-new/extended-security-updates ↩

  11. Microsoft Learn, Credential locker for Windows apps。Credential Locker の概要、roaming の考え方、domain account 時の挙動を整理している。https://learn.microsoft.com/en-us/windows/apps/develop/security/credential-locker ↩