WPF の PasswordBox.Password をバインドできない理由と、MVVM でビューモデルへ渡す 2 つの方法

PasswordBox.Password は依存関係プロパティでなく、XAML でバインドすると XamlParseException になる。添付プロパティと CommandParameter の 2 つの渡し方を、ボタンの有効化とメモリに残る平文で実測比較する。

概要

MVVM でログイン画面を作るとき、TextBox と同じように PasswordBox の Password をビューモデルの string にバインドする書き方が最初に試される。
しかし Password="{Binding Password}" と書いた XAML は、読み込むと例外になる。
回避策の添付プロパティも、書き方によっては入力がビューモデルに届かなかったり、入力した文字の順序が逆になったりする。

本記事では、バインドできない原因を例外と型の情報から確かめたうえで、ビューモデルへパスワードを渡す 2 つの方法(添付プロパティで string と同期する / CommandParameter で PasswordBox を渡す)を実装する。
2 つの方法は、ボタンの有効・無効を入力に追従させられるか、パスワードの平文がプロセスのメモリに残るかで結果が分かれる。
後者はプロセスのメモリを実際に走査して確かめた。


前提・対象環境


問題

次の XAML は、ビューモデルの Password プロパティを PasswordBox に表示し、入力をビューモデルへ戻すつもりで書いたものである。

<PasswordBox Password="{Binding Password}" />

この XAML を読み込むと XamlParseException になる。
メッセージは、PasswordBox の Password プロパティには Binding を設定できず、Binding は DependencyObject の DependencyProperty にしか設定できない、というものだった。

条件 計測値
XAML: Password="{Binding Password}" XamlParseException: 型 'PasswordBox' の 'Password' プロパティで 'Binding' を設定することはできません。'Binding' は、DependencyObject の DependencyProperty でのみ設定できます。
PasswordBox が sealed か True
PasswordProperty / SecurePasswordProperty があるか / INotifyPropertyChanged を実装しているか False / False / False

Password にバインドを設定したときの例外と、PasswordBox の型の性質。.NET 10 / Windows 11 で実測。XAML は XamlReader.Parse で読み込み、メッセージは UI カルチャを ja-JP にして 1 行目を読んだ。


原因・背景

PasswordBox には PasswordProperty も SecurePasswordProperty も無く、Password と SecurePassword は依存関係プロパティではない(表の 3 行目。Password については 1 行目の例外も同じことを示す)。
PasswordBox は INotifyPropertyChanged も実装していない。
そのため Password はバインドのターゲットにならない。
バインドのソースにした場合も、入力による値の変化は伝わらなかった(後述の CommandParameter の表)。
入力の変化を知るために用意されているのは PasswordChanged イベントである(PasswordBox の Examples)。

依存関係プロパティにしなかった理由は、公式ドキュメントには書かれていない。
ドキュメントが述べているのは、Password を読むと平文がメモリに出ること、それを避けるには SecurePassword で SecureString として受け取ること、の 2 点である(PasswordBox.Password)。
内部でも、パスワードは string ではなく SecureString のフィールドに保持されている(PasswordBox のデモページで、同じ環境で確かめている)。

PasswordBox は sealed なので(表の 2 行目)、派生クラスで依存関係プロパティを足すこともできない。
ビューモデルへ渡すには、PasswordBox の外側から仕組みを足すことになる。
本記事では、その手段として添付プロパティと CommandParameter の 2 つを比べる。


2 つの受け渡し方

方法 1: 添付プロパティで string のプロパティと同期する

PasswordBox に添付プロパティ BoundPassword を付け、これをビューモデルの string にバインドする。
入力されたら PasswordChanged で BoundPassword を書き換え、ビューモデルが値を変えたら Password を書き換える。

この方向の実装は、書き方によって次の 2 つの不具合が出た。

条件 計測値
変更コールバックの中で購読・ビューモデルの初期値 "": "abc" を入力 ViewModel.Password ""
値を比べずに Password を設定し直す: "abc" を入力 PasswordBox "cba" / ViewModel "cba"
添付プロパティなし: "ab" を入力してから "c" を入力(Password は設定しない) PasswordBox "abc"
添付プロパティなし: "ab" を入力し、コードで Password = "ab" を設定してから "c" を入力 PasswordBox "cab"
記事の添付プロパティ: "abc" を入力 PasswordBox "abc" / ViewModel "abc"
続けてビューモデルが Password = "" を設定 PasswordBox ""
ボタンの IsEnabled: 入力の前 / 後 False / True

添付プロパティの書き方と、入力の結果。.NET 10 / Windows 11 で実測。「abc」は 1 文字ずつ入力した。

1 つ目は、PasswordChanged の購読を BoundPassword の変更コールバックの中で行う書き方である。
変更コールバックは、プロパティの有効値が変わったときに呼ばれる(PropertyChangedCallback)。
ビューモデルの初期値が空文字列だと、添付プロパティの既定値(空文字列)と同じなので有効値が変わらず、この規則のとおりなら変更コールバックは呼ばれない。
実際に、入力してもビューモデルの Password は空のままだった。

2 つ目は、変更コールバックで値を比べずに Password を設定し直す書き方である。
入力のたびに PasswordChanged で BoundPassword が書き換わり、その変更コールバックが同じ値を Password に設定し直す。
コードから同じ値を Password に設定し直すと、その直後の入力は先頭に入る(表の「添付プロパティなし」の 2 行。設定しなければ "abc"、「ab」の後に設定し直してから「c」を入力すると "cab")。
これが 1 文字ごとに起きるため、「abc」と入力した結果は "cba" になった。

記事の実装では、購読を専用の Attach プロパティに分け、Password と値が同じなら設定し直さない。

using System.Windows;
using System.Windows.Controls;

public static class PasswordBoxAssist
{
    public static readonly DependencyProperty AttachProperty =
        DependencyProperty.RegisterAttached("Attach", typeof(bool), typeof(PasswordBoxAssist),
            new PropertyMetadata(false, OnAttachChanged));

    public static readonly DependencyProperty BoundPasswordProperty =
        DependencyProperty.RegisterAttached("BoundPassword", typeof(string), typeof(PasswordBoxAssist),
            new FrameworkPropertyMetadata(string.Empty,
                FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnBoundPasswordChanged));

    public static bool GetAttach(DependencyObject d) => (bool)d.GetValue(AttachProperty);
    public static void SetAttach(DependencyObject d, bool value) => d.SetValue(AttachProperty, value);
    public static string GetBoundPassword(DependencyObject d) => (string)d.GetValue(BoundPasswordProperty);
    public static void SetBoundPassword(DependencyObject d, string value) => d.SetValue(BoundPasswordProperty, value);

    private static void OnAttachChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (d is not PasswordBox box) return;
        box.PasswordChanged -= OnPasswordChanged;
        if ((bool)e.NewValue) box.PasswordChanged += OnPasswordChanged;
    }

    private static void OnBoundPasswordChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (d is not PasswordBox box) return;
        string value = (string?)e.NewValue ?? string.Empty;

        // 入力で書き戻した値と同じなら設定し直さない。Password を設定すると、次の入力が先頭に入る。
        if (box.Password != value) box.Password = value;
    }

    private static void OnPasswordChanged(object sender, RoutedEventArgs e)
    {
        var box = (PasswordBox)sender;
        SetBoundPassword(box, box.Password);
    }
}

Attach は値が false から true に変わったときに変更コールバックが呼ばれるため、ビューモデルの初期値に関係なく購読される。
BoundPassword は BindsTwoWayByDefault にしてあるので、XAML で Mode=TwoWay を書かなくても入力がビューモデルへ戻る。

ビューモデルは、パスワードを string のプロパティとして持つ。
パスワードが空でなければボタンを押せるよう、値が変わるたびに RaiseCanExecuteChanged を呼ぶ。

public sealed class LoginViewModel : ObservableObject
{
    private string _password = string.Empty;

    public LoginViewModel() => SignInCommand = new RelayCommand(SignIn, () => Password.Length > 0);

    public RelayCommand SignInCommand { get; }

    public string Password
    {
        get => _password;
        set
        {
            if (SetProperty(ref _password, value)) SignInCommand.RaiseCanExecuteChanged();
        }
    }

    private void SignIn()
    {
        // 認証の処理。終わったら空にする。
        Password = string.Empty;
    }
}

XAML では、Attach と BoundPassword の両方を付ける。
local は PasswordBoxAssist を定義した名前空間である。

<StackPanel>
  <PasswordBox local:PasswordBoxAssist.Attach="True"
               local:PasswordBoxAssist.BoundPassword="{Binding Password}" />
  <Button Content="サインイン" Command="{Binding SignInCommand}" />
</StackPanel>

この実装では「abc」がそのまま "abc" としてビューモデルに届き、ビューモデルが Password = "" にすると PasswordBox も空になった(表の「記事の添付プロパティ」の行と、続く 1 行)。
ボタンは入力の前は無効で、入力すると有効になった(表の最後の行)。

方法 2: CommandParameter で PasswordBox を渡し、実行時に SecurePassword を読む

ビューモデルにパスワードを持たせず、ボタンを押したときだけ受け取る方法である。
ここでも、SecurePassword や Password をバインドする書き方は期待どおりに動かなかった。

条件 計測値
CommandParameter="{Binding SecurePassword, ElementName=PasswordInput}": "abc" を入力して押す SecureString.Length 0
CommandParameter="{Binding Password, ElementName=PasswordInput}": "abc" を入力して押す String.Length 0
記事の SignInViewModel・CommandParameter="{Binding ElementName=PasswordInput}": "abc" を入力して押す SecureString.Length 3
RelayCommand<PasswordBox> の CanExecute が SecurePassword.Length > 0 を見る: ボタンの IsEnabled: 入力の前 / 後、(CommandParameter の型; RaiseCanExecuteChanged を呼んだ後) False / False (PasswordBox; True)

CommandParameter の書き方と、コマンドが受け取った値。.NET 10 / Windows 11 で実測。「abc」は 1 文字ずつ入力した。

CommandParameter に {Binding SecurePassword, ElementName=PasswordInput} や {Binding Password, ElementName=PasswordInput} を書くと、「abc」と入力してから押しても、コマンドに渡る値の長さは 0 だった。
入力の前の値(空)がそのまま渡っている。
PasswordBox には PasswordChanged イベントがあるが、バインドは入力の後に値を読み直さなかった。

PasswordBox そのものを渡し、コマンドを実行するときに SecurePassword を読めば、入力した長さ(3)になった。

<StackPanel>
  <PasswordBox x:Name="PasswordInput" />
  <Button Content="サインイン"
          Command="{Binding SignInCommand}"
          CommandParameter="{Binding ElementName=PasswordInput}" />
</StackPanel>

ビューモデルは PasswordBox を受け取るコマンドを公開し、実行の中だけ SecureString を扱う。
SecurePassword は読むたびに新しいコピーを返すので(デモページで確認)、using で破棄する。

using System.Security;
using System.Windows.Controls;

public sealed class SignInViewModel
{
    public SignInViewModel() => SignInCommand = new RelayCommand<PasswordBox>(SignIn);

    public RelayCommand<PasswordBox> SignInCommand { get; }

    private void SignIn(PasswordBox box)
    {
        using SecureString password = box.SecurePassword;

        // 認証の処理に password を渡す。
    }
}

この方法では、ビューモデルは入力が変わったことを知らない。
同じ RelayCommand<PasswordBox> の CanExecute で SecurePassword.Length > 0 を見る書き方にしても、ボタンは入力の前も後も無効のままだった(表の最後の行)。
このとき CommandParameter は PasswordBox に解決しており、ビューモデルが RaiseCanExecuteChanged を呼ぶと有効になった。
入力しても CanExecuteChanged を発生させるコードが無いため、ボタンの状態は入力の前のまま変わらない。
CommandManager.RequerySuggested につなぐ実装のコマンドでは、TextBox へのキー入力で問い直されることを関連記事で実測している。
RequerySuggested につなぐコマンドに PasswordBox を組み合わせた場合は、本記事の結果(追従しない)と異なる可能性があるが、本記事では測っていない。


判断の軸


軸ごとの結論

平文がメモリに残るかは、プロセスの読み取り可能なコミット済みメモリをすべて読み、パスワードの UTF-16 の並びを探して確かめた。
パスワードは条件ごとに乱数で作り直し、探す側のコードがパスワードの並びを作らないよう、マスクした形でだけ保持して 1 文字ずつ照合した。

コードが行ったこと メモリ上に見つかったか
PasswordBox に入力しただけ なし
SecurePassword を読んだ(Dispose した) なし
SecurePassword を非マネージドのメモリへ展開した直後(ZeroFreeGlobalAllocUnicode の前) あり
SecurePassword を非マネージドのメモリへ展開し、ZeroFreeGlobalAllocUnicode で消した なし
Password を 1 回読んだ(string。参照は捨てた) あり
添付プロパティでビューモデルの string に同期した あり
同じ同期の後、PasswordBox.Clear() で空にして(ビューモデルも "" になる)GC.Collect を実行した あり

各操作の後に、12 文字のパスワードの UTF-16 の並びがプロセスのメモリに見つかったか。.NET 10 / Windows 11(64 ビット)で実測。条件ごとに乱数のパスワードを作り直し、1 文字ずつ TextComposition で入力した。12 文字届いたことは各行で確かめた(ビューモデルを持つ行は操作の前にビューモデルの string の長さで、ほかの行は走査の後に SecurePassword の長さで)。走査したのは VirtualQuery でコミット済みかつ読み取り可能な領域(アクセス不可とガードページを除く)で、2 バイト境界に揃った連続した並びだけを探した。読み込みに失敗した範囲は飛ばした。ANSI や分割された断片は探していない。

PasswordBox に入力しただけ、SecurePassword を読んだだけでは、並びは見つからなかった。
SecureString を Marshal.SecureStringToGlobalAllocUnicode で非マネージドのメモリへ展開すると、その直後は見つかり、ZeroFreeGlobalAllocUnicode で消した後には見つからなかった。
展開した直後に見つかったことは、この走査が非マネージドのメモリも読めていることを示している。

一方、Password を 1 回読むと、参照を捨てた後でも並びが見つかった。
方法 1 でビューモデルの string に同期した場合も見つかった。
同期の後に PasswordBox.Clear() で空にして(ビューモデルも空文字列になる)、GC.Collect を実行した後でも見つかった。

軸 方法 1: 添付プロパティで string と同期 方法 2: CommandParameter で PasswordBox を渡す
ボタンの有効・無効 入力に追従した(添付プロパティの表の最後の行) 本記事の RelayCommand では追従しなかった(CommandParameter の表の最後の行)
ビューモデルから値を消す Password = "" で PasswordBox も空になった ビューモデルは値を持たない
平文の並びがメモリに残るか 同期した時点で残り、空にして GC を実行した後も残っていた SecurePassword を読むだけなら見つからなかった
ビューモデルが依存する型 string だけ PasswordBox(WPF)

本記事の RelayCommand で入力に応じた有効化を行いたい場合や、ビューモデルからの消去が要る場合は、方法 1 を選ぶ。
その代わり、入力した時点から平文の string がプロセスに残ることを受け入れることになる。

平文を string にする回数を減らしたいなら、方法 2 を選ぶ。
ただし本記事の RelayCommand では、CanExecute で入力を見ず、空のパスワードは実行の中で弾く設計になる。


注意点


まとめ

PasswordBox.Password は依存関係プロパティではないため、XAML でバインドすると XamlParseException になる。
PasswordBox は sealed なので外側から仕組みを足すことになり、本記事ではその手段として添付プロパティと CommandParameter を比べた。

CanExecuteChanged をビューモデルから発生させるコマンドでボタンの有効・無効を入力に追従させる画面や、ビューモデルから入力欄を空にしたい画面では、方法 1 の添付プロパティを使う。
購読は専用の Attach プロパティで行い、Password は値が違うときだけ設定し直す。
平文の string を作る回数を減らしたい画面では、方法 2 で PasswordBox を渡し、実行の中で SecurePassword を読んで破棄する。
どちらを選んでも、認証の API が string を要求する時点で平文はメモリに出るので、メモリ上の秘匿を強く求める要件ではパスワードをプロセスの外の資格情報で扱う設計を検討する。


関連記事