0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

C# で DB のパスワードを保存する方法。設定ファイルに平文で置かず、Windows の資格情報マネージャーに入れる (CredWrite・CredRead の P/Invoke)

0
Posted at

C# のアプリで DB のパスワードを保存する方法の 1 つです。
Windows の資格情報マネージャー (Credential Manager)に、文字列を保存・読み込み・削除する部品を使います。
この部品が動くことを確かめました。
使い方の例は、データベースのパスワードを app.config や appsettings.json に書かず、資格情報マネージャーに入れる形です。
Windows の API を P/Invoke で直接呼ぶので、NuGet のパッケージは要りません。

やること

  1. 下の CredentialStore.cs をプロジェクトの好きなフォルダに追加する。名前空間は付けていないので、そのまま CredentialStore で呼べます。
  2. 保存する: CredentialStore.Set("MyApp.Database", password);
  3. 読み込む: var password = CredentialStore.Get("MyApp.Database");
  4. 消す: CredentialStore.Remove("MyApp.Database");

知っておくこと

  • 対象は Windows 11 の .NET 8 以降です。動かして確かめたのは .NET 10 です。.NET Framework は試していません。
  • Get は保存されていないとき null を返します。それ以外の失敗では Win32Exception になります。
  • Remove は消したら true、もともと無ければ false を返します。
  • 値は UTF-16 の単位で 1280 個分までです。超えると Set が Win32Exception で失敗します。エラー番号は 1783 です。
  • 保存する名前は決めたら変えないでください。変えると保存済みの値を読めなくなります。
  • MIT ライセンスで自由に使えます。
  • ファイルの直接のリンク: https://github.com/Mimumumimu/CodeSamples/blob/main/CredentialManagerSample/Secrets/CredentialStore.cs

先頭に必要な using と #nullable enable を書いてあるので、プロジェクトの設定に関わらずそのままビルドできます。

Secrets/CredentialStore.cs
#nullable enable
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;

/// <summary>
/// 文字列を、Windows の資格情報マネージャー (汎用資格情報)に保存する。
/// 名前 (ターゲット名)ごとに、文字列を 1 件ずつ持つ。
/// 保存先は、今の Windows ユーザーの、この PC の中だけ。ほかの PC には、同期しない。
/// </summary>
/// <remarks>
/// 1 件の大きさには、OS の上限がある。値は UTF-16 で保存するので、上限は 1280 文字で、バイトでは 2560 になる。
/// 超えると、保存できずに <see cref="Win32Exception"/> になる。
/// </remarks>
public static class CredentialStore
{
    private const uint TypeGeneric = 1;          // CRED_TYPE_GENERIC
    private const uint PersistLocalMachine = 2;  // CRED_PERSIST_LOCAL_MACHINE
    private const int ErrorNotFound = 1168;      // ERROR_NOT_FOUND

    /// <summary>名前の値を読む。保存されていなければ null を返す</summary>
    /// <exception cref="Win32Exception">保存されていない以外の理由で、読めなかった</exception>
    public static string? Get(string name)
    {
        if (!CredRead(name, TypeGeneric, 0, out var pointer))
        {
            var error = Marshal.GetLastWin32Error();
            return error == ErrorNotFound ? null : throw new Win32Exception(error);
        }

        try
        {
            var credential = Marshal.PtrToStructure<Credential>(pointer);
            return Marshal.PtrToStringUni(credential.CredentialBlob, (int)credential.CredentialBlobSize / 2);
        }
        finally
        {
            CredFree(pointer);
        }
    }

    /// <summary>名前に値を保存する。同じ名前が、すでにあれば、上書きする</summary>
    /// <exception cref="Win32Exception">保存できなかった。値が大きすぎるときも、ここに来る</exception>
    public static void Set(string name, string value)
    {
        ArgumentException.ThrowIfNullOrEmpty(value);

        var blob = Encoding.Unicode.GetBytes(value);
        var blobPointer = Marshal.AllocHGlobal(blob.Length);
        try
        {
            Marshal.Copy(blob, 0, blobPointer, blob.Length);
            var credential = new Credential
            {
                Type = TypeGeneric,
                TargetName = name,
                UserName = name,
                CredentialBlobSize = (uint)blob.Length,
                CredentialBlob = blobPointer,
                Persist = PersistLocalMachine,
            };
            if (!CredWrite(ref credential, 0))
            {
                throw new Win32Exception(Marshal.GetLastWin32Error());
            }
        }
        finally
        {
            // 平文のコピーを、メモリに残さない
            Array.Clear(blob);
            Marshal.Copy(blob, 0, blobPointer, blob.Length);
            Marshal.FreeHGlobal(blobPointer);
        }
    }

    /// <summary>名前の値を消す。消したら true、もともと無ければ false を返す</summary>
    /// <exception cref="Win32Exception">保存されていない以外の理由で、消せなかった</exception>
    public static bool Remove(string name)
    {
        if (CredDelete(name, TypeGeneric, 0))
        {
            return true;
        }

        var error = Marshal.GetLastWin32Error();
        return error == ErrorNotFound ? false : throw new Win32Exception(error);
    }

    [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
    private struct Credential
    {
        public uint Flags;
        public uint Type;
        public string? TargetName;
        public string? Comment;
        public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
        public uint CredentialBlobSize;
        public IntPtr CredentialBlob;
        public uint Persist;
        public uint AttributeCount;
        public IntPtr Attributes;
        public string? TargetAlias;
        public string? UserName;
    }

    [DllImport("advapi32.dll", EntryPoint = "CredReadW", CharSet = CharSet.Unicode, SetLastError = true)]
    private static extern bool CredRead(string target, uint type, uint flags, out IntPtr credential);

    [DllImport("advapi32.dll", EntryPoint = "CredWriteW", CharSet = CharSet.Unicode, SetLastError = true)]
    private static extern bool CredWrite(ref Credential credential, uint flags);

    [DllImport("advapi32.dll", EntryPoint = "CredDeleteW", CharSet = CharSet.Unicode, SetLastError = true)]
    private static extern bool CredDelete(string target, uint type, uint flags);

    [DllImport("advapi32.dll")]
    private static extern void CredFree(IntPtr buffer);
}

向かない場面

  • 利用者のログインのパスワードを照合するだけなら、保存せずにハッシュにします。この部品は、アプリ自身が使うパスワード (DB への接続など)を、元の文字列で取り出す用途です。
  • SQL Server に Windows 認証で入れるならパスワード自体が要りません。そちらのほうが安全です。
  • 開発中の手元の確認だけなら dotnet user-secrets があります。こちらは利用者のフォルダに平文の JSON として保存されます。
  • 防げるのは設定ファイルや Git に平文が残ることです。同じ Windows アカウントで動く別のプログラムからは読めます。別のプロセスから同じ名前を読めることを確かめました。マルウェアから守る仕組みではありません。
  • 値はこの PC の中だけに保存します。ほかの PC の同じアプリには渡りません。

前提

  • Windows 11 の .NET 8 以降のアプリ。動かして確かめたのは .NET 10 です。
  • 画面を持たないアプリでも使えます。API を呼ぶ部分は画面の部品に依存しません。
  • 別の Windows アカウントで開いたときの動きと Windows のサービスとして動かしたときの動きは試していません。

ここから下は中で何をしているかの解説です。
部品を直して使いたいときに読んでください。

何のために使うのか

設定ファイルに平文で書いたパスワードは、ファイルをコピーすると一緒にコピーされます。
資格情報マネージャーに入れると、パスワードは設定ファイルにも Git にも残りません。
たとえば次のような場面です。

  • 設定ファイルをそのまま Git に上げてもパスワードが入らない形にしたい。
  • 設定ファイルをほかの人に渡したり問い合わせで送ったりしてもパスワードが含まれない形にしたい。
  • ログイン画面で入れたパスワードを次の起動から覚えておきたい。

資格情報マネージャーに入るもの

Windows の「コントロール パネル」の「資格情報マネージャー」を開くと「Windows 資格情報」の中に「汎用資格情報」の一覧があります。
アプリが保存した項目はここに並びます。

1 件の項目は次のものでできています。

項目 内容
ターゲット名 項目の名前。読み込みと削除はこの名前で探す
ユーザー名 一覧に出る名前。この部品ではターゲット名と同じ値を入れる
値 バイト列。大きさは 2560 バイトまで
保存の範囲 サインアウトで消えるか、この PC に残るか

値が文字列ではなくバイト列であることが、長い値の上限と文字コードの落とし穴につながります。

呼ぶ API

使う関数は advapi32.dll にある 4 つです。

関数 役割
CredWriteW 項目を保存する。同じターゲット名があれば上書きする
CredReadW ターゲット名で項目を読む。Windows が用意した領域のポインターを返す
CredDeleteW ターゲット名で項目を消す
CredFree CredReadW が返した領域を解放する

名前の最後の W は文字列が UTF-16 の版です。
C# の宣言では EntryPoint に W の付いた名前を書きます。

構造体 CREDENTIALW

保存と読み込みのやり取りは CREDENTIALW という構造体で行います。
C# では次のように宣言します。

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct Credential
{
    public uint Flags;
    public uint Type;
    public string? TargetName;
    public string? Comment;
    public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
    public uint CredentialBlobSize;
    public IntPtr CredentialBlob;
    public uint Persist;
    public uint AttributeCount;
    public IntPtr Attributes;
    public string? TargetAlias;
    public string? UserName;
}

使わない項目も宣言を省けません。
Windows はこの構造体を決まった並びの決まった大きさのメモリとして読むので、1 項目でも欠けると後ろの項目の位置がずれます。
LayoutKind.Sequential と CharSet.Unicode で、並びと文字列の形を Windows の定義に合わせています。

この部品で値を入れる項目は次の 6 つです。
残りは 0 か null のままにします。

項目 入れる値
Type 1。汎用資格情報を表す CRED_TYPE_GENERIC
TargetName ターゲット名
UserName ターゲット名と同じ値。一覧に出る名前になる
CredentialBlobSize 値のバイト数
CredentialBlob 値の先頭を指すポインター
Persist 2。この PC に残す CRED_PERSIST_LOCAL_MACHINE

値は次の定数にして持ちます。

private const uint TypeGeneric = 1;          // CRED_TYPE_GENERIC
private const uint PersistLocalMachine = 2;  // CRED_PERSIST_LOCAL_MACHINE
private const int ErrorNotFound = 1168;      // ERROR_NOT_FOUND

Persist にはほかに、サインアウトで消える 1 (CRED_PERSIST_SESSION)と、ドメインを通じてほかの PC に渡ることがある 3 (CRED_PERSIST_ENTERPRISE)があります。
この 2 つは試していません。

関数の宣言

[DllImport("advapi32.dll", EntryPoint = "CredReadW", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool CredRead(string target, uint type, uint flags, out IntPtr credential);

[DllImport("advapi32.dll", EntryPoint = "CredWriteW", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool CredWrite(ref Credential credential, uint flags);

[DllImport("advapi32.dll", EntryPoint = "CredDeleteW", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool CredDelete(string target, uint type, uint flags);

[DllImport("advapi32.dll")]
private static extern void CredFree(IntPtr buffer);

SetLastError = true を付けると、失敗したときに Windows のエラー番号を Marshal.GetLastWin32Error() で取れます。
CredRead は読んだ項目を Windows が用意した領域に置いて、そのポインターを out IntPtr で返します。
CredWrite はこちらで用意した構造体を ref で渡します。

保存する

値は文字列ではなくバイト列として渡します。
構造体には値そのものではなく値の先頭を指すポインターを入れるので、先にアンマネージドのメモリにバイト列をコピーします。
次は、上の CredentialStore.cs の Set です。

public static void Set(string name, string value)
{
    ArgumentException.ThrowIfNullOrEmpty(value);

    var blob = Encoding.Unicode.GetBytes(value);
    var blobPointer = Marshal.AllocHGlobal(blob.Length);
    try
    {
        Marshal.Copy(blob, 0, blobPointer, blob.Length);
        var credential = new Credential
        {
            Type = TypeGeneric,
            TargetName = name,
            UserName = name,
            CredentialBlobSize = (uint)blob.Length,
            CredentialBlob = blobPointer,
            Persist = PersistLocalMachine,
        };
        if (!CredWrite(ref credential, 0))
        {
            throw new Win32Exception(Marshal.GetLastWin32Error());
        }
    }
    finally
    {
        // 平文のコピーを、メモリに残さない
        Array.Clear(blob);
        Marshal.Copy(blob, 0, blobPointer, blob.Length);
        Marshal.FreeHGlobal(blobPointer);
    }
}

文字列は Encoding.Unicode で UTF-16 のバイト列にしています。
finally でバイト列を 0 で上書きしてからメモリを解放します。
ただし元の string は .NET のメモリに残るので、完全に消せるわけではありません。

失敗したときは Win32Exception を投げます。
Win32Exception にエラー番号を渡すと、その番号に対応するメッセージが Message に入ります。

読み込む

CredRead が返すのは Windows が用意した領域のポインターです。
Marshal.PtrToStructure で構造体にして値を取り出し、最後に CredFree で領域を返します。
次は、上の CredentialStore.cs の Get です。

public static string? Get(string name)
{
    if (!CredRead(name, TypeGeneric, 0, out var pointer))
    {
        var error = Marshal.GetLastWin32Error();
        return error == ErrorNotFound ? null : throw new Win32Exception(error);
    }

    try
    {
        var credential = Marshal.PtrToStructure<Credential>(pointer);
        return Marshal.PtrToStringUni(credential.CredentialBlob, (int)credential.CredentialBlobSize / 2);
    }
    finally
    {
        CredFree(pointer);
    }
}

項目が無いときはエラー番号 1168 (ERROR_NOT_FOUND)で失敗します。
これは異常ではないので、例外にせず null を返しています。

CredentialBlobSize は文字数ではなくバイト数です。
UTF-16 の 1 文字は 2 バイトなので、2 で割って文字数にしています。

削除する

上の CredentialStore.cs の Remove です。

public static bool Remove(string name)
{
    if (CredDelete(name, TypeGeneric, 0))
    {
        return true;
    }

    var error = Marshal.GetLastWin32Error();
    return error == ErrorNotFound ? false : throw new Win32Exception(error);
}

消したら true、もともと無ければ false を返します。
読み込みと同じ 1168 の扱いです。

つまずきやすいところ

動かして出会ったのは最初の 1 つです。
残りは保存の仕組みから、そうなるものです。

長い値で、「スタブは正しくないデータを受信しました。」と出る

1281 文字以上の値を保存すると、次のエラーで失敗します。

スタブは正しくないデータを受信しました。(エラー 1783)

メッセージからは長すぎることが分かりません。
値の上限は 2560 バイトで、UTF-16 では 1280 個分です。
1280 文字までは保存できることを確かめました。
接続文字列を丸ごと保存するのではなくパスワードだけを保存すると、この上限には当たりにくくなります。

読んだあとに CredFree を呼ばないと領域が残る

CredRead が返した領域は Windows が確保したもので、.NET のガベージ コレクションでは解放されません。
CredFree を呼ばないと Windows が確保した領域が残ります。
try と finally で、読み取りが失敗しても必ず CredFree が呼ばれるようにしています。

文字コードを読み書きでそろえる

値はバイト列なので、どの文字コードで読むかは読む側が決めます。
この部品は保存も読み込みも UTF-16 です。
ほかのプログラムが保存した項目を読むときは、そのプログラムの文字コードに合わせます。

保存した名前を変えると読めなくなる

読み込みはターゲット名を指定して、その名前の値を探す形です。
ターゲット名を書き換えると、前の名前で保存した値は新しい名前では探せなくなり、読めなくなります。
名前は決めたら変えないでください。

アンインストールしても項目が残る

資格情報マネージャーの項目は、アプリのフォルダではなく Windows が持っています。
そのためアプリを消しても残ります。
消したいときは、Remove を呼ぶ処理を自分で用意するか、利用者に資格情報マネージャーの画面から消してもらう必要があります。

何を JSON に、何を資格情報マネージャーに置くか

資格情報マネージャーに入れるのは秘密のものだけです。
サーバー名・データベース名・ユーザー名は秘密ではないので、これまでどおり JSON のファイルに残します。
こうすると設定ファイルは、人が読んで直せて Git にも上げられます。
接続文字列を丸ごと入れるより、上限にも当たりにくくなります。

ターゲット名は アプリ名.用途 の形にして、ほかのアプリの項目とぶつからないようにします。
API キーのような別の秘密を保存するときは名前を分けて、同じ Set と Get を使います。

接続先の JSON は、一時ファイルに書いてから置き換えて保存します。
途中で止まってもファイルが壊れないようにするためです。
コードは下に載せています。

保存と削除は失敗することがあります。
失敗すると Win32Exception か IOException が出るので、画面にメッセージを出して続けられるようにします。

残りのファイル

CredentialStore.cs は上に載せました。
ここには、接続先を JSON に保存する 2 つのファイルと、フォームから呼ぶ例を載せます。
先頭に必要な using と #nullable enable を書いてあるので、プロジェクトの設定に関わらずそのままビルドできます。
名前空間は付けていません。

Settings/ConnectionSettings.cs
#nullable enable

/// <summary>
/// データベースの接続先。パスワードは含まない。パスワードは、資格情報マネージャーに置く。
/// JSON のファイルに書いても、Git に上げても、困らないものだけを持つ。
/// </summary>
public sealed record ConnectionSettings(string Server, string Database, string UserName);
Settings/ConnectionSettingsFile.cs
#nullable enable
using System;
using System.IO;
using System.Text.Json;

/// <summary>
/// <see cref="ConnectionSettings"/> を、JSON のファイルに保存して、読み込む。
/// 保存は、一時ファイルに書いてから置き換えるので、途中で止まっても、ファイルは壊れない。
/// </summary>
public sealed class ConnectionSettingsFile
{
    private static readonly JsonSerializerOptions Options = new() { WriteIndented = true };

    private readonly string _path;

    /// <param name="path">JSON のファイルの場所</param>
    public ConnectionSettingsFile(string path) => _path = path;

    /// <summary>JSON のファイルの場所</summary>
    public string Path => _path;

    /// <summary>読み込む。ファイルが無いときは、null を返す</summary>
    public ConnectionSettings? Load()
    {
        if (!File.Exists(_path)) return null;

        using var stream = File.OpenRead(_path);
        return JsonSerializer.Deserialize<ConnectionSettings>(stream);
    }

    /// <summary>保存する。フォルダが無ければ、作る</summary>
    public void Save(ConnectionSettings settings)
    {
        Directory.CreateDirectory(System.IO.Path.GetDirectoryName(_path)!);

        var temporary = _path + ".tmp";
        File.WriteAllText(temporary, JsonSerializer.Serialize(settings, Options));
        File.Move(temporary, _path, overwrite: true);
    }
}

フォームから呼ぶ形は次のとおりです。
_server・_database・_userName・_password は入力欄で、_status は結果を出すラベルです。
自分のフォームの名前に読み替えてください。

フォームから呼ぶ例
/// <summary>資格情報マネージャーに、パスワードを入れる名前。ターゲット名と呼ばれる。決めたら変えない。変えると、保存済みの値を読めなくなる</summary>
private const string PasswordName = "CredentialManagerSample.Database";

private readonly ConnectionSettingsFile _file = new(Path.Combine(
    Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
    "CredentialManagerSample", "AppSettings.json"));

/// <summary>サーバー名などを JSON に、パスワードを資格情報マネージャーに、保存する</summary>
private void SaveSettings()
{
    if (string.IsNullOrEmpty(_password.Text))
    {
        _status.Text = "パスワードが空です。";
        return;
    }

    _file.Save(new ConnectionSettings(_server.Text, _database.Text, _userName.Text));
    CredentialStore.Set(PasswordName, _password.Text);
    _status.Text = "保存しました。";
}

/// <summary>JSON と資格情報マネージャーから、読み込む</summary>
private void LoadSettings()
{
    var settings = _file.Load();
    if (settings is not null)
    {
        _server.Text = settings.Server;
        _database.Text = settings.Database;
        _userName.Text = settings.UserName;
    }

    var password = CredentialStore.Get(PasswordName);
    _password.Text = password ?? "";
    _status.Text = password is null ? "パスワードは、保存されていません。" : "読み込みました。";
}

/// <summary>資格情報マネージャーから、パスワードだけを消す</summary>
private void DeletePassword()
{
    _status.Text = CredentialStore.Remove(PasswordName) ? "パスワードを削除しました。" : "パスワードは、保存されていません。";
    _password.Clear();
}

読み込んだパスワードをいつもの接続文字列に入れて、データベースにつなぎます。
入力欄とボタンまで付いた完成形の見本は、リポジトリの MainForm.cs にあります。

この仕組みが動いているところ

この仕組みは、MmmTool という Windows 11 のタスクトレイに常駐するツールで、データベースのパスワードや Backlog の API キーの保存に使っています。
MmmTool では、サーバー名とデータベース名とユーザー名を JSON に、パスワードを資格情報マネージャーに保存しています。
この部品は、その分け方を元にしています。

AI と一緒に作りました。どう動くかは自分で決めて、気に入らないところは指示して直しています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?