はじめに
Windows環境でOpenSSHサーバーを構築し、TermuxやWSL、Macなどから公開鍵認証でSSH接続しようとしたとき、「なぜか公開鍵が無視され、無言でパスワード入力を求められる」 という経験はないでしょうか。
Linuxであれば ssh-copy-id コマンド一発で終わる設定が、Windowsでは数時間のトラブルシューティングに化けることがあります。しかも、エラーメッセージすら出ないケースが大半です。
この記事では、筆者自身がこの問題に数時間ハマった経験をもとに、Windows OpenSSH特有の 「4つの罠」 とその確実な解決方法をまとめます。さらに、この問題を自動化するために開発した Go言語製CLIツール「ssh-pushkey」 の開発経緯と、その過程で得られた深い技術的教訓についても詳しく書きます。
この記事は2部構成です。
- 前半(4つの罠と解決策): Windows OpenSSHで公開鍵認証が弾かれる問題を今すぐ解決したい方向け
- 後半(ビジョンと教訓): この問題を自動化するツールを開発した過程と、GoでWindows向けCLIを作る際の落とし穴を知りたい方向け
前半だけでも問題は解決できます。後半は開発者向けの読み物としてお楽しみください。
環境・前提条件
- OS: Windows 10(ビルド1809以降)/ Windows 11 / Windows Server 2019以降
- OpenSSH: Windows組み込みのOpenSSHサーバー(
sshd) - クライアント: Termux(Android)、WSL、macOSなど
- 鍵の種類: Ed25519(RSAでも同様の問題が発生します)
ハマったポイント:Windows OpenSSH「4つの罠」
罠1:PowerShellの「文字コード」の罠(BOM付きUTF-16)
Linuxの感覚で、公開鍵をサーバーに送った後に以下のようなコマンドで追記してしまうと、最初の罠にハマります。
# ❌ やってはいけない書き方
cat id_ed25519.pub >> .ssh\authorized_keys
PowerShellの >>(リダイレクト演算子)や Out-File を使用すると、デフォルトで BOM付きUTF-16LE という文字コードでファイルが作成されます。これはMicrosoft公式ドキュメントでも明記されている仕様です(参考: PowerShell の文字エンコードについて – Microsoft Learn)。
しかし、OpenSSHは BOMなしUTF-8またはASCII しか正しく読み込めません。そのため、鍵ファイルが文字化けした無効なデータとして扱われ、完全に無視されてしまいます。
補足: PowerShell 7.x以降ではデフォルトがBOMなしUTF-8に変更されていますが、Windows標準搭載のWindows PowerShell 5.1ではUTF-16LEのままです。ご自身のバージョンをご確認ください。
罠2:親フォルダの権限(NTFS ACL継承)の罠
文字コードを直しても繋がらない場合、最大の原因は NTFSのアクセス権限(ACL) です。
Windows OpenSSHは非常に厳格な権限チェックを行います。authorized_keys ファイル自体だけでなく、その親フォルダ(C:\Users\ユーザー名 など)に少しでも緩い権限が設定されていると、鍵認証を無効化します(参考: OpenSSH Server Configuration for Windows – Microsoft Learn)。
例えば、開発ツール(Cursorなど)が作成した CodexSandboxUsers などのグループや、Everyone に変更権限が付与されていると、「第三者に鍵をすり替えられるリスクがある」と判定され、サイレントに拒否 されます。エラーメッセージも表示されないため、原因の特定が非常に困難です。
この問題はGitHubのWin32-OpenSSHプロジェクトでも多くのユーザーから報告されており、長年にわたる課題として認識されています(参考: PowerShell/Win32-OpenSSH Issue #1942)。
罠3:管理者ユーザー特有の「ProgramData」の罠
接続先のWindowsユーザーが Administratorsグループ(管理者グループ) に属している場合、デフォルトの sshd_config の設定により、ユーザーフォルダの .ssh\authorized_keys ではなく、以下の特別なパスの鍵ファイルが参照されます。
C:\ProgramData\ssh\administrators_authorized_keys
これはMicrosoft公式ドキュメントにも明記されている仕様です(参考: Key-Based Authentication in OpenSSH for Windows – Microsoft Learn)。
つまり、いくら ~/.ssh/authorized_keys に正しい鍵を配置しても、管理者ユーザーの場合はそもそも 見に行く場所が違う ということです。
罠4:灯台下暗し「鍵の不一致」
権限も文字コードも完璧なのに弾かれる場合、最後に疑うべきは 「手元にある公開鍵と、サーバーに登録した公開鍵が物理的に別物になっている」 ことです。
複数のデバイスを使っていたり、鍵を再生成したりした際に混同してしまうケースは想像以上に多いです。「文字列が完全に一致しているか」を必ず目視またはハッシュ値で確認してください。
解決策
ステップ1:BOMなしUTF-8で公開鍵を書き込む(罠1の解決)
PowerShellの >> リダイレクトは使わず、.NETのクラスを利用して明示的にBOMなしUTF-8で書き込みます。
# BOMなしUTF-8で公開鍵を書き込む
$pubKey = "ssh-ed25519 AAAAC3...(あなたの公開鍵)... user@host"
$authFile = "C:\Users\ユーザー名\.ssh\authorized_keys"
# [IO.File]::WriteAllTextはデフォルトでBOMなしUTF-8を使用
[IO.File]::WriteAllText($authFile, $pubKey + "`n", [Text.Encoding]::UTF8)
ポイント:
[IO.File]::WriteAllTextは .NET Frameworkのメソッドで、PowerShellのOut-Fileとは異なり、明示的にエンコーディングを制御できます。
ステップ2:.sshフォルダとauthorized_keysの権限を厳格化する(罠2の解決)
ユーザーフォルダ全体の権限を修正するのは影響範囲が大きすぎるため、.ssh フォルダと authorized_keys ファイルの 「親からの権限継承」を断ち切り、必要な権限だけを明示的に付与します。
# .sshフォルダの継承を切り、必要なアカウントだけに権限を付与
$dotSsh = "C:\Users\ユーザー名\.ssh"
icacls $dotSsh /inheritance:r
icacls $dotSsh /grant:r "$($env:USERNAME):(OI)(CI)F"
icacls $dotSsh /grant:r "SYSTEM:(OI)(CI)F"
icacls $dotSsh /grant:r "Administrators:(OI)(CI)F"
# authorized_keysファイルも同様に設定
$authFile = "C:\Users\ユーザー名\.ssh\authorized_keys"
icacls $authFile /inheritance:r
icacls $authFile /grant:r "$($env:USERNAME):F"
icacls $authFile /grant:r "SYSTEM:F"
各オプションの意味は以下の通りです。
/inheritance:r: 親フォルダからの権限継承を削除/grant:r: 既存の権限をリセットして新しい権限を付与(OI)(CI): オブジェクト継承・コンテナ継承(フォルダ内のファイルにも適用)F: フルコントロール
ステップ3:管理者ユーザーのauthorized_keys参照先を変更する(罠3の解決)
個人開発環境などで、Linuxと同じように ~/.ssh/authorized_keys で一元管理したい場合は、設定ファイルを修正してこのルールを無効化します。
C:\ProgramData\ssh\sshd_configを管理者権限のテキストエディタで開きます。- ファイル末尾にある以下の2行をコメントアウト(
#を追記)します。
# 変更前
Match Group administrators
AuthorizedKeysFile __PROGRAMDATA__/ssh/administrators_authorized_keys
# 変更後
#Match Group administrators
# AuthorizedKeysFile __PROGRAMDATA__/ssh/administrators_authorized_keys
- PowerShell(管理者)でsshdサービスを再起動します。
Restart-Service sshd
注意: サーバーの用途によっては、
administrators_authorized_keysを使う方がセキュリティ上望ましい場合もあります。共有サーバーや本番環境では、コメントアウトではなくadministrators_authorized_keys側に鍵を正しく配置する方法もご検討ください。
ステップ4:鍵の一致を確認する(罠4の解決)
クライアント側とサーバー側で公開鍵が一致しているかを確認します。
# クライアント側(Termux / WSL / Mac)
cat ~/.ssh/id_ed25519.pub
# サーバー側(Windows PowerShell)
Get-Content C:\Users\ユーザー名\.ssh\authorized_keys
両方の出力を比較し、一文字でも違いがないか を確認してください。スペースや改行の有無も確認対象です。
動作確認
デバッグコマンドで原因を特定する
WindowsのOpenSSHが「なぜ拒否したのか」を突き止めるには、イベントビューアのログを確認するのが最も確実です。PowerShell(管理者)で以下を実行します。
# OpenSSHの直近のログを確認
Get-WinEvent -LogName "OpenSSH/Operational" -MaxEvents 10 |
Select-Object -ExpandProperty Message
ログを読む際のポイントは以下の通りです。
Bad permissionsというメッセージが出ている → 罠2(権限の問題) が原因- エラーが出ずに
Accepted passwordになっている → サイレント拒否(罠1〜3のいずれか) key not foundや認証失敗のメッセージ → 罠4(鍵の不一致) の可能性
接続テスト
すべての設定が完了したら、クライアントから接続テストを行います。
# -v オプションで詳細ログを出力
ssh -v ユーザー名@サーバーのIPアドレス
-v オプションを付けることで、鍵認証のネゴシエーション過程が表示されます。Authenticated と表示されればパスワードなしでの接続が成功です。
なぜ「ssh-pushkey」を作ったのか ― LinuxとWindowsの設計思想の断絶
ここまでの手順を見て、「毎回これをやるのか?」と思った方もいるかもしれません。筆者も同じでした。
Linuxの世界では、ssh-copy-id user@host の一行で公開鍵の登録が完結します。ファイルのパーミッションも chmod 600 で済みます。それが当たり前の世界で育った感覚からすると、Windows OpenSSHの仕組みは異質に感じます。
「POSIX的なシンプルさ」と「Windows的な厳密さ」
この違いの根本にあるのは、ファイルシステムの設計思想そのものです。
Linuxのパーミッションモデルは owner / group / other の3階層で、chmod 600 と言えば「オーナー以外読み書き不可」と一意に決まります。シンプルで予測可能です。
一方、WindowsのNTFS ACLは ACE(Access Control Entry)の積み重ね で権限を定義します。「誰に」「何の操作を」「許可/拒否」するかを個別に設定でき、さらにそれが親フォルダから子ファイルへと 継承 されます。柔軟ですが、継承の連鎖が意図しないACEを生むことがあります。
Windows OpenSSHはこの複雑なACLモデルの上で動いているにもかかわらず、LinuxのOpenSSHと同じ「厳格な権限チェック」ポリシーを踏襲 しています。つまり、「NTFS的な複雑さ」と「POSIX的な厳格さ」が同居した結果、ユーザーが踏む地雷が増えているわけです。
手作業の限界とツール化の動機
この問題を何度か手作業で解決するうちに、気づいたことがあります。
やっていること自体は単純 です。UTF-8で書き込み、ACLの継承を切り、必要なACEを付与する。ただ、それを正しくやるためのコマンドが長く、覚えにくく、一手順でも間違えると エラーメッセージなしで失敗 します。
さらに厄介なのは、これらの罠が 同時に複数重なる ことです。文字コードを直しても権限で弾かれ、権限を直しても管理者ユーザーの参照先が違い……。一つ修正するたびに「まだ繋がらない」を繰り返す体験は、控えめに言って消耗します。
であれば、これらの罠をすべて自動で回避するツールを作ればいい。そう考えて開発したのが ssh-pushkey です。
- GitHub: kwrkb/ssh-pushkey
- GitLab: kwrkb/ssh-pushkey
Go言語で書いたクロスプラットフォームのCLIツールで、Linuxの ssh-copy-id と同じ感覚で使えます。Windows特有のBOM問題、ACL設定、管理者ユーザーの分岐をすべて内部で処理します。
開発で得た教訓 ― 「罠の裏側にある罠」
ssh-pushkeyの開発は、記事本文で解説した「4つの罠」を自動化するだけの話ではありませんでした。ツールとして自動化しようとした瞬間に、手作業では見えなかった さらに深い層の罠 が次々と現れました。
ここからは、その開発過程で得た教訓を共有します。同じようなWindowsクロスプラットフォームツールを開発する方の参考になれば幸いです。
教訓1:SSH経由のPowerShellコマンドは二重解釈される
ssh-pushkeyは、クライアントからSSH経由でWindows側にPowerShellコマンドを送信する設計です。ここで最初にぶつかったのが、シェルの二重解釈問題 でした。
たとえば、powershell -Command "$var = ..." という形でコマンドを送ると、Windows OpenSSHのデフォルトシェルがPowerShellの場合、外側のPowerShellが先に $var を変数展開してしまいます。送信側が意図した文字列がリモートに届く前に壊れるのです。セミコロンで区切った複合コマンドも正しくパースされません。
解決策: スクリプト全体をUTF-16LEでエンコードしてBase64化し、powershell -NoProfile -EncodedCommand <base64> で実行する方式に切り替えました。-Command に直接文字列を渡すのは避けるべきです。
この教訓の本質は、リモートコマンド実行時は「ログインシェルが何か」を常に意識する ということです。ローカルでは動くコマンドがリモートでは壊れる原因の多くは、この二重解釈に起因しています。
教訓2:PowerShellの出力にはCLIXMLが混入する
-EncodedCommand で二重解釈問題を解決しても、次の罠が待っていました。PowerShellのモジュール初期化時に、CLIXML形式のプログレスメッセージがstdoutに混入するのです。CLIXMLとは、PowerShellがオブジェクトをシリアライズする際に使うXMLベースの形式で、#< CLIXML という文字列で始まります。これが本来のコマンド出力に紛れ込みます。
ssh-pushkeyでは、リモートPowerShellの出力を判定して処理を分岐しています。たとえば、ユーザーがAdministratorsグループに属しているかどうかを IsInRole で判定し、その結果を "True" / "False" で受け取ります。
ところが、CLIXMLが混入すると出力が #< CLIXML\r\nTrue のようになり、strings.TrimSpace(output) == "True" の完全一致比較が失敗します。AdminユーザーなのにAdminではないと誤判定され、鍵の配置先が間違ったまま処理が進みます。
解決策: 出力の判定はすべて strings.Contains で行うようにしました。もしくは、スクリプトの先頭に $ProgressPreference = 'SilentlyContinue' を追加してプログレス出力を抑制する手もあります。
ここで重要なのは、1箇所直したら他のすべての出力判定も直す ということです。筆者は最初、テストコードの判定だけ Contains に直して本体の useAdminKeyFile 関数を直し忘れ、本番で同じバグを踏みました。
教訓3:ACLは「ファイルだけ」では不十分、しかもAdmin/一般で分岐してはいけない
記事本文では「.sshフォルダとauthorized_keysの両方に権限を設定する」と書きました。これは正しいのですが、ツールとして実装する際にもう一つの落とし穴がありました。
当初の実装では、Adminユーザーと一般ユーザーでACE(Access Control Entry = 「誰に何を許可するか」を定義する個別の権限エントリ)の付与ロジックを分岐していました。
- Admin時:
SYSTEM:(F)+Administrators:(F)のみ - 一般ユーザー時:
SYSTEM:(F)+${env:USERNAME}:(F)のみ
しかし実際には、Adminユーザーでもユーザー個別のACEが必要 であり、一般ユーザーでもAdministratorsグループのACEが必要 です。ACEは常に SYSTEM:(F) / Administrators:(F) / ${env:USERNAME}:(F) の3つをセットで付与するのが正解でした。
さらに、icacls の実行結果を検証していなかったために、ACL設定が失敗してもサイレントに成功扱いになるバグもありました。外部コマンドの実行後は $LASTEXITCODE をチェックし、失敗時は明示的にエラーハンドリングする必要があります。
教訓4:Select-Stringの -SimpleMatch と [regex]::Escape() は併用してはいけない
ssh-pushkeyでは、公開鍵がすでに登録されているかどうかを Select-String で重複チェックしています。ここで、安全のためにと思って以下のように書いたのが間違いでした。
# ❌ 両方使うと壊れる
Select-String -SimpleMatch -Pattern ([regex]::Escape($pubKey))
-SimpleMatch はリテラル検索モードです。一方、[regex]::Escape() は正規表現の特殊文字をエスケープする関数で、たとえば - を \- に変換します。
両方を同時に使うと、-SimpleMatch が \- をリテラル文字列として検索するため、元の公開鍵の -(ハイフン)と一致しません。結果、「鍵が未登録」と誤判定され、同じ鍵が何度も重複登録されるバグになりました。
ルール: -SimpleMatch を使うならエスケープは不要。正規表現モードを使うなら -SimpleMatch を外して [regex]::Escape() を使う。両方同時に使わないでください。
教訓5:GoのSSHクライアントとknown_hostsの互換性問題
ssh-pushkeyはGoの x/crypto/ssh パッケージでSSH接続を行います。ここでOpenSSH互換のCLIクライアントでは起きない問題に遭遇しました。
OpenSSHのCLIクライアントは、known_hostsに登録済みのホスト鍵アルゴリズムだけをネゴシエーション対象にします。ところが、Goの x/crypto/ssh はknown_hostsのアルゴリズムを無視して、任意のアルゴリズムでネゴシエーションを試みます。
たとえば、known_hostsに ssh-ed25519 の鍵しか登録されていないのに、Goクライアントが ecdsa-sha2-nistp384 でネゴシエーションすると、「ホスト鍵が変更された」と誤検知されてしまいます。
解決策: known_hostsをパースして、登録済みのアルゴリズムで config.HostKeyAlgorithms を制限する処理を入れました。ただし、サーバー側で鍵をローテーションした場合にハンドシェイクが失敗するため、制限を外してリトライするフォールバックも必要です。
さらに、known_hostsの自前パースではハッシュ形式にも対応する必要があります。ハッシュ形式とは、ホスト名を平文で保存せずに |1|<salt>|<hash> という形式で暗号化して記録するセキュリティ機能です。プレーンテキストの host == addr マッチングだけでは不十分で、HMAC-SHA1(ハッシュベースのメッセージ認証コード)による比較ロジックの実装が必要でした。
教訓のまとめ:「自動化する」ということの意味
手作業で解決するだけなら、4つの罠を理解すれば十分です。しかし、それをツールとして自動化しようとした瞬間、手作業では見えなかった前提条件 が露出します。
- シェルの二重解釈は、人間がターミナルで打つ分には意識しない
- CLIXML混入は、人間が目で見れば無視できる
- ACLの分岐は、人間が状況に応じて判断できる
- known_hostsの互換性は、OpenSSHクライアントが暗黙的に処理する
自動化とは、人間が無意識にやっている「暗黙の判断」を、すべて明示的なコードに落とし込む作業です。その過程で、元の問題よりも深い理解が得られます。これが、ツールを作ることの隠れた価値だと筆者は考えています。
まとめ
Windows OpenSSHで公開鍵認証が弾かれる原因は、主に以下の4つです。
- 罠1(文字コード): PowerShellの
>>はBOM付きUTF-16LEで出力する →[IO.File]::WriteAllTextでBOMなしUTF-8を使う - 罠2(NTFS権限): 親フォルダから継承された緩い権限がサイレント拒否を引き起こす →
icaclsで継承を切り、必要最小限の権限を付与する - 罠3(管理者ユーザー): 管理者グループのユーザーは
administrators_authorized_keysを参照する →sshd_configのMatch Groupルールをコメントアウトする - 罠4(鍵の不一致): 複数デバイス間で鍵を取り違えている → クライアントとサーバーの鍵を目視で照合する
そして、これらを自動化するために ssh-pushkey を開発した結果、手作業では見えなかった さらに深い層の罠 ——シェルの二重解釈、CLIXML混入、ACLの統一ルール、GoとOpenSSHの挙動の違い——が見えてきました。
Linuxでは ssh-copy-id 一発で済む作業が、Windowsではこれだけの落とし穴があります。一つひとつは単純な原因ですが、複数が同時に重なると原因の特定が非常に困難になる のが厄介なところです。
この記事が、同じ問題で何時間も消耗している方の助けになれば幸いです。
この記事の前半部分(4つの罠と解決策)はZennにも掲載しています。手順だけをサクッと確認したい方はそちらもご覧ください。
ssh-pushkeyについて: 現時点ではよくあるユースケースを網羅していますが、すべてのエッジケースに対応しているわけではありません。問題やご要望があればGitHubのIssueでお知らせいただければ幸いです。
参考リンク
- OpenSSH Server Configuration for Windows – Microsoft Learn
- Key-Based Authentication in OpenSSH for Windows – Microsoft Learn
- OpenSSH Client Can’t Connect To a Server via SSH – Microsoft Learn
- PowerShell の文字エンコードについて(about_Character_Encoding)- Microsoft Learn
- PowerShell/Win32-OpenSSH Issue #1942 – GitHub
- kwrkb/ssh-pushkey – GitHub
- kwrkb/ssh-pushkey – GitLab

コメント