A GTK4 ssh-askpass in Zig

Marius

ZigとGTK4で作ったssh-askpass

Gentooツリーにあるどのssh-askpassX11かフルセットのKDEスタックを引っ張ってくるため、Zig 0.16とGTK4で自作しました。手書きのバインディングで、ビルドからXを排除しています。

ZigとGTK4で作ったssh-askpass

普段はノートPCでhardened Gentooを使っており、ほとんどのシステムでは-sk鍵を使っているため、通常はssh-askpassに触れることはありません。ただ、どうしても必要になる場面が一つだけあります。通常のED25519鍵のパスフレーズをプログラムが求めてくるのに、読み取り元の端末がない場合です。典型的なのはgo get、あるいはTTYなしで実行されるビルド中にgoツールチェインがSSH経由でプライベートモジュールを取得するケース全般です。OpenSSHはパイプ上ではプロンプトを出せないため、SSH_ASKPASSが指すプログラムを実行し、代わりにウィンドウでパスフレーズの入力を求めます。私は何年もそのためのものを何も入れておらず、その都度やりくりして凌いできました。その最大の理由が、GentooPortageが提供する選択肢です。

 ~ emerge -s ssh-askpass

[ Results for search key : ssh-askpass ]
Searching...

*  kde-plasma/ksshaskpass
      Latest version available: 6.6.6
      Latest version installed: [ Not Installed ]
      Size of files: 32 KiB
      Homepage:      https://kde.org/plasma-desktop https://invent.kde.org/plasma/ksshaskpass
      Description:   Implementation of ssh-askpass with KDE Wallet integration
      License:       GPL-2

*  lxqt-base/lxqt-openssh-askpass
      Latest version available: 2.4.0
      Latest version installed: [ Not Installed ]
      Size of files: 19 KiB
      Homepage:      https://lxqt-project.org/ https://github.com/lxqt/lxqt-openssh-askpass/
      Description:   LXQt OpenSSH user password prompt tool
      License:       LGPL-2.1 LGPL-2.1+

*  net-misc/gnome-ssh-askpass
      Latest version available: 10.0_p1
      Latest version installed: [ Not Installed ]
      Size of files: 1,927 KiB
      Homepage:      https://www.openssh.com/
      Description:   GTK-based passphrase dialog for use with OpenSSH
      License:       BSD GPL-2

*  net-misc/ssh-askpass-fullscreen
      Latest version available: 1.2
      Latest version installed: [ Not Installed ]
      Size of files: 81 KiB
      Homepage:      https://github.com/atj/ssh-askpass-fullscreen
      Description:   A small SSH Askpass replacement written with GTK2
      License:       GPL-2+

*  net-misc/x11-ssh-askpass
      Latest version available: 1.2.4.1-r2
      Latest version installed: [ Not Installed ]
      Size of files: 30 KiB
      Homepage:      https://github.com/sigmavirus24/x11-ssh-askpass
      Description:   X11-based passphrase dialog for use with OpenSSH
      License:       HPND

[ Applications found : 5 ]

これらはいずれも、我慢してまで使いたいとは思えない難点が少なくとも一つはあります。私のシステムはグローバルなUSEフラグを-Xで運用しているため、X11を必要とするものは検討するまでもなく除外されます。5つのうち、X11への依存がまったくないのはkde-plasma/ksshaskpassだけなので、本来ならこれが当然の選択肢になるはずでした。しかし問題は、それ以外に付随して入ってくるものの多さです。Swayユーザーとして、たまにパスフレーズを入力するためだけにフルセットのKDEスタックをマシンに入れたくはありませんが、ksshaskpassを入れるとまさにそうなってしまいます。

~ # emerge -a kde-plasma/ksshaskpass

These are the packages that would be merged, in order:

Calculating dependencies... done!
Dependency resolution took 1.18 s (backtrack: 0/20).

[ebuild  N     ] kde-frameworks/kf-env-6
[ebuild  N     ] dev-libs/plasma-wayland-protocols-1.21.0  USE="-test"
[ebuild  N     ] dev-libs/qtkeychain-0.16.0  USE="-keyring -test"
[ebuild  N     ] kde-frameworks/ksecretd-services-6.27.0  USE="(-systemd)"
[ebuild  N     ] kde-frameworks/breeze-icons-6.27.0  USE="-test"
[ebuild  N     ] kde-frameworks/ki18n-6.27.0  USE="-debug -test" PYTHON_SINGLE_TARGET="python3_14 -python3_12 -python3_13"
[ebuild  N     ] kde-frameworks/kconfig-6.27.0  USE="dbus -debug -qml -test"
[ebuild  N     ] kde-frameworks/kcoreaddons-6.27.0  USE="dbus -debug -test"
[ebuild  N     ] kde-frameworks/kwidgetsaddons-6.27.0  USE="-debug -designer -test"
[ebuild  N     ] kde-frameworks/kdbusaddons-6.27.0  USE="-X -debug -test"
[ebuild  N     ] kde-frameworks/kwindowsystem-6.27.0  USE="wayland -X -debug -test"
[ebuild  N     ] app-crypt/qca-2.3.10-r1  USE="ssl -botan -debug -doc -examples -gcrypt -gpg -logger -nss -pkcs11 -sasl -softstore -test"
[ebuild  N     ] kde-frameworks/karchive-6.27.0  USE="crypt zstd -debug -test"
[ebuild  N     ] kde-frameworks/kguiaddons-6.27.0  USE="dbus wayland -X -debug -test"
[ebuild  N     ] kde-frameworks/kdoctools-6.27.0  USE="nls -debug -test"
[ebuild  N     ] kde-frameworks/kcolorscheme-6.27.0  USE="-debug"
[ebuild  N     ] kde-frameworks/kcrash-6.27.0  USE="-X -debug -test"
[ebuild  N     ] kde-frameworks/knotifications-6.27.0  USE="-debug"
[ebuild  N     ] kde-frameworks/kservice-6.27.0  USE="man -debug -test"
[ebuild  N     ] kde-frameworks/kwallet-6.27.0  USE="-debug -minimal -test"
[ebuild  N     ] kde-frameworks/kwallet-runtime-6.27.0  USE="(keyring) (legacy-kwallet) man -X -debug -gpg -test"
[ebuild  N     ] kde-plasma/ksshaskpass-6.6.6  USE="-debug"

Would you like to merge these packages? [Yes/No]

次はlxqt-base/lxqt-openssh-askpassですが、こちらは端的にXを必要とします。さらにいくつかのKDE Frameworksパッケージと、Xサポート付きでビルドされたQtを引っ張ってくるため、すでに-Xでコンパイルしてある手元のdev-qt/qtbaseと衝突し、Portageがスロット競合で止まります。

~ # emerge -a lxqt-base/lxqt-openssh-askpass

These are the packages that would be merged, in order:

Calculating dependencies... done!
Dependency resolution took 3.10 s (backtrack: 0/20).

[ebuild   R    ] x11-libs/libxkbcommon-1.13.2  USE="X*"
[ebuild  N     ] dev-util/lxqt-build-tools-2.4.0
[ebuild  N     ] x11-libs/xcb-util-renderutil-0.3.10  ABI_X86="(64) -32 (-x32)"
[ebuild  N     ] x11-libs/libXScrnSaver-1.2.5  USE="-doc" ABI_X86="(64) -32 (-x32)"
[ebuild  N     ] dev-libs/plasma-wayland-protocols-1.21.0  USE="-test"
[ebuild  N     ] x11-libs/xcb-util-wm-0.4.2  ABI_X86="(64) -32 (-x32)"
[ebuild  N     ] x11-libs/xcb-util-0.4.1  ABI_X86="(64) -32 (-x32)"
[ebuild  N     ] x11-libs/xcb-util-image-0.4.1  ABI_X86="(64) -32 (-x32)"
[ebuild  N     ] x11-libs/xcb-util-cursor-0.1.6  ABI_X86="(64) -32 (-x32)"
[ebuild   R    ] dev-qt/qtbase-6.11.1  USE="X*"
[ebuild  N     ] sys-auth/polkit-qt-0.201.1
[ebuild  N     ] dev-libs/libqtxdg-4.4.0  USE="-test"
[ebuild  N     ] kde-frameworks/kf-env-6
[ebuild  N     ] kde-frameworks/kwindowsystem-6.27.0  USE="X wayland -debug -test"
[ebuild  N     ] lxqt-base/liblxqt-2.4.0  USE="backlight"
[ebuild  N     ] lxqt-base/lxqt-openssh-askpass-2.4.0

!!! Multiple package instances within a single package slot have been pulled
!!! into the dependency graph, resulting in a slot conflict:

dev-qt/qtbase:6
  (dev-qt/qtbase-6.11.1:6/6.11.1::gentoo, installed) USE="concurrent dbus gui icu libinput libproxy network nls opengl sql sqlite ssl syslog udev vulkan wayland widgets xml (zstd) -X -accessibility -brotli -cups -custom-cflags -eglfs -evdev -gles2-only -gssapi -gtk -io-uring (-journald) -mysql -oci8 -odbc -postgres -renderdoc -sctp -test -tslib" ABI_X86="(64)"
  (dev-qt/qtbase-6.11.1:6/6.11.1::gentoo, ebuild scheduled for merge) USE="X concurrent dbus gui icu libinput libproxy network nls opengl sql sqlite ssl syslog udev vulkan wayland widgets xml (zstd) -accessibility -brotli -cups -custom-cflags -eglfs -evdev -gles2-only -gssapi -gtk -io-uring (-journald) -mysql -oci8 -odbc -postgres -renderdoc -sctp -test -tslib" ABI_X86="(64)"

The following USE changes are necessary to proceed:
# required by lxqt-base/liblxqt-2.4.0::gentoo
>=kde-frameworks/kwindowsystem-6.27.0 X
# required by kde-frameworks/kwindowsystem-6.27.0::gentoo[X]
>=dev-qt/qtbase-6.11.1 X
# required by dev-qt/qtbase-6.11.1::gentoo[gui]
>=x11-libs/libxkbcommon-1.13.2 X

net-misc/ssh-askpass-fullscreenも同様にXを必要とします。今度はGTK2と、Xサポート付きでビルドされたCairo経由です。

~ # emerge -a net-misc/ssh-askpass-fullscreen

These are the packages that would be merged, in order:

Calculating dependencies... done!
Dependency resolution took 2.98 s (backtrack: 0/20).

[ebuild   R    ] x11-libs/cairo-1.18.4-r1  USE="X*"
[ebuild  N     ] x11-libs/libXcomposite-0.4.7  USE="-doc" ABI_X86="(64) -32 (-x32)"
[ebuild  N     ] x11-libs/libXcursor-1.2.3  USE="-doc" ABI_X86="(64) -32 (-x32)"
[ebuild  N     ] x11-libs/libXdamage-1.1.7  ABI_X86="(64) -32 (-x32)"
[ebuild  N     ] x11-libs/libXi-1.8.3  USE="-doc" ABI_X86="(64) -32 (-x32)"
[ebuild  N     ] x11-themes/gnome-themes-standard-3.28-r1
[ebuild  NS    ] x11-libs/gtk+-2.24.33-r3 [3.24.52]
[ebuild  N     ] x11-themes/gtk-engines-adwaita-3.28-r1  ABI_X86="(64) -32 (-x32)"
[ebuild  N     ] net-misc/ssh-askpass-fullscreen-1.2

The following USE changes are necessary to proceed:
# required by x11-libs/gtk+-2.24.33-r3::gentoo
# required by x11-themes/gtk-engines-adwaita-3.28-r1::gentoo
>=x11-libs/cairo-1.18.4-r1 X

Would you like to add these changes to your config files? [Yes/No]

net-misc/x11-ssh-askpassは名前のとおりX11なので驚きはありませんし、そもそもコンパイルのために古いimakeビルドシステム、具体的にはx11-misc/xorg-cf-filesx11-misc/imakeまで必要になります。

~ # emerge -a net-misc/x11-ssh-askpass

These are the packages that would be merged, in order:

Calculating dependencies... done!
Dependency resolution took 1.00 s (backtrack: 0/20).

[ebuild  N     ] app-text/rman-3.2-r2
[ebuild  N     ] x11-misc/xorg-cf-files-1.0.9
[ebuild  N     ] x11-misc/imake-1.0.10
[ebuild  N     ] net-misc/x11-ssh-askpass-1.2.4.1-r2

Would you like to merge these packages? [Yes/No]

残るはnet-misc/gnome-ssh-askpassでした。一見するとXをまったく必要としない唯一の選択肢に見えましたが、実際は違いました。やはりX11が必要で、ebuild側の扱いが壊れているようです。ビルドではpkg-config --libs gtk+-3.0 x11が呼ばれ、ソースではX専用のGDKヘッダーであるgdk/gdkx.hがインクルードされているため、Xなしでコンパイルされたシステムではビルドに失敗します。

~ # emerge -a net-misc/gnome-ssh-askpass

These are the packages that would be merged, in order:

Calculating dependencies... done!
Dependency resolution took 0.89 s (backtrack: 0/20).

[ebuild  N    ~] net-misc/gnome-ssh-askpass-10.0_p1  USE="-verify-sig"

Would you like to merge these packages? [Yes/No] Yes
>>> Verifying ebuild manifests
>>> Emerging (1 of 1) net-misc/gnome-ssh-askpass-10.0_p1::guru
>>> Failed to emerge net-misc/gnome-ssh-askpass-10.0_p1, Log file:
>>>  '/var/tmp/portage/net-misc/gnome-ssh-askpass-10.0_p1/temp/build.log'
>>> Jobs: 0 of 1 complete, 1 failed                                     Load avg: 0.79, 0.57, 0.61
 * Package:    net-misc/gnome-ssh-askpass-10.0_p1:0
 * Repository: guru
 * USE:        abi_x86_64 amd64 elibc_glibc kernel_linux
 * FEATURES:   network-sandbox preserve-libs sandbox selinux sesandbox userpriv usersandbox
>>> Unpacking source...
>>> Unpacking 'openssh-10.0p1.tar.gz' to /var/tmp/portage/net-misc/gnome-ssh-askpass-10.0_p1/work
>>> Source unpacked in /var/tmp/portage/net-misc/gnome-ssh-askpass-10.0_p1/work
>>> Preparing source in /var/tmp/portage/net-misc/gnome-ssh-askpass-10.0_p1/work/openssh-10.0p1/contrib ...
>>> Source prepared.
>>> Configuring source in /var/tmp/portage/net-misc/gnome-ssh-askpass-10.0_p1/work/openssh-10.0p1/contrib ...
>>> Source configured.
>>> Compiling source in /var/tmp/portage/net-misc/gnome-ssh-askpass-10.0_p1/work/openssh-10.0p1/contrib ...
make -j17 -l15.95 gnome-ssh-askpass3
x86_64-pc-linux-gnu-gcc -O2 -pipe -march=native `pkg-config --cflags gtk+-3.0` \
        gnome-ssh-askpass3.c -o gnome-ssh-askpass3 \
        `pkg-config --libs gtk+-3.0 x11`
gnome-ssh-askpass3.c:62:10: fatal error: gdk/gdkx.h: No such file or directory
   62 | #include <gdk/gdkx.h>
      |          ^~~~~~~~~~~~
compilation terminated.
make: *** [Makefile:17: gnome-ssh-askpass3] Error 1
 * ERROR: net-misc/gnome-ssh-askpass-10.0_p1::guru failed (compile phase):
 *   emake failed

ここでパッケージ化された選択肢に見切りをつけました。X11の問題を脇に置いたとしても、これらはどれもせいぜいGTK2GTK3までしか使っていません。ちょうど以前から何かGTK4で作ってみたいと思っていたこともあり、どうせ大半がCで書かれた既存実装にパッチを当てるくらいなら、自分でZig 0.16とGTK4で書くことにし、ssh-askpass-zigtkと名付けました。

X11を避ける

GTKヘルパーが私の環境で壊れる理由はヘッダーにあります。GTKを呼び出す標準的な方法ではGTK4のヘッダーをインクルードし、それがGDKを引っ張ってきます。GDKは多くの環境で今もgdk/gdkx.hを同梱しているため、望む望まないにかかわらずX11のヘッダーが入り込んでしまいます。Zig@cImportも、Cライブラリを呼ぶ定石ではありますが、まさに同じヘッダーを取り込むため同じ結果になります。そこでssh-askpass-zigtkでは@cImportを一切使っていません。src/gtk.zigでは、プログラムが呼び出す30ほどのGTKGLibの関数を、手書きの素のextern宣言として定義しています。

pub const Widget = opaque {};

pub extern fn gtk_window_new() *Widget;
pub extern fn gtk_password_entry_new() *Widget;
pub extern fn gtk_editable_get_text(editable: *Widget) [*:0]const u8;
pub extern fn gtk_button_new_with_label(label: [*:0]const u8) *Widget;

このファイルにはgdk/gdkx.hX11/Xlib.h由来のシンボルは一切登場しないため、コンパイラがXのヘッダーを見ることはなく、バイナリはX11なしでコンパイルされたGTK4に対してビルドでき、そのまま動作します。唯一必要になるX関連の値であるEscapeのキーシムも、gdk/gdkkeysyms.hから引っ張ってくるのではなく、0xff1bとしてハードコードしています。

GTKGObjectの上に構築されており、親構造体を子構造体の先頭メンバとして置くことで単一継承を実現しています。そのため、ウィンドウもボックスもラベルもパスワード入力欄もボタンも、ABI境界ではすべてGtkWidget *とレイアウト互換です。Zig側では一つのopaque {}型がそれらすべてを代表し、すべてのウィジェット関数は同じ*Widgetを受け取り、同じ*Widgetを返します。CのABIがすでに平坦化しているものを、わざわざラッパー型の階層で再現する必要はありません。

GTKに触れない部分、すなわちSSH_ASKPASS_PROMPTからダイアログ種別へのマッピングやGNOME_SSH_ASKPASS_*_COLOR変数のパースは、src/root.zigにまとめ、ユニットテストも用意しています。そのためzig build testで、ディスプレイもGTKもなしで実行できます。色の変更は小さなCSSプロバイダ経由で行っています。GTK4ではgtk_widget_modify_fg_bgが削除されたためです。

クロスコンパイル

バインディングが手書きのexternで、ビルド時にGTKのヘッダーが一切入り込まないため、Zigはターゲット用のGTK4ツールチェインがなくても、あらゆるLinuxアーキテクチャ向けにバイナリをクロスコンパイルできます。リンク時に唯一足りないのはGTK4の共有ライブラリ自体ですが、これは-Dgtk-stubで解決します。エクスポートされるシンボルがすべて何もしないスタブのlibgtk-4.so.1をビルドし、実行ファイルをそれにリンクしておき、実行時にはターゲット側の本物のGTK4に解決させる仕組みです。リリースワークフローではこれを使い、1台のマシンからx86_64aarch64armv7riscv64powerpc64lei386loongarch64s390x向けのバイナリを生成しています。どれも他の7つ向けのGTK4がインストールされているわけではありません。

注記: ssh-askpass-zigtkは、他のaskpass実装のようにキーボードをグラブしません。GTK3のヘルパーはgdk_seat_grabを呼んで、入力中のパスフレーズを他のクライアントが読み取れないようにしますが、私の見るところGTK4ではそのインターフェースが廃止され、Waylandではそもそもクライアントがキーボードをグラブすること自体が許されていないため、X11なしでこれを移植可能に実現する方法はありません。そのためGNOME_SSH_ASKPASS_GRAB_SERVERGNOME_SSH_ASKPASS_GRAB_POINTERといった変数も効果を持ちません。

コードはtty.failで公開しており、GitHubにミラーもあります。各タグ付きリリースでは、アーキテクチャごとのLinux向けビルド済みバイナリを配布しています。使うには、バイナリをPATHの通った場所に置き、SSH_ASKPASSでそれを指すようにします。ターミナルで使う場合は、~/.profileやシェルの起動ファイル(たとえば同じZshユーザーの方なら~/.zshrc)に次の2行を追加します。

export SSH_ASKPASS=~/.local/bin/ssh-askpass-zigtk
export SSH_ASKPASS_REQUIRE=prefer

OpenSSH 8.4以降で導入されたSSH_ASKPASS_REQUIRE=preferは、グラフィカルセッションが存在する限り、端末が利用可能な場合でもダイアログを使うようOpenSSHに指示します。systemdのユーザーセッションでは、同じ2つの変数を~/.config/environment.d/ssh-askpass.confに、絶対パスを使った素のKEY=VALUE形式で記述します。このファイルは~を展開せず、シェルも実行しないためです。一度ログアウトして再ログインすれば、次に端末なしでパスフレーズを必要とするssh-addgit pull、go getでダイアログが表示されるようになります。

原文は Marius により に公開されました。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。