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

【実装】LockerPoCApp

概要

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

記事の要点だけ先に書くと、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 本に寄せるため PublishSingleFileIncludeNativeLibrariesForSelfExtract を有効化しました。

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 側では、resourceuserName を固定して 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 を運用対象にすべきか」とは別です。3

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

PoC の API 選定自体は妥当です。PasswordVaultPasswordCredential は 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 82026年3月28日時点では妥当ただし .NET 8 は LTS でも 2026年11月10日までで、運用は最新 servicing update 追従が前提7
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 以降を前提にビルドできる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 は十分成立します。一方で、配布先端末へ広い権限の長期固定キーを配るなら、内部ネットであっても別方式を検討した方がよいです。

参考資料(出典)

Footnotes

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

  2. 内部メモ, LockerPoCApp 実装メモ。本記事の再構成元となった下書き、実装メモ、コード断片。

  3. 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

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

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

  6. 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

  7. 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

  8. 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

  9. 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

  10. 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

  11. 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

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