SSH プロトコル

このページの目次

SSH は「信頼できないネットワーク上のリモート操作」を3つの問題に分解します。どのホストに接続しているか、リモート側がどのユーザーを受け入れるか、認証済みユーザーがどのような機能を開くことができるかです。この3つの境界を理解することは、Host key verification failed、Permission denied、および転送拒否の違いを特定する上で、鍵交換の手順を暗記するよりも役立ちます。

レイヤー完了する作業成功しても保証されないこと
Transportアルゴリズムのネゴシエーション、ホスト認証、暗号化と完全性、鍵交換ユーザーがログインできること
User Authenticationユーザーの身元証明と認証ポリシーの満たすことすべてのセッション機能が認可されること
Connectionチャネルのマルチプレクシング、フロー制御、セッションおよび転送リクエストアプリケーション操作が必ず完了または永続化されること
SSHの3つの境界:信頼できるホスト、許可されたユーザー、許可された機能 Transport:アルゴリズムのネゴシエーション、鍵の交換、ホストの検証 署名は秘密鍵の所持を証明します;known_hosts / ホストCAが信頼を決定します User Authentication:ユーザーの身元証明 認証方法は双方のポリシーによって選択されます;複数の方法が必要になる場合があります Connection:チャネルを開き、具体的な機能を要求する ログイン成功が、シェル、SFTP、ポート転送のすべてが許可されることを意味しません 最初の交換ではHはセッションIDに固定されます;後続のrekeyは転送鍵を更新しますが、アイデンティティのバインディングはリセットされません。

鍵交換:共有秘密、交換要約およびホスト鍵

SSH は通常、TCP を確立してから SSH-2.0-ソフトウェア識別子 と CRLF 終端文字を送信します。バージョン行は暗号化された内容ではありません;その後、バイナリパケット交換を使用して KEXINIT を交換し、それぞれ鍵交換、ホスト鍵、および2つの方向の転送アルゴリズムをネゴシエートします。アルゴリズムリストの共通部分と順序は結果に影響を与えるため、ある OpenSSH バージョンのデフォルト値を SSH プロトコルの唯一のアルゴリズムと見なしてはいけません。

ECDH 類の交換を例に説明します:クライアントとサーバーはそれぞれ一時鍵を生成し、共有秘密 K を共通で計算します。交換要約 H は、バージョン情報、双方の KEXINIT、サーバーのホスト公開鍵、一時交換値、および K などの規定フィールドをカバーします。サーバーはホスト秘密鍵で H に署名し、クライアントは署名を検証し、これによりネゴシエーションプロセスをこのホスト鍵にバインドします。H は共有秘密の名前変更ではなく、直接トラフィック暗号化鍵として使用されるものでもありません。

最初の鍵交換の H のみがセッション ID に固定されます。 後続の rekey は新しい K、H、および転送鍵を生成しますが、セッション ID は変更されません。双方はそれぞれ NEWKEYS を送信し、対応する送信および受信方向で鍵を切り替えます。これをグローバルなスイッチとして理解してはいけません。RFC 4253 §7–9

現代の実装では、ポスト量子鍵交換のハイブリッドもサポートされています。例えば、OpenSSH 10.0 では mlkem768x25519-sha256 がデフォルトの優先候補に設定されています。これは鍵交換の変化であり、ホスト認証が自動的に同じアルゴリズムになることを意味しません。一方、ssh-rsa 署名を無効化することは通常、RSA/SHA-1 の無効化を指し、すべての RSA 公開鍵が無効になるわけではありません。同じ RSA 鍵は、双方がサポートし、許可している場合、RSA/SHA-2 署名に使用できます。OpenSSH リリースノート

署名検証が通過したのに、なぜ known_hosts が必要なのか

攻撃者も独自の鍵を生成し、独自に交換に署名することができます。署名検証は、相手が提供された公開鍵の秘密鍵を所持していることを証明するだけです。「これが接続したいホストである」ことを確認するには、ホスト名を信頼できる鍵または信頼できる発行者にバインドする必要があります。

信頼のソースバインディングの確立方法維持が必要な内容
事前配置ホスト公開鍵信頼できるチャネル経由で公開鍵を known_hosts に書き込むホストの変更、鍵交換、取り消し
初回使用信頼 (TOFU)初回の確認または受諾後に保存し、後続で比較最初に受諾した信頼の程度
OpenSSH ホスト証明書一致するホスト範囲に対して CA を信頼し、証明書制約を検証CA、プリンシパル、有効期限および取り消し
DNS SSHFP設定と検証条件を満たす場合に DNS 内のフィンガープリントを利用DNSSEC 検証チェーンおよびレコードの維持

したがって、初回の信頼は設定の配布またはホスト CA によって自動的に完了できるものであり、「SSH で自動化できない唯一のステップ」ではありません。ssh-keyscan が収集するのはネットワーク上で得られた公開鍵に過ぎず、単独では信頼できる検証を構成しません。古いレコードをクリアしても、ホスト鍵の変化を説明するものではありません。ssh ホスト認証、VerifyHostKeyDNS

OpenSSH 証明書は SSH 独自の証明書形式であり、TLS の X.509 証明書ではありません。ホスト証明書はサーバーの身元の問題に答え、ユーザー証明書はログイン者の身元の問題に答えます。あるホスト CA を信頼したからといって、その CA が発行した任意のユーザーがログイン権限を得るわけではありません。

ユーザー認証:署名リクエストと「部分的成功」

publickey 認証で署名されるデータには、セッション ID および規定のユーザー認証リクエストフィールドが含まれており、証明を現在の接続および要求された身份にバインドします。クライアントはまずサーバーが特定の公開鍵を受け入れるかどうかを問い合わせ、その後署名を送信できます。SSH_MSG_USERAUTH_PK_OK を受信することは、その候補を承認するだけであり、ログインが完了したことを意味しません。完全な成功は SSH_MSG_USERAUTH_SUCCESS によって示されます。RFC 4252 §5、7

方法またはメカニズム実際の役割
publickey対応する秘密鍵の制御を検証し、同時にこのユーザーの認可ルールを確認する
password保護された SSH 転送内でパスワードを送信;サーバーは依然としてパスワードにアクセスする
keyboard-interactive一般的なチャレンジ/レスポンス、OTP、PAM などのインタラクティブな処理を担う
ユーザー証明書公開鍵に CA 署名、プリンシパル、有効期限、制約を追加し、依然として秘密鍵による証明が必要

TOTP は、基本 SSH ユーザー認証プロトコル内で直接同名の方法ではありません。認証試行の順序もプロトコルで規定された「公開鍵、OTP、パスワード」ではありません。クライアントの優先順位、サーバーが許可する方法、および多要素ポリシーがプロセスを共同で決定します。サーバーは、ある方法が部分的に成功したが、別の方法が必要であることを報告できます。これは秘密鍵のエラーによる完全な失敗とは異なります。

OpenSSH の AuthenticationMethods publickey,keyboard-interactive は、2段階のポリシーを必要とすることを表現できますが、2番目のステップが TOTP かどうかは、サーバーのインタラクティブ認証設定に依存します。authorized_keys 内の鍵には、コマンドまたは転送の制限を付加することもできます。認可された鍵を持っているからといって、制限のないシェルが得られるわけではありません。sshd_config

バイナリパケット:境界が先にあり、メッセージが後にある

基本転送パケットには、4 バイトの packet_length、1 バイトの padding_length、ペイロード、ランダムな padding、およびアルゴリズム定義に従って処理される完全性データが含まれます。packet_length は自身の 4 バイトを含めず、従来の形式の末尾の MAC も含みません。padding は少なくとも 4 バイトです。この基本記述を、すべての AEAD または Encrypt-then-MAC アルゴリズムの暗号化範囲に機械的に適用しないでください。

以下は、⁠初期未暗号化段階の SSH_MSG_IGNORE 教育用パケットの構築例です:メッセージタイプ 1 バイト、文字列長 4 バイト、hello 5 バイト、合計 10 バイトのペイロード。8 バイトにアライメントするため、5 バイトのヘッダーと 10 バイトのペイロードには 9 バイトの padding が必要で、完全なパケットは 24 バイト、packet_length は 20 となります。

import struct
payload = bytes([2]) + struct.pack('>I', 5) + b'hello'
padding = 4
while (5 + len(payload) + padding) % 8:
    padding += 1
# 固定バイトは再現可能なエンコーディングのデモのみを目的としています;実際の送信では、要求されたランダムな padding が使用されます。
packet = struct.pack('>IB', 1 + len(payload) + padding, padding)
packet += payload + bytes(range(padding))
assert len(payload) == 10 and padding == 9
assert len(packet) == 24
assert struct.unpack('>I', packet[:4])[0] == 20
assert packet[5] == 2 and packet[10:15] == b'hello'
print('基本 SSH パケット:ペイロード 10 バイト、padding 9 バイト、総長 24 バイト')

TCP の1回の読み取りでは、パケットの一部しか取得できない場合もあれば、複数のパケットが含まれる場合もあります。受信側は、長さごとに蓄積して検証する必要があり、ソケットの読み取り回数で SSH メッセージ数を判断してはいけません。RFC 4253 §6

チャネル:独立した番号とウィンドウ、共有 TCP

session チャネルは、シェル、exec、または subsystem の要求が可能です。SFTP は subsystem の一種であるアプリケーションプロトコルです。ポート転送では、direct-tcpip または forwarded-tcpip が一般的です。これは「チャネル 0 は常にシェル、チャネル 1 は常に転送」という固定番号表ではありません。

双方はそれぞれローカルチャネル ID を割り当てます。クライアントがオープン時に 7 を使用し、サーバーが確認して 42 を割り当てた場合、クライアントが後続でサーバーに送信するデータには recipient 42 を使用し、サーバーが返信するデータには recipient 7 を使用します。番号は、対応する接続およびエンドポイント内でのみ意味を持ちます。

各方向には受信ウィンドウと最大チャネルパケットサイズがあり、送信量は両方の制限を受けます。受信側が処理してバッファを解放した後、WINDOW_ADJUST を使用して相手の送信可能額を増やします。stdout および stderr などの拡張データもチャネルウィンドウに従う必要があり、無限に蓄積することはできません。

window, max_packet, queued = 65536, 32768, 90000
sent = []
while window and queued:
    n = min(window, max_packet, queued)
    sent.append(n)
    window -= n
    queued -= n
assert sent == [32768, 32768] and queued == 24464
assert window == 0  # この時点で、この方向のウィンドウ補充を待つ必要があります。
window += 16384
n = min(window, max_packet, queued)
assert n == 16384 and queued - n == 8080
print('ウィンドウ枯渇で一時停止、WINDOW_ADJUST は対応する額のみを回復する')

独立したウィンドウにより、1つの遅いチャネルが他のチャネルのアプリケーションバッファをすべて消費する必要はありませんが、底層の TCP パケット損失によるヘッドオブラインブロックを消除することはできません。CHANNEL_EOF は、この方向ではデータが送信されなくなることを示し、逆方向は引き続き可能です。CHANNEL_CLOSE のみが、チャネル全体のクローズに関与します。コマンドの exit-status は別の種類のチャネルリクエストであり、「TCP の正常なクローズ」で業務の終了コードを置き換えることはできません。RFC 4254 §5–6

42 に送信するのに、なぜ返信は 7 を書くのか?

方向矢印が変更されると、recipient は対側が保存しているローカル番号を取得します。ウィンドウを大きくしても 32768 の単一パケット上限を超えられません。送信データを小さくしても、ウィンドウを満たす必要はありません。これら3つの量には、異なる管理者とライフサイクルがあります。

図を準備しています
42 に送信するのに、なぜ返信は 7 を書くのか?

チャネル番号は両端でそれぞれ割り当てられ、送信には対側の番号が使用されます。単回データの量は、方向ウィンドウ、最大パケット、およびキューイングされたデータの両方の制限を受けます。

転送:誰がリスンし、誰がターゲットを解決し、どこで暗号化が終了するか

ポート転送:L / R はリスン位置を指し、ターゲットは他端が接続 -L 127.0.0.1:8080:internal.example:80 SSH クライアント クライアントが :8080 をリスン SSH サーバー サーバーが internal:80 に接続 SSH 暗号化セグメント -R 127.0.0.1:3000:localhost:22 SSH クライアント クライアントが localhost:22 に接続 SSH サーバー サーバーが :3000 をリスン SSH 暗号化セグメント 矢印は転送される接続の開始方向を示します;SSH 底層接続は依然としてクライアントからサーバーへ向かいます。

以下はオンデマンドで使用するコマンドの例です;gateway.example、internal.example は独自のものに置き換えてください。

# 本体 8080 から接続し、gateway を経由して internal.example:80 に接続。
ssh -N -o ExitOnForwardFailure=yes \
  -L 127.0.0.1:8080:internal.example:80 user@gateway.example

# gateway のループバックアドレス 3000 から接続し、SSH クライアント経由で本体 22 に接続。
ssh -N -o ExitOnForwardFailure=yes \
  -R 127.0.0.1:3000:localhost:22 user@gateway.example

-L 内のターゲット名は通常、SSH サーバーによって解決され、接続されます。上記の -R 内の localhost は、SSH クライアント側です。リスンアドレスは誰が接続できるかを制限しますが、ターゲットがどちら側にあるかを決定するものではありません。リモートリスンがループバックアドレス以外にバインドできるかどうかは、サーバーの GatewayPorts などのポリシーの影響を受けます。

SSH 暗号化は SSH の両端で終了します。サーバーが internal.example:80 に再接続するセグメントは、自動的に SSH 暗号化を取得しません。ターゲットが HTTPS を実行している場合、アプリケーション TLS を使用してターゲットサービスへの保護を継続できます。-N はリモートコマンドを要求せず、ExitOnForwardFailure はリスンなどの転送確立の失敗を検出できますが、将来のターゲットへの接続がすべて成功することを保証するものではありません。ssh 転送オプション

要件メカニズム境界
固定 TCP サービス-L / -R接続のマッピングであり、完全な IP VPN ではありません
アプリケーションが複数の TCP ターゲットを選択-D ダイナミック SOCKSアプリケーションがプロキシを使用する必要があります;汎用 UDP 転送ではありません
ジャンプホスト経由でターゲットホストにログイン-J ProxyJumpターゲットホストの身元を依然として検証する必要があります
リモートでローカルの署名エージェントを呼び出すagent forwarding秘密鍵は直接コピーされませんが、リモートは認可された範囲内で署名を要求できます

ProxyJump は通常、エージェント転送をジャンプホストに必要としません。クライアントは、ジャンプホスト経由で転送された TCP チャンネルを介して、最終ターゲットとの独自の SSH 接続を確立できます。agent forwarding の役割は異なり、「秘密鍵ファイルが本体から離れていないため、リモートはアイデンティティを使用できない」と見なすことはできません。ssh エージェントとジャンプホスト

段階的に特定し、「鍵が間違っている」と一律に帰因しない

現象優先的に確認
TCP に接続できないアドレス、ポート、ルーティング、サービスリスン、および接続ポリシー
共通アルゴリズムがないエラーメッセージが属する KEX、ホスト署名、または転送アルゴリズムのカテゴリ
ホスト検証失敗ターゲット名、ポートマッピング、信頼記録、および計画された鍵交換
ユーザー認証失敗ログインユーザー名、候補アイデンティティ、サーバー側方法、および認可制約
ログイン後に機能が拒否されるsubsystem、コマンド制限、転送ポリシー、およびターゲット接続
転送が停止または接続が切断されるチャネル背圧、TCP パケット損失、アイドル状態、およびタイムアウト

コマンド解析のみを確認したい場合は、ssh -G -F /dev/null を使用して、個人設定を読まない有効なオプションを出力できます;-G は SSH セッションを確立しません。実際の接続デバッグ時には、-v または -vvv を使用して失敗した段階と照らし合わせます。保活は応答しない接続を発見するために使用しますが、業務処理の完了を証明するものではありません。再接続後でも、以前に起動したコマンドが実行されていないと仮定してはいけません。