- Windows OpenSSHのデフォルトシェルがPowerShellの場合、
powershell -Command "$var = ..."で送信すると外側のPowerShellが$varを変数展開してしまい、コマンドが壊れる。セミコロン区切りの複合文も正しくパースされない - ルール: SSH経由でPowerShellスクリプトを実行する際は、スクリプトをUTF-16LEエンコード→Base64化し、
powershell -NoProfile -EncodedCommand <base64>で実行する。-Commandに直接文字列を渡さない
runRemoteCommandで送信するコマンドは、リモートのログインシェルによって一度解釈される。Windows OpenSSHではログインシェルがPowerShellの場合があり、powershell -Command "..."はPowerShell→PowerShellの二重解釈になる- ルール: リモートコマンド実行時は「ログインシェルが何か」を意識し、変数展開・クォート・特殊文字の二重解釈が起きないエンコード手法を使う
Select-String -SimpleMatch -Pattern ([regex]::Escape($pubKey))で重複チェックしたところ、[regex]::Escapeが-を\-に変換し、-SimpleMatchがそれをリテラル\-として検索するため一致しなかった。結果、鍵が何度も重複登録された- ルール:
-SimpleMatchはリテラル検索なのでエスケープ不要。$pubKeyをそのまま渡す。正規表現モードを使うなら-SimpleMatchを外して[regex]::Escape()を使う。両方同時に使わない
-EncodedCommand経由でもPowerShellのモジュール初期化時にCLIXMLプログレスメッセージ(#< CLIXML)がstdoutに出力される。出力の完全一致比較が失敗する原因になる- ルール: リモートPowerShellの出力を検証する際は、完全一致ではなく
strings.Containsで判定する。または$ProgressPreference = 'SilentlyContinue'をスクリプト先頭に追加してプログレス出力を抑制する
- テストの出力比較は
strings.Containsに修正済みだったが、useAdminKeyFile内のIsInRole/sshd_config判定はstrings.TrimSpace(output) == "True"のままだった。結果、Adminユーザーが一般ユーザーと誤判定された - ルール: PowerShellの出力を判定するコードを書く・修正する際は、プロジェクト内の全箇所を検索し、同じパターンの出力比較が残っていないか確認する。1箇所直したら他も直す
- Windows OpenSSHは
.sshディレクトリとauthorized_keysファイルの両方に正しいACLを要求する。ファイルのみにicacls /inheritance:rを適用しても、親ディレクトリのACLが不正だと認証に失敗する - ルール: Windows OpenSSHの鍵配置時は、親ディレクトリ(
$sshDir)と鍵ファイル($keyFile)の両方にicacls /inheritance:r+ ACE付与を行う
- 以前はAdmin時に
SYSTEM:(F)+Administrators:(F)のみ、一般ユーザー時にSYSTEM:(F)+${env:USERNAME}:(F)のみを付与していた。しかしAdminユーザーでもユーザー個別のACEが必要で、一般ユーザーでもAdministratorsグループのACEが必要 - ルール: ACEは常に
SYSTEM:(F)/Administrators:(F)/${env:USERNAME}:(F)の3つを付与する。isAdminで分岐しない
- icaclsの実行結果を検証していなかったため、ACL設定が失敗してもサイレントに成功扱いになっていた
- ルール: PowerShellスクリプト内で外部コマンド(icacls等)を実行した後は
$LASTEXITCODEをチェックし、失敗時はマーカー文字列を出力してGo側でハンドリングする
- Go の
x/crypto/sshは known_hosts に登録済みのアルゴリズムを無視して任意のアルゴリズムでネゴシエーションする。known_hosts にssh-ed25519しかないのにecdsa-sha2-nistp384でネゴシエーションすると「ホスト鍵が変更された」と誤検知する - OpenSSH は known_hosts のアルゴリズムだけをネゴシエーション対象にする
- ルール: Go で SSH 接続する際は known_hosts をパースして
config.HostKeyAlgorithmsを制限する。ただし鍵ローテーション時にハンドシェイクが失敗するため、制限を外してリトライするフォールバックも必要
knownhosts.New()のコールバックはハッシュ化エントリ(|1|<salt>|<hash>)を内部で処理するが、マッチングロジックは private で外部利用不可- 自前で known_hosts をパースする場合、plain-text マッチング(
h == addr)だけではハッシュ形式に対応できない - ルール: known_hosts を自前パースする場合は
|1|プレフィックスを検出し、HMAC-SHA1(salt, addr) で比較するロジックを実装する
host,ip ssh-ed25519 AAAA...のような行から特定ホストだけ除去する際、strings.Replaceで行内テキスト置換すると Base64 部分に偶然一致するリスクがある- ルール: known_hosts の行を書き換える際は
fieldsに分割してからfields[0]を再構成し、strings.Join(fields, " ")で行を組み立てる
- TOFU(初回接続)パスでは「ファイル内にハッシュエントリがあればハッシュ化」、鍵更新パスでは「置換対象がハッシュならハッシュ化」と判定基準がズレていた。Gemini レビューで発見
- ルール: known_hosts へのエントリ追加時、ハッシュ形式の判定基準は「ファイル内に任意のハッシュエントリが存在するか」で統一する。コードパスごとに異なる判定を入れない
defaultPubKeyPath()を削除したが、integration_test.go(//go:build integration)がまだ参照しており、go test -tags=integration ./...がコンパイルエラーになった。通常のgo test ./...では build tag 付きファイルがビルドされないため見逃した。Codex レビューで発見- ルール: 関数を削除・リネームする際は
go build -tags=integration ./...など全 build tag でビルドを確認する。go test ./...だけでは不十分
ssh-add -Lは鍵がない場合The agent has no identities.を返す。len(fields) >= 2だけでは誤って有効な鍵と判定してしまう。また FIDO/U2F キーはsk-ssh-ed25519,sk-ecdsa-sha2-nistp256で始まるためssh-とecdsa-だけでは不十分- ルール: ssh 公開鍵の判定は鍵タイプ文字列のプレフィックス(
ssh-,ecdsa-,sk-ssh-,sk-ecdsa-)で行う。フィールド数だけで判定しない
- 旧
readPubKeyはlen(strings.Fields(key)) >= 2のみで検証しており、複数行ファイルや base64 が壊れた.pubをそのままauthorized_keysに追記してしまう。鍵が壊れると SSH ログイン不能になる - ルール: ローカルファイル・ssh-agent 出力など外部由来の公開鍵を扱う際は
golang.org/x/crypto/ssh.ParseAuthorizedKeyで必ず検証する。フィールド数チェックや自前 base64 検証で済ませない
.pubファイルに複数鍵が並んでいたり、コピペ事故で別鍵が連結されていても、旧実装は無音で先頭部分だけ取り出して使ってしまう。意図しない鍵が配備される温床になる- ルール:
.pubファイルから鍵を読み込む際は、空白除去後の非空行を数え、0 行 → 空エラー、2 行以上 → reject エラー、1 行 →ssh.ParseAuthorizedKeyで検証、の 3 段階を必ず通す。複数鍵を黙殺せず明示拒否する
- ファイル読込(
readPubKey)と ssh-agent 取得(keyFromAgent)で別々の検証パスがあると、片方だけ厳密化しても agent 経路で壊れた鍵が抜ける - ルール: 同じ意味の検証を複数の入力経路で行うときは
validatePubKeyLineのような共通ヘルパーに集約し、すべての経路から呼ぶ。経路ごとに検証を書き分けない
validatePubKeyLineを最初strings.TrimRight(line, " \t\r\n")で実装したところ、行頭スペース付き入力(" ssh-ed25519 ...")の先頭スペースが残り、authorized_keysに書き込まれて PowerShell のSelect-String -SimpleMatch重複チェックが破綻する潜在回帰になった。自己レビューで発見- ルール: 公開鍵 1 行の正規化は
strings.TrimSpaceを使い、前後両方の空白を除去する。末尾だけ trim する関数を選ばない
golang.org/x/crypto/sshは鍵アルゴリズム交渉失敗・認証失敗時に sentinel error を公開しておらず、fmt.Errorf由来の文字列でしか区別できない。errors.Is/errors.Asは使えない- ルール: x/crypto/ssh のエラー分類が必要なときは
strings.ToLower(err.Error())後にキーワード("no common algorithm","unable to authenticate"等)を含むかで判定する。アップストリーム文言変更で破綻し得るためテストでカバーし、フォールバック挙動は保守的に「再試行しない」側に倒す
- 旧
dialSSHはHostKeyAlgorithms制限有時の任意の失敗で制限を外して再試行していた。認証失敗でもパスワードを再送するため、サーバー側ロックアウトと監査ログ汚染のリスクがあった - ルール: SSH 再試行は「成功する見込みのある失敗」だけに絞る。
shouldRetryWithoutHostKeyAlgorithms(err)のように判定をヘルパー関数に切り出してユニットテストで網羅する。unable to authenticateを含むエラーでは絶対に再試行しない
ssh.ParseAuthorizedKeyを通すには実 base64 で構成された正規の公開鍵が必要。テストごとにssh-keygen生成すると再現性がなく CI で揺れる- ルール: フォーマット検証を伴う Go テストでは正規データを
constで固定化し、複数テストで共有する。生成スクリプトに依存させない