From 8c9543e0bcbcd4d52d930170ff353f840c282692 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Fri, 9 Oct 2026 09:23:46 +0900 Subject: [PATCH 1/3] feat(ssh): bind optional host-key pins to the dispatch connection --- .github/requirements/ssh-test.txt | 2 + .github/workflows/ci.yml | 3 + docs/cli-reference.md | 42 +++++ docs/i18n-manifest.json | 2 +- docs/ja/cli-reference.md | 36 ++++ docs/ko/cli-reference.md | 35 ++++ docs/zh-CN/cli-reference.md | 31 ++++ session_peer.py | 204 +++++++++++++++++++++- session_peer_core/cli.py | 3 + session_peer_core/commands.py | 7 +- session_peer_core/registry.py | 4 + session_peer_core/ssh.py | 6 +- session_peer_core/ssh_identity.py | 182 +++++++++++++++++++ tests/core/test_ssh_identity.py | 158 +++++++++++++++++ tests/core/test_ssh_identity_handshake.py | 117 +++++++++++++ tests/docs/test_release_workflows.py | 10 ++ tools/generate_session_peer.py | 1 + 17 files changed, 834 insertions(+), 9 deletions(-) create mode 100644 .github/requirements/ssh-test.txt create mode 100644 session_peer_core/ssh_identity.py create mode 100644 tests/core/test_ssh_identity.py create mode 100644 tests/core/test_ssh_identity_handshake.py diff --git a/.github/requirements/ssh-test.txt b/.github/requirements/ssh-test.txt new file mode 100644 index 00000000..bafdc461 --- /dev/null +++ b/.github/requirements/ssh-test.txt @@ -0,0 +1,2 @@ +# Test-only in-memory SSH server; never a runtime or build dependency. +paramiko==4.0.0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8207d6e4..9019fa1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,9 @@ jobs: python-version: ${{ matrix.python }} - name: Install pinned test-only source verification dependency run: python -m pip install --requirement .github/requirements/test.txt + - name: Install optional native SSH handshake fixture + if: matrix.os != 'windows-latest' && matrix.python == '3.13' + run: python -m pip install --requirement .github/requirements/ssh-test.txt - name: Compile run: python -m compileall -q cc_peer.py session_peer.py tests - name: Generated standalone is current diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 67a6975c..75eb91af 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -877,3 +877,45 @@ list/send/wake/wait/ack support; it does not grant permission or prove readiness `install.sh` stages local and SSH runtime updates in the destination filesystem, downloads both runtime and skill first, and validates the staged CLI version before atomically replacing the runtime. Download or validation failure leaves the installed runtime, launcher and skill intact. This is not a transaction across all installation files, a signature check, or rollback after a later skill-manager failure. Existing skill-manager ownership is unchanged. SSH validates the final shell-quoted remote command as UTF-8 bytes before starting any SSH subprocess. The conservative limit is 131071 bytes, including base64 message expansion, envelope, options and quoting. Oversized commands fail locally with `ssh_command_too_large` and `submitted: false`; character-count validation alone is insufficient for Unicode. Local delivery limits are unchanged. This per-command guard cannot guarantee that every remote operating system has enough total environment/argument space. + +## Connection-bound SSH key identity (development) + +The opt-in `--ssh-identity` flag reports `sshIdentity` for `list`, `send`, +and `doctor --host`. Only the fingerprint and key algorithm come from the +actual OpenSSH host-key callback followed by a complete validated remote result. +The destination, port and key-lookup name are **preflight SSH configuration**, +not independent proof of the final network endpoint. An offered key without a +completed remote result is only `observed`, not `verified`. + +```sh +session-peer list --host workstation --ssh-identity --json +session-peer send --host workstation --to reviewer --message="Review this change" \\ + --require-ssh-host-key 'SHA256:' --json +``` + +Copy the verified fingerprint from discovery; the placeholder above is not a +valid key. The pin is checked on the **send connection**, before authentication +and remote execution, rather than by a separate probe. A changed key is refused; +key rotation requires explicit verification and a new pin. Connection reuse is +disabled in this mode and strict host-key verification stays enabled. In report +mode the user's known-hosts trust is retained; pin mode trusts only the supplied +public-key fingerprint for that connection and writes no known-hosts entry. +A server key can be reused by multiple hosts, so it is not a unique machine, +account, session-generation or message-consumption identity. + +This development path is POSIX-only and supports ordinary Ed25519, RSA and +ECDSA keys, not host certificates or security-key algorithms. It refuses a +user-configured `KnownHostsCommand` instead of silently replacing that trust +policy. Existing SSH configuration and jump-host trust remain operator-owned. +The optional second installed-version probe is omitted for identity-mode +listing because its separate connection is not covered by the receipt. Missing +receipt after a complete native response preserves that submission evidence, +never permits automatic retry, and cannot satisfy a required key pin. + +Default output and SSH behavior are unchanged without opt-in. These options +are not part of published v1.0.4. Local tests cover the native callback helper, +read-only configuration, fake transports and actual OpenSSH handshakes against +an owned loopback fixture. A mismatched key caused zero authentication and +remote-exec requests. Fixture host private keys remain in memory, never files. +This is not a real remote-host or live-agent acceptance test; platform CI must +also pass. diff --git a/docs/i18n-manifest.json b/docs/i18n-manifest.json index c974d5ad..933925b4 100644 --- a/docs/i18n-manifest.json +++ b/docs/i18n-manifest.json @@ -15,7 +15,7 @@ "docs/ai-assistant-guide.md": "9624ec96b92fb688fae3be3c94cb3564c77985091be451fbf1f056a1af9b9d9c", "docs/antigravity.md": "f2b5122a0ce6e6fb5bd4d8ed50ce2c8bd2dc9c98ec8ce328405cf7c2cd24229d", "docs/architecture/agent-transports.md": "01a4e3942e87acb476595859f2e37f9653ea1e3bc19ebc68dced1adf20a6966f", - "docs/cli-reference.md": "a4abf8ca375d058a23b3ae1d91698f95c77e59f0ebd98f623da26461e6b3e08b", + "docs/cli-reference.md": "af27829e43ba5a17aed0d155dfeb79ae900496506b185ee13d24ca86a3beebcc", "docs/compatibility-v1.md": "7e551aad37a369faff7e370728376763efb54c2c9e9cfd8556a0c19aabc3d103", "docs/diagnostics.md": "0949e53612eeab2cd7c7bbda498e14d71e9cb1b1189aa7722030cb8702cd8481", "docs/download-metrics.md": "ded60e7b84d14ce779f8d639516b4e390358a06ef76b677dc9c1f997fb626a9f", diff --git a/docs/ja/cli-reference.md b/docs/ja/cli-reference.md index 38fd4741..0a57f09e 100644 --- a/docs/ja/cli-reference.md +++ b/docs/ja/cli-reference.md @@ -857,3 +857,39 @@ list/send/wake/wait/ack サポートを記述します。権限を付与した `install.sh` はローカル・SSH更新を対象ファイルシステムに準備し、runtimeとskillを取得して一時CLIのバージョンを検証後、runtimeを原子的に置換します。取得・検証失敗時は既存runtime・launcher・skillを保持します。全ファイルのトランザクション、署名検証、後続skill-manager失敗のロールバックではありません。既存インストール管理者は変えません。 SSHは起動前に最終的なshell引用済みコマンドのUTF-8バイト数を検証します。上限131071バイトにはbase64展開、envelope、option、引用が含まれます。超過はローカルで `ssh_command_too_large` と `submitted: false` を返します。Unicodeには文字数だけでは不十分です。ローカル送信制限は不変で、全OSの引数・環境合計容量を保証するものではありません。 + +## 実際の SSH 接続でのホスト鍵確認(開発中) + +`list`、`send`、`doctor --host`に `--ssh-identity`を指定すると +`sshIdentity`を返します。指紋と鍵の種類は実際の OpenSSH の鍵確認と完全な +リモート応答に基づきます。接続先・ポート・鍵検索名は接続前の設定情報で、 +最終的なネットワーク接続先を別途証明するものではありません。応答が完了 +していない提示鍵は `observed`であり、`verified`ではありません。 + +```sh +session-peer list --host workstation --ssh-identity --json +session-peer send --host workstation --to reviewer --message="Review this change" \\ + --require-ssh-host-key 'SHA256:' --json +``` + +上の仮の値を一覧の検証済み指紋に置き換えてください。鍵は別の試験接続では +なく送信接続の認証・リモート実行前に確認します。鍵の変更は拒否し、鍵の +更新時は明示的に確認した新しい指紋が必要です。このモードでは接続再利用を +無効にし、厳密なホスト鍵検証を維持します。表示モードは既存の known-hosts +の信頼を使い、固定モードは指定した公開鍵だけを信頼し、known-hosts を +書き換えません。同じ鍵を複数のホストが共有できるため、機器・アカウント・ +セッション世代・メッセージ消費の識別や証明にはなりません。 + +開発実装は POSIX の通常の Ed25519・RSA・ECDSA 鍵だけを扱い、ホスト証明書や +セキュリティキー方式は非対応です。ユーザーの `KnownHostsCommand`を上書き +せず拒否します。SSH 設定や踏み台の信頼は運用者が管理します。別接続の +インストール済みバージョン照会はこのモードでは省略します。完全な応答の +後に鍵の確認記録がなくても提出の証拠は保持しますが、必須の固定鍵条件は +満たさず、自動再送も許可しません。 + +指定しなければ既存の動作と出力を維持します。公開 v1.0.4 には含まれません。 +ローカル試験は確認ヘルパー、読み取り専用設定、模擬転送と専用ループバック +サーバーへの実際の OpenSSH 接続を対象とします。指紋不一致では認証と +リモート実行の要求は 0 回で、試験用秘密鍵はメモリにだけ保持しました。 +実際のリモートホストや稼働中エージェントの受け入れ試験ではなく、 +プラットフォーム CI の成功も必要です。 diff --git a/docs/ko/cli-reference.md b/docs/ko/cli-reference.md index 70aa5130..2b8201a7 100644 --- a/docs/ko/cli-reference.md +++ b/docs/ko/cli-reference.md @@ -848,3 +848,38 @@ Python 3.10+ 환경에서 `session-peer[mcp]`를 설치하고 [MCP 설정](mcp.m `install.sh`는 로컬·SSH 갱신을 대상 파일시스템에서 준비하고 런타임·스킬을 모두 받은 후 임시 CLI 버전을 검증하여 런타임을 원자적으로 교체합니다. 다운로드·검증 실패 시 기존 런타임·실행기·스킬을 유지합니다. 전체 설치 파일의 트랜잭션, 서명 검증 또는 이후 스킬 관리자 실패의 롤백을 의미하지 않습니다. 기존 설치 관리자 소유권은 유지됩니다. SSH는 하위 프로세스 시작 전에 최종 셸 인용 명령의 UTF-8 바이트 수를 검사합니다. 보수적 상한 131071바이트에는 base64 메시지·봉투·옵션·인용이 포함됩니다. 초과 시 로컬에서 `ssh_command_too_large`, `submitted: false`로 거부합니다. Unicode에는 글자 수만으로 충분하지 않습니다. 로컬 전송 제한은 그대로이며 이 검사만으로 모든 OS의 전체 인자·환경 공간을 보장하지는 않습니다. + +## 실제 SSH 연결의 호스트 키 확인 (개발 중) + +`list`, `send`, `doctor --host`에 `--ssh-identity`를 추가하면 +`sshIdentity`를 반환합니다. 지문과 키 종류는 실제 OpenSSH 호스트 키 확인과 +완전한 원격 응답으로 확인합니다. 목적지·포트·키 조회 이름은 연결 전 설정 +조회 결과이며, 최종 네트워크 목적지의 별도 증명이 아닙니다. 원격 응답이 +완료되지 않은 키는 `observed`이며 `verified`로 표시하지 않습니다. + +```sh +session-peer list --host workstation --ssh-identity --json +session-peer send --host workstation --to reviewer --message="Review this change" \\ + --require-ssh-host-key 'SHA256:' --json +``` + +위 자리표시자를 발견 결과의 확인된 지문으로 바꾸세요. 지문은 별도 시험 +연결이 아니라 실제 전송 연결에서 인증·원격 실행 전에 확인합니다. 키가 +바뀌면 거부하며, 키 교체 시 직접 확인한 새 지문을 지정해야 합니다. 이 +모드는 연결 재사용을 끄고 엄격한 호스트 키 확인을 유지합니다. 조회 모드는 +기존 known-hosts 신뢰를 사용하고, 고정 모드는 지정한 공개 키만 신뢰하며 +known-hosts 파일에 기록하지 않습니다. 여러 기기가 같은 키를 쓸 수 있으므로 +기기·계정·세션 세대·메시지 소비를 증명하는 값은 아닙니다. + +개발 구현은 POSIX의 일반 Ed25519·RSA·ECDSA 키만 지원하며 호스트 인증서와 +보안 키 방식은 지원하지 않습니다. 사용자 설정의 `KnownHostsCommand`는 +덮어쓰지 않고 거부합니다. SSH 설정과 중간 경유 호스트의 신뢰는 운영자가 +관리합니다. 별도 연결인 설치 버전 보충 조회는 이 모드에서 생략합니다. +완전한 응답 뒤 확인 기록이 없으면 기존 제출 증거를 유지하지만 필수 지문 +조건은 만족하지 않으며 자동 재전송을 허용하지 않습니다. + +옵션을 쓰지 않으면 기존 동작·출력이 유지됩니다. 공개 v1.0.4에는 포함되지 +않았습니다. 로컬 검증은 확인 도우미·읽기 전용 SSH 설정·가짜 전송 경로와 +전용 루프백 서버에 대한 실제 OpenSSH 연결을 포함합니다. 잘못된 지문에서는 +인증·원격 실행 요청이 0회였으며 시험용 비밀 키는 메모리에만 두었습니다. +실제 원격 호스트나 실행 중인 에이전트의 검증은 아니며 플랫폼 CI도 필요합니다. diff --git a/docs/zh-CN/cli-reference.md b/docs/zh-CN/cli-reference.md index ef55bf8d..af2c018f 100644 --- a/docs/zh-CN/cli-reference.md +++ b/docs/zh-CN/cli-reference.md @@ -490,3 +490,34 @@ MIT `install.sh` 在目标文件系统暂存本地和SSH更新,先下载runtime和skill并验证暂存CLI版本,再原子替换runtime。下载或验证失败时保留现有runtime、launcher和skill。这不表示全部安装文件的事务、签名验证或后续skill-manager失败的回滚;原有安装管理器归属不变。 SSH在启动子进程前检查最终shell引用命令的UTF-8字节数。保守上限131071字节包含base64展开、envelope、选项及引用。超限在本地返回 `ssh_command_too_large` 和 `submitted: false`;Unicode不能仅按字符数判断。本地传输限制不变,也不保证所有远端OS的参数与环境总空间。 + +## 在实际 SSH 连接中核验主机密钥(开发中) + +为 `list`、`send`、`doctor --host`添加 `--ssh-identity`可返回 +`sshIdentity`。指纹和密钥算法来自实际 OpenSSH 密钥核验及完整的远端响应。 +目标地址、端口和密钥查询名称仅是连接前的配置,不单独证明最终网络端点。 +若远端响应未完成,所提供的密钥仅记为 `observed`,不记为 `verified`。 + +```sh +session-peer list --host workstation --ssh-identity --json +session-peer send --host workstation --to reviewer --message="Review this change" \\ + --require-ssh-host-key 'SHA256:' --json +``` + +请用发现结果中已经核验的指纹替换占位值。密钥在实际发送连接的认证和远端 +执行之前核验,而不是依靠单独的探测连接。密钥变化会被拒绝;轮换密钥时 +必须明确核验并提供新指纹。此模式禁用连接复用并保留严格的主机密钥检查。 +报告模式沿用现有 known-hosts 信任;固定模式仅信任指定的公钥,不写入 +known-hosts 文件。多台主机可共用同一密钥,因此它不证明唯一设备、账户、 +会话世代或消息消费。 + +开发实现仅支持 POSIX 的普通 Ed25519、RSA 和 ECDSA 密钥,不支持主机证书或 +安全密钥算法。已有 `KnownHostsCommand`会被拒绝,而不会被静默替换。SSH +配置与跳板机的信任仍由操作者管理。此模式省略通过另一个连接进行的安装 +版本补充查询。完整响应后若密钥记录缺失,仍保留提交证据,但无法满足必需 +的密钥固定条件,也不允许自动重发。 + +未启用时保持原有行为和输出。公开 v1.0.4 尚不包含这些选项。本地验证仅涵盖 +核验辅助程序、只读 SSH 配置、模拟传输及专用回环服务器上的实际 OpenSSH +连接。错误指纹下认证和远端执行请求均为 0 次,测试私钥仅保存在内存中。 +这不是实际远端主机或运行中代理的验收测试,平台 CI 也必须通过。 diff --git a/session_peer.py b/session_peer.py index 2d96f50d..0644cb70 100644 --- a/session_peer.py +++ b/session_peer.py @@ -2029,7 +2029,7 @@ def check_ssh_options(options: list[str]) -> None: raise CcPeerError("invalid StrictHostKeyChecking value") -SSH_METADATA_FIELDS = ("sshUser", "sshUserSource") +SSH_METADATA_FIELDS = ("sshUser", "sshUserSource", "sshIdentity") def ssh_metadata_from(payload: dict) -> dict: @@ -2152,7 +2152,7 @@ def constant_value(value): return stdout, result, bool(stdout), valid -def run_remote(host: str, argv: list[str], ssh_opts: list[str]) -> dict: +def _run_remote_dispatch(host: str, argv: list[str], ssh_opts: list[str], *, identity_options=()) -> dict: check_ssh_argument(host, "--host") check_ssh_options(ssh_opts) @@ -2176,7 +2176,7 @@ def run_remote(host: str, argv: list[str], ssh_opts: list[str]) -> dict: # result to the remote *shell*, so an argv list is not the protection it # looks like: a metacharacter in any element executes over there. Build # the remote command as one already-quoted string instead. - command = ["ssh", *ssh_opts, host, remote] + command = ["ssh", *identity_options, *ssh_opts, host, remote] def incomplete_response_error(message: str, details: dict | None = None) -> CcPeerError: metadata = {**ssh_info, **(details or {})} @@ -2592,6 +2592,190 @@ def reject_receipt(output: str | bytes | None) -> None: raise CcPeerError(f"{host}: installed version did not match {__version__}", uncertain) +# Opt-in connection identity. Native host-key verification, not remote JSON, +# supplies the fingerprint; an independent probe is not a send precondition. +SSH_FINGERPRINT_PATTERN = r"SHA256:[A-Za-z0-9+/]{43}" +SSH_IDENTITY_HELPER = r""" +import base64, hashlib, hmac, json, os, re, sys +try: + path, expected, phase = sys.argv[1:4] + if phase == "ORDER": + sys.exit(0) + if phase != "HOSTNAME" or len(sys.argv) != 6: + sys.exit(1) + kind, blob = sys.argv[4:] + if kind not in ("ssh-ed25519", "ssh-rsa", "ecdsa-sha2-nistp256", + "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521"): + sys.exit(1) + if len(blob) > 16384 or not re.fullmatch(r"[A-Za-z0-9+/]+={0,2}", blob): + sys.exit(1) + raw = base64.b64decode(blob, validate=True) + length = int.from_bytes(raw[:4], "big") + if not 1 <= length <= 64 or raw[4:4 + length].decode("ascii") != kind: + sys.exit(1) + fingerprint = "SHA256:" + base64.b64encode(hashlib.sha256(raw).digest()).decode().rstrip("=") + matches = expected == "-" or hmac.compare_digest(expected, fingerprint) + result = {"schemaVersion": 1, "algorithm": kind, "fingerprint": fingerprint, + "decision": "observed" if matches else "refused"} + with os.fdopen(os.open(path + ".new", os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600), "w") as stream: + json.dump(result, stream) + stream.flush() + os.fsync(stream.fileno()) + os.replace(path + ".new", path) + if expected != "-" and matches: + print("session-peer-pinned", kind, blob) +except Exception: + sys.exit(1) +""" + + +def validate_ssh_identity_options(args) -> None: + expected = getattr(args, "require_ssh_host_key", None) + requested = bool(expected is not None or getattr(args, "ssh_identity", False)) + if not requested: + return + if getattr(args, "command", None) not in ("list", "send", "doctor") or getattr(args, "device", None): + raise CcPeerError("SSH identity is only available for list/send/doctor --host", + {"reason": "unsupported_ssh_identity_operation", "retryAllowed": False}) + if expected is not None and not re.fullmatch(SSH_FINGERPRINT_PATTERN, expected): + raise CcPeerError("Invalid SHA256 SSH host-key fingerprint", + {"reason": "invalid_ssh_host_key", "retryAllowed": False}) + if not getattr(args, "host", None): + raise CcPeerError("SSH identity options require --host", + {"reason": "ssh_identity_requires_host", "retryAllowed": False}) + if IS_WINDOWS: + raise CcPeerError("Connection-bound SSH identity is currently POSIX-only", + {"reason": "unsupported_ssh_identity", "retryAllowed": False}) + + +def ssh_identity_configuration(host: str, ssh_opts: list[str]) -> dict: + check_ssh_argument(host, "--host") + check_ssh_options(ssh_opts) + try: + completed = subprocess.run(["ssh", "-G", *ssh_opts, host], + capture_output=True, timeout=DETECT_TIMEOUT) + output = completed.stdout + if isinstance(output, bytes): + if len(output) > 1024 * 1024: + raise ValueError("config budget") + output = output.decode("utf-8", errors="strict") + if not isinstance(output, str) or len(output.encode("utf-8")) > 1024 * 1024 or completed.returncode: + raise ValueError("config unavailable") + values = {} + for line in output.splitlines(): + key, sep, value = line.partition(" ") + if sep and key.lower() in ("hostname", "port", "hostkeyalias", "knownhostscommand"): + values[key.lower()] = value.strip() + if values.get("knownhostscommand", "none").lower() != "none": + raise CcPeerError("User-owned KnownHostsCommand cannot be replaced in identity mode", + {"reason": "unsupported_ssh_identity_config", "retryAllowed": False}) + hostname = values["hostname"] + port = values["port"] + alias = values.get("hostkeyalias", "none") + if (not hostname or len(hostname.encode("utf-8")) > 1024 + or any(ord(c) < 32 or ord(c) == 127 for c in hostname + alias) + or not re.fullmatch(r"[0-9]{1,5}", port) or not 1 <= int(port) <= 65535 + or len(alias.encode("utf-8")) > 1024): + raise ValueError("invalid config") + return {"destination": hostname, "port": int(port), + "keyLookupName": hostname if alias.lower() == "none" else alias} + except CcPeerError: + raise + except (OSError, subprocess.SubprocessError, UnicodeError, ValueError, KeyError) as exc: + raise CcPeerError("Cannot inspect SSH identity configuration", + {"reason": "ssh_identity_config_unavailable", "retryAllowed": False}) from exc + + +def ssh_key_receipt(path: Path) -> dict | None: + try: + node = path.lstat() + if (not stat.S_ISREG(node.st_mode) or node.st_size > 2048 + or node.st_uid != os.geteuid() or node.st_mode & 0o077): + return None + result = json.loads(path.read_text(encoding="utf-8")) + if (not isinstance(result, dict) or set(result) != {"schemaVersion", "algorithm", "fingerprint", "decision"} + or type(result["schemaVersion"]) is not int or result["schemaVersion"] != 1 + or result["decision"] not in ("observed", "refused") + or result["algorithm"] not in ("ssh-ed25519", "ssh-rsa", "ecdsa-sha2-nistp256", + "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521") + or not isinstance(result["fingerprint"], str) + or not re.fullmatch(SSH_FINGERPRINT_PATTERN, result["fingerprint"])): + return None + return result + except (OSError, UnicodeError, ValueError, KeyError): + return None + + +def run_remote_with_identity(host: str, argv: list[str], ssh_opts: list[str], + expected: str | None = None) -> dict: + if IS_WINDOWS: + raise CcPeerError("Connection-bound SSH identity is currently POSIX-only", + {"reason": "unsupported_ssh_identity", "retryAllowed": False}) + if expected is not None and not re.fullmatch(SSH_FINGERPRINT_PATTERN, expected): + raise CcPeerError("Invalid SHA256 SSH host-key fingerprint", + {"reason": "invalid_ssh_host_key", "retryAllowed": False}) + configuration = ssh_identity_configuration(host, ssh_opts) + with tempfile.TemporaryDirectory(prefix="session-peer-ssh-identity-") as directory: + root = Path(directory) + root.chmod(0o700) + helper = root / "known-hosts.py" + receipt = root / "receipt.json" + with os.fdopen(os.open(helper, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600), "w") as stream: + stream.write(SSH_IDENTITY_HELPER) + # OpenSSH does not shell-escape token expansions. All expanded values + # here are native enum/key-type/base64, quoted even when ORDER is empty. + parts = [sys.executable, "-I", str(helper), str(receipt), expected or "-"] + command = " ".join(shlex.quote(part.replace("%", "%%")) for part in parts) + command += " '%I' '%t' '%K'" + fixed = ["-oControlMaster=no", "-oControlPath=none", "-oControlPersist=no", + "-oStrictHostKeyChecking=yes", "-oVerifyHostKeyDNS=no", + "-oCheckHostIP=no", "-oUpdateHostKeys=no", "-oKnownHostsCommand=" + command] + if expected is not None: + fixed += ["-oUserKnownHostsFile=none", "-oGlobalKnownHostsFile=none", + "-oHostKeyAlias=session-peer-pinned"] + try: + result = _run_remote_dispatch(host, argv, ssh_opts, identity_options=fixed) + except CcPeerError as exc: + proof = ssh_key_receipt(receipt) + # An offered key is not verified authentication or remote execution. + identity = {"schemaVersion": 1, "status": "unknown", **configuration} + if proof is not None: + identity.update(algorithm=proof["algorithm"], fingerprint=proof["fingerprint"], + status="refused" if proof["decision"] == "refused" else "observed") + if (expected is not None and proof["decision"] == "refused" + and exc.details.get("submitted") is not True + and "queueId" not in exc.details and "target" not in exc.details): + # Native strict key verification precedes auth and remote + # execution. This local negative receipt is stronger than + # arbitrary remote stderr and proves this pin had no effect. + exc.details.update(status="refused", submitted=False, + reason="ssh_host_key_mismatch", retryAllowed=False) + exc.details["sshIdentity"] = identity + raise + proof = ssh_key_receipt(receipt) + valid = proof is not None and proof["decision"] == "observed" + if expected is not None: + import hmac + valid = valid and hmac.compare_digest(proof["fingerprint"], expected) + if expected is not None and not valid: + # Retain independently validated native facts, never create a + # submitted:false claim or invite a second submission. + details = {key: value for key, value in result.items() + if key not in ("ok", "error", "command", "schemaVersion")} + details.update(reason="ssh_identity_evidence_unavailable", retryAllowed=False, + sshIdentity={"schemaVersion": 1, "status": "unknown", **configuration}) + raise CcPeerError("SSH key evidence unavailable; do not automatically retry", details) + result["sshIdentity"] = {"schemaVersion": 1, "status": "verified" if valid else "unsupported", + **configuration} + if valid: + result["sshIdentity"].update(algorithm=proof["algorithm"], fingerprint=proof["fingerprint"]) + return result + + +def run_remote(host: str, argv: list[str], ssh_opts: list[str]) -> dict: + return _run_remote_dispatch(host, argv, ssh_opts) + + # -------------------------------------------------------------------------- # Output # -------------------------------------------------------------------------- @@ -3824,8 +4008,12 @@ def __init__(self, requested_host: str, args: argparse.Namespace, status: dict): self.requested_host = requested_host self.host = resolve_ssh_destination(requested_host, status) self.ssh_opts = tailscale_ssh_options(requested_host, self.host) + args.ssh_opt + self.expected_host_key = getattr(args, "require_ssh_host_key", None) + self.identity_requested = bool(self.expected_host_key is not None or getattr(args, "ssh_identity", False)) def execute(self, argv: list[str]) -> dict: + if self.identity_requested: + return run_remote_with_identity(self.requested_host, argv, self.ssh_opts, self.expected_host_key) return run_remote(self.requested_host, argv, self.ssh_opts) @@ -3917,7 +4105,12 @@ def cmd_list(args: argparse.Namespace) -> int: remote_version = None version_probe = None try: - remote_version = remote_installed_version(requested_host, ssh_opts, ssh_info) + if transport.identity_requested: + # The optional installed-version probe is another SSH + # connection and cannot reuse this verified-key receipt. + version_probe = {"status": "unsupported", "reason": "separate_identity_probe"} + else: + remote_version = remote_installed_version(requested_host, ssh_opts, ssh_info) except Exception as exc: # This second connection is advisory. Keep discovery and its # identity metadata; never copy probe process/host diagnostics. @@ -5059,6 +5252,8 @@ def build_parser() -> argparse.ArgumentParser: subparsers = parser.add_subparsers(dest="command", required=True) def add_common(sub: argparse.ArgumentParser) -> None: + sub.add_argument("--ssh-identity", action="store_true", help="report connection-verified SSH host-key identity (POSIX opt-in)") + sub.add_argument("--require-ssh-host-key", metavar="SHA256:KEY", help="require this host key on the actual SSH connection (POSIX opt-in)") sub.add_argument( "--host", action="append", default=[], metavar="DEST", help="SSH [USER@]HOST, repeatable; otherwise User comes from SSH config/default", @@ -5222,6 +5417,7 @@ def main(argv: list[str] | None = None) -> int: _SKILL_UPDATE_NOTICES = [] show_human_notice = True try: + validate_ssh_identity_options(args) exit_code = args.func(args) except CcPeerError as exc: message = str(exc) diff --git a/session_peer_core/cli.py b/session_peer_core/cli.py index 73abbc11..009844bb 100644 --- a/session_peer_core/cli.py +++ b/session_peer_core/cli.py @@ -27,6 +27,8 @@ def build_parser() -> argparse.ArgumentParser: subparsers = parser.add_subparsers(dest="command", required=True) def add_common(sub: argparse.ArgumentParser) -> None: + sub.add_argument("--ssh-identity", action="store_true", help="report connection-verified SSH host-key identity (POSIX opt-in)") + sub.add_argument("--require-ssh-host-key", metavar="SHA256:KEY", help="require this host key on the actual SSH connection (POSIX opt-in)") sub.add_argument( "--host", action="append", default=[], metavar="DEST", help="SSH [USER@]HOST, repeatable; otherwise User comes from SSH config/default", @@ -190,6 +192,7 @@ def main(argv: list[str] | None = None) -> int: _SKILL_UPDATE_NOTICES = [] show_human_notice = True try: + validate_ssh_identity_options(args) exit_code = args.func(args) except CcPeerError as exc: message = str(exc) diff --git a/session_peer_core/commands.py b/session_peer_core/commands.py index 48d49a62..1b0f0af1 100644 --- a/session_peer_core/commands.py +++ b/session_peer_core/commands.py @@ -25,7 +25,12 @@ def cmd_list(args: argparse.Namespace) -> int: remote_version = None version_probe = None try: - remote_version = remote_installed_version(requested_host, ssh_opts, ssh_info) + if transport.identity_requested: + # The optional installed-version probe is another SSH + # connection and cannot reuse this verified-key receipt. + version_probe = {"status": "unsupported", "reason": "separate_identity_probe"} + else: + remote_version = remote_installed_version(requested_host, ssh_opts, ssh_info) except Exception as exc: # This second connection is advisory. Keep discovery and its # identity metadata; never copy probe process/host diagnostics. diff --git a/session_peer_core/registry.py b/session_peer_core/registry.py index 470515d6..4b94b034 100644 --- a/session_peer_core/registry.py +++ b/session_peer_core/registry.py @@ -96,8 +96,12 @@ def __init__(self, requested_host: str, args: argparse.Namespace, status: dict): self.requested_host = requested_host self.host = resolve_ssh_destination(requested_host, status) self.ssh_opts = tailscale_ssh_options(requested_host, self.host) + args.ssh_opt + self.expected_host_key = getattr(args, "require_ssh_host_key", None) + self.identity_requested = bool(self.expected_host_key is not None or getattr(args, "ssh_identity", False)) def execute(self, argv: list[str]) -> dict: + if self.identity_requested: + return run_remote_with_identity(self.requested_host, argv, self.ssh_opts, self.expected_host_key) return run_remote(self.requested_host, argv, self.ssh_opts) diff --git a/session_peer_core/ssh.py b/session_peer_core/ssh.py index 5561c026..9caeb0c5 100644 --- a/session_peer_core/ssh.py +++ b/session_peer_core/ssh.py @@ -72,7 +72,7 @@ def check_ssh_options(options: list[str]) -> None: raise CcPeerError("invalid StrictHostKeyChecking value") -SSH_METADATA_FIELDS = ("sshUser", "sshUserSource") +SSH_METADATA_FIELDS = ("sshUser", "sshUserSource", "sshIdentity") def ssh_metadata_from(payload: dict) -> dict: @@ -195,7 +195,7 @@ def constant_value(value): return stdout, result, bool(stdout), valid -def run_remote(host: str, argv: list[str], ssh_opts: list[str]) -> dict: +def _run_remote_dispatch(host: str, argv: list[str], ssh_opts: list[str], *, identity_options=()) -> dict: check_ssh_argument(host, "--host") check_ssh_options(ssh_opts) @@ -219,7 +219,7 @@ def run_remote(host: str, argv: list[str], ssh_opts: list[str]) -> dict: # result to the remote *shell*, so an argv list is not the protection it # looks like: a metacharacter in any element executes over there. Build # the remote command as one already-quoted string instead. - command = ["ssh", *ssh_opts, host, remote] + command = ["ssh", *identity_options, *ssh_opts, host, remote] def incomplete_response_error(message: str, details: dict | None = None) -> CcPeerError: metadata = {**ssh_info, **(details or {})} diff --git a/session_peer_core/ssh_identity.py b/session_peer_core/ssh_identity.py new file mode 100644 index 00000000..2392f91d --- /dev/null +++ b/session_peer_core/ssh_identity.py @@ -0,0 +1,182 @@ +# Opt-in connection identity. Native host-key verification, not remote JSON, +# supplies the fingerprint; an independent probe is not a send precondition. +SSH_FINGERPRINT_PATTERN = r"SHA256:[A-Za-z0-9+/]{43}" +SSH_IDENTITY_HELPER = r""" +import base64, hashlib, hmac, json, os, re, sys +try: + path, expected, phase = sys.argv[1:4] + if phase == "ORDER": + sys.exit(0) + if phase != "HOSTNAME" or len(sys.argv) != 6: + sys.exit(1) + kind, blob = sys.argv[4:] + if kind not in ("ssh-ed25519", "ssh-rsa", "ecdsa-sha2-nistp256", + "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521"): + sys.exit(1) + if len(blob) > 16384 or not re.fullmatch(r"[A-Za-z0-9+/]+={0,2}", blob): + sys.exit(1) + raw = base64.b64decode(blob, validate=True) + length = int.from_bytes(raw[:4], "big") + if not 1 <= length <= 64 or raw[4:4 + length].decode("ascii") != kind: + sys.exit(1) + fingerprint = "SHA256:" + base64.b64encode(hashlib.sha256(raw).digest()).decode().rstrip("=") + matches = expected == "-" or hmac.compare_digest(expected, fingerprint) + result = {"schemaVersion": 1, "algorithm": kind, "fingerprint": fingerprint, + "decision": "observed" if matches else "refused"} + with os.fdopen(os.open(path + ".new", os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600), "w") as stream: + json.dump(result, stream) + stream.flush() + os.fsync(stream.fileno()) + os.replace(path + ".new", path) + if expected != "-" and matches: + print("session-peer-pinned", kind, blob) +except Exception: + sys.exit(1) +""" + + +def validate_ssh_identity_options(args) -> None: + expected = getattr(args, "require_ssh_host_key", None) + requested = bool(expected is not None or getattr(args, "ssh_identity", False)) + if not requested: + return + if getattr(args, "command", None) not in ("list", "send", "doctor") or getattr(args, "device", None): + raise CcPeerError("SSH identity is only available for list/send/doctor --host", + {"reason": "unsupported_ssh_identity_operation", "retryAllowed": False}) + if expected is not None and not re.fullmatch(SSH_FINGERPRINT_PATTERN, expected): + raise CcPeerError("Invalid SHA256 SSH host-key fingerprint", + {"reason": "invalid_ssh_host_key", "retryAllowed": False}) + if not getattr(args, "host", None): + raise CcPeerError("SSH identity options require --host", + {"reason": "ssh_identity_requires_host", "retryAllowed": False}) + if IS_WINDOWS: + raise CcPeerError("Connection-bound SSH identity is currently POSIX-only", + {"reason": "unsupported_ssh_identity", "retryAllowed": False}) + + +def ssh_identity_configuration(host: str, ssh_opts: list[str]) -> dict: + check_ssh_argument(host, "--host") + check_ssh_options(ssh_opts) + try: + completed = subprocess.run(["ssh", "-G", *ssh_opts, host], + capture_output=True, timeout=DETECT_TIMEOUT) + output = completed.stdout + if isinstance(output, bytes): + if len(output) > 1024 * 1024: + raise ValueError("config budget") + output = output.decode("utf-8", errors="strict") + if not isinstance(output, str) or len(output.encode("utf-8")) > 1024 * 1024 or completed.returncode: + raise ValueError("config unavailable") + values = {} + for line in output.splitlines(): + key, sep, value = line.partition(" ") + if sep and key.lower() in ("hostname", "port", "hostkeyalias", "knownhostscommand"): + values[key.lower()] = value.strip() + if values.get("knownhostscommand", "none").lower() != "none": + raise CcPeerError("User-owned KnownHostsCommand cannot be replaced in identity mode", + {"reason": "unsupported_ssh_identity_config", "retryAllowed": False}) + hostname = values["hostname"] + port = values["port"] + alias = values.get("hostkeyalias", "none") + if (not hostname or len(hostname.encode("utf-8")) > 1024 + or any(ord(c) < 32 or ord(c) == 127 for c in hostname + alias) + or not re.fullmatch(r"[0-9]{1,5}", port) or not 1 <= int(port) <= 65535 + or len(alias.encode("utf-8")) > 1024): + raise ValueError("invalid config") + return {"destination": hostname, "port": int(port), + "keyLookupName": hostname if alias.lower() == "none" else alias} + except CcPeerError: + raise + except (OSError, subprocess.SubprocessError, UnicodeError, ValueError, KeyError) as exc: + raise CcPeerError("Cannot inspect SSH identity configuration", + {"reason": "ssh_identity_config_unavailable", "retryAllowed": False}) from exc + + +def ssh_key_receipt(path: Path) -> dict | None: + try: + node = path.lstat() + if (not stat.S_ISREG(node.st_mode) or node.st_size > 2048 + or node.st_uid != os.geteuid() or node.st_mode & 0o077): + return None + result = json.loads(path.read_text(encoding="utf-8")) + if (not isinstance(result, dict) or set(result) != {"schemaVersion", "algorithm", "fingerprint", "decision"} + or type(result["schemaVersion"]) is not int or result["schemaVersion"] != 1 + or result["decision"] not in ("observed", "refused") + or result["algorithm"] not in ("ssh-ed25519", "ssh-rsa", "ecdsa-sha2-nistp256", + "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521") + or not isinstance(result["fingerprint"], str) + or not re.fullmatch(SSH_FINGERPRINT_PATTERN, result["fingerprint"])): + return None + return result + except (OSError, UnicodeError, ValueError, KeyError): + return None + + +def run_remote_with_identity(host: str, argv: list[str], ssh_opts: list[str], + expected: str | None = None) -> dict: + if IS_WINDOWS: + raise CcPeerError("Connection-bound SSH identity is currently POSIX-only", + {"reason": "unsupported_ssh_identity", "retryAllowed": False}) + if expected is not None and not re.fullmatch(SSH_FINGERPRINT_PATTERN, expected): + raise CcPeerError("Invalid SHA256 SSH host-key fingerprint", + {"reason": "invalid_ssh_host_key", "retryAllowed": False}) + configuration = ssh_identity_configuration(host, ssh_opts) + with tempfile.TemporaryDirectory(prefix="session-peer-ssh-identity-") as directory: + root = Path(directory) + root.chmod(0o700) + helper = root / "known-hosts.py" + receipt = root / "receipt.json" + with os.fdopen(os.open(helper, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600), "w") as stream: + stream.write(SSH_IDENTITY_HELPER) + # OpenSSH does not shell-escape token expansions. All expanded values + # here are native enum/key-type/base64, quoted even when ORDER is empty. + parts = [sys.executable, "-I", str(helper), str(receipt), expected or "-"] + command = " ".join(shlex.quote(part.replace("%", "%%")) for part in parts) + command += " '%I' '%t' '%K'" + fixed = ["-oControlMaster=no", "-oControlPath=none", "-oControlPersist=no", + "-oStrictHostKeyChecking=yes", "-oVerifyHostKeyDNS=no", + "-oCheckHostIP=no", "-oUpdateHostKeys=no", "-oKnownHostsCommand=" + command] + if expected is not None: + fixed += ["-oUserKnownHostsFile=none", "-oGlobalKnownHostsFile=none", + "-oHostKeyAlias=session-peer-pinned"] + try: + result = _run_remote_dispatch(host, argv, ssh_opts, identity_options=fixed) + except CcPeerError as exc: + proof = ssh_key_receipt(receipt) + # An offered key is not verified authentication or remote execution. + identity = {"schemaVersion": 1, "status": "unknown", **configuration} + if proof is not None: + identity.update(algorithm=proof["algorithm"], fingerprint=proof["fingerprint"], + status="refused" if proof["decision"] == "refused" else "observed") + if (expected is not None and proof["decision"] == "refused" + and exc.details.get("submitted") is not True + and "queueId" not in exc.details and "target" not in exc.details): + # Native strict key verification precedes auth and remote + # execution. This local negative receipt is stronger than + # arbitrary remote stderr and proves this pin had no effect. + exc.details.update(status="refused", submitted=False, + reason="ssh_host_key_mismatch", retryAllowed=False) + exc.details["sshIdentity"] = identity + raise + proof = ssh_key_receipt(receipt) + valid = proof is not None and proof["decision"] == "observed" + if expected is not None: + import hmac + valid = valid and hmac.compare_digest(proof["fingerprint"], expected) + if expected is not None and not valid: + # Retain independently validated native facts, never create a + # submitted:false claim or invite a second submission. + details = {key: value for key, value in result.items() + if key not in ("ok", "error", "command", "schemaVersion")} + details.update(reason="ssh_identity_evidence_unavailable", retryAllowed=False, + sshIdentity={"schemaVersion": 1, "status": "unknown", **configuration}) + raise CcPeerError("SSH key evidence unavailable; do not automatically retry", details) + result["sshIdentity"] = {"schemaVersion": 1, "status": "verified" if valid else "unsupported", + **configuration} + if valid: + result["sshIdentity"].update(algorithm=proof["algorithm"], fingerprint=proof["fingerprint"]) + return result + + +def run_remote(host: str, argv: list[str], ssh_opts: list[str]) -> dict: + return _run_remote_dispatch(host, argv, ssh_opts) diff --git a/tests/core/test_ssh_identity.py b/tests/core/test_ssh_identity.py new file mode 100644 index 00000000..1aeca458 --- /dev/null +++ b/tests/core/test_ssh_identity.py @@ -0,0 +1,158 @@ +"""No real hosts: native helper subprocess + fake connection/response fixtures.""" +import argparse +import base64 +import hashlib +import json +import os +from pathlib import Path +import shlex +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + +import session_peer as peer + + +@unittest.skipIf(peer.IS_WINDOWS, "POSIX KnownHostsCommand only") +class SshIdentity(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory(prefix="codex-ssh-identity-") + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + self.kind = "ssh-ed25519" + # Public-key-format synthetic bytes, not a usable private credential. + raw = len(self.kind).to_bytes(4, "big") + self.kind.encode() + b"\x00\x00\x00\x20" + b"x" * 32 + self.blob = base64.b64encode(raw).decode() + self.fingerprint = "SHA256:" + base64.b64encode(hashlib.sha256(raw).digest()).decode().rstrip("=") + self.helper = self.root / "helper.py" + self.helper.write_text(peer.SSH_IDENTITY_HELPER, encoding="utf-8") + self.receipt = self.root / "receipt.json" + self.config = {"destination": "fixture.example", "port": 2222, "keyLookupName": "fixture-key"} + + def run_helper(self, expected, phase="HOSTNAME", kind=None, blob=None): + return subprocess.run([sys.executable, "-I", str(self.helper), str(self.receipt), expected, + phase, self.kind if kind is None else kind, self.blob if blob is None else blob], + capture_output=True) + + def test_native_helper_correct_wrong_key_and_order(self): + out = self.run_helper(self.fingerprint) + self.assertEqual(out.returncode, 0) + self.assertEqual(out.stdout.decode().strip(), f"session-peer-pinned {self.kind} {self.blob}") + self.assertEqual(peer.ssh_key_receipt(self.receipt)["fingerprint"], self.fingerprint) + self.assertEqual(self.receipt.stat().st_mode & 0o777, 0o600) + out = self.run_helper("SHA256:" + "A" * 43) + self.assertEqual(out.returncode, 0) + self.assertEqual(out.stdout, b"") + self.assertEqual(peer.ssh_key_receipt(self.receipt)["decision"], "refused") + out = self.run_helper("-", phase="ORDER", kind="", blob="") + self.assertEqual(out.returncode, 0) + out = self.run_helper("-") + self.assertEqual(out.stdout, b"") + + def test_helper_rejects_unsupported_type_and_malformed_no_reflection(self): + for kind, blob in (("host-cert-v01@openssh.com", self.blob), + (self.kind, "invalid ; diagnostic"), (self.kind, "eA==")): + out = self.run_helper(self.fingerprint, kind=kind, blob=blob) + self.assertNotEqual(out.returncode, 0) + self.assertEqual(out.stdout, b"") + self.assertEqual(out.stderr, b"") + + def callback(self, result, expected=None, fail=False): + def dispatch(host, argv, opts, *, identity_options): + self.assertEqual(host, "fixture") + self.assertIn("-oControlPath=none", identity_options) + self.assertIn("-oStrictHostKeyChecking=yes", identity_options) + line = next(v.split("=", 1)[1] for v in identity_options if v.startswith("-oKnownHostsCommand=")) + self.assertTrue(line.endswith("'%I' '%t' '%K'")) + # These native expansions are enums and base64, not remote stderr. + parts = shlex.split(line) + parts[-3:] = ["HOSTNAME", self.kind, self.blob] + run = subprocess.run(parts, capture_output=True) + self.assertEqual(run.returncode, 0) + if fail: + raise peer.CcPeerError("synthetic SSH auth failure", {"sshFailure": "authentication_failed"}) + if expected: + self.assertIn("-oUserKnownHostsFile=none", identity_options) + self.assertIn("-oHostKeyAlias=session-peer-pinned", identity_options) + self.assertIn(b"session-peer-pinned", run.stdout) + return dict(result) + return dispatch + + def test_complete_success_verifies_callback_in_same_dispatch(self): + native = {"schemaVersion": 1, "command": "send", "ok": True, + "target": {"pid": 10, "name": "fixture"}, "chars": 4} + with mock.patch.object(peer, "ssh_identity_configuration", return_value=self.config), \ + mock.patch.object(peer, "_run_remote_dispatch", + side_effect=self.callback(native, self.fingerprint)) as dispatch: + result = peer.run_remote_with_identity("fixture", ["send"], [], self.fingerprint) + self.assertEqual(dispatch.call_count, 1) + self.assertEqual(result["sshIdentity"]["status"], "verified") + self.assertEqual(result["sshIdentity"]["fingerprint"], self.fingerprint) + self.assertNotIn("submitted", result) # Do not invent absent Claude fields. + + def test_callback_without_remote_success_is_only_observed(self): + with mock.patch.object(peer, "ssh_identity_configuration", return_value=self.config), \ + mock.patch.object(peer, "_run_remote_dispatch", side_effect=self.callback({}, fail=True)): + with self.assertRaises(peer.CcPeerError) as caught: + peer.run_remote_with_identity("fixture", ["send"], []) + self.assertEqual(caught.exception.details["sshIdentity"]["status"], "observed") + self.assertEqual(caught.exception.details["sshFailure"], "authentication_failed") + + def test_missing_receipt_keeps_valid_submission_facts_no_retry(self): + native = {"ok": True, "status": "queued", "submitted": True, + "queueId": "native-q", "target": {"agent": "codex", "session": "fixture"}} + with mock.patch.object(peer, "ssh_identity_configuration", return_value=self.config), \ + mock.patch.object(peer, "_run_remote_dispatch", return_value=native): + with self.assertRaises(peer.CcPeerError) as caught: + peer.run_remote_with_identity("fixture", ["send"], [], self.fingerprint) + self.assertEqual(caught.exception.details["submitted"], True) + self.assertEqual(caught.exception.details["queueId"], "native-q") + self.assertFalse(caught.exception.details["retryAllowed"]) + self.assertEqual(caught.exception.details["reason"], "ssh_identity_evidence_unavailable") + + def test_custom_known_hosts_command_unavailable_config_bounds(self): + for body, reason in (("hostname fixture\nport 22\nknownhostscommand unsafe command\n", + "unsupported_ssh_identity_config"), + ("hostname fixture\nport 99999\n", "ssh_identity_config_unavailable"), + ("hostname fixture\nport 22\n".encode() + b"\xff", "ssh_identity_config_unavailable")): + completed = subprocess.CompletedProcess([], 0, body, b"") + with mock.patch.object(peer.subprocess, "run", return_value=completed): + with self.assertRaises(peer.CcPeerError) as caught: + peer.ssh_identity_configuration("fixture", []) + self.assertEqual(caught.exception.details["reason"], reason) + + def test_public_cli_allowlist_remains_closed_and_invalid_modes_refuse(self): + for option in ("-oKnownHostsCommand=anything", "-oUserKnownHostsFile=anything", + "-oStrictHostKeyChecking=no", "-oControlPath=anything"): + with self.assertRaises(peer.CcPeerError): + peer.check_ssh_options([option]) + for args, reason in ((argparse.Namespace(ssh_identity=True, host=[], command="list"), + "ssh_identity_requires_host"), + (argparse.Namespace(ssh_identity=True, host=["fixture"], command="update"), + "unsupported_ssh_identity_operation"), + (argparse.Namespace(require_ssh_host_key="bad", host=["fixture"], command="send"), + "invalid_ssh_host_key")): + with self.assertRaises(peer.CcPeerError) as caught: + peer.validate_ssh_identity_options(args) + self.assertEqual(caught.exception.details["reason"], reason) + + def test_legacy_dispatch_unchanged_and_native_ssh_config_accepts_fixed_options(self): + with mock.patch.object(peer, "_run_remote_dispatch", return_value={"ok": True}) as dispatch: + peer.run_remote("fixture", ["list"], []) + dispatch.assert_called_once_with("fixture", ["list"], []) + # Native -G is read-only and -F /dev/null excludes personal configuration. + completed = subprocess.run(["ssh", "-G", "-F", os.devnull, + "-oControlMaster=no", "-oControlPath=none", "-oControlPersist=no", + "-oStrictHostKeyChecking=yes", "-oUserKnownHostsFile=none", + "-oGlobalKnownHostsFile=none", "-oHostKeyAlias=session-peer-pinned", + "-oKnownHostsCommand=" + sys.executable + " -I fixture '%I' '%t' '%K'", + "fixture.invalid"], capture_output=True) + self.assertEqual(completed.returncode, 0, completed.stderr.decode(errors="replace")) + self.assertIn(b"stricthostkeychecking true", completed.stdout) + + +if __name__ == "__main__": + unittest.main() + diff --git a/tests/core/test_ssh_identity_handshake.py b/tests/core/test_ssh_identity_handshake.py new file mode 100644 index 00000000..9fc90704 --- /dev/null +++ b/tests/core/test_ssh_identity_handshake.py @@ -0,0 +1,117 @@ +"""Optional native OpenSSH handshake against an owned in-memory fixture. + +No real accounts/configuration/agents. Fixture host private keys live in memory +only. Paramiko is a test-only optional dependency, never a runtime requirement. +""" +import base64 +import hashlib +import json +import os +import shutil +import socket +import threading +import unittest +from unittest import mock + +import session_peer as peer +try: + import paramiko +except ImportError: + paramiko = None + + +@unittest.skipUnless(paramiko is not None and shutil.which("ssh") and not peer.IS_WINDOWS, + "optional Paramiko + POSIX OpenSSH handshake fixture") +class NativeSshIdentity(unittest.TestCase): + def connect(self, expected): + key = paramiko.RSAKey.generate(2048) + fingerprint = "SHA256:" + base64.b64encode(hashlib.sha256(key.asbytes()).digest()).decode().rstrip("=") + required = fingerprint if expected == "correct" else "SHA256:" + "A" * 43 + listener = socket.socket() + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + listener.settimeout(15) + self.addCleanup(listener.close) + port = listener.getsockname()[1] + facts = {"auth": 0, "exec": 0, "completed": False} + errors = [] + started = threading.Event() + class Fixture(paramiko.ServerInterface): + def check_auth_none(self, username): + facts["auth"] += 1 + return paramiko.AUTH_SUCCESSFUL + def check_channel_request(self, kind, channel_id): + return paramiko.OPEN_SUCCEEDED if kind == "session" else paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED + def check_channel_exec_request(self, channel, command): + facts["exec"] += 1 + started.set() + return True + def serve(): + transport = None + try: + connection, _ = listener.accept() + with connection: + transport = paramiko.Transport(connection) + transport.add_server_key(key) + transport.start_server(server=Fixture()) + channel = transport.accept(10) + if channel is not None: + with channel: + if not started.wait(10): + raise RuntimeError("fixture exec deadline") + channel.settimeout(10) + while channel.recv(32768): + pass # Discard streamed source; NEVER execute it. + response = {"schemaVersion": 1, "command": "send", "ok": True, + "target": {"pid": 123, "name": "fixture"}, "chars": 4} + channel.sendall(json.dumps(response).encode() + b"\n") + channel.send_exit_status(0) + facts["completed"] = True + except (EOFError, paramiko.SSHException, ConnectionResetError): + pass # Expected when strict host-key pin refuses before auth. + except Exception as exc: + errors.append(type(exc).__name__) + finally: + if transport is not None: + transport.close() + worker = threading.Thread(target=serve, daemon=True) + worker.start() + original = peer._run_remote_dispatch + def isolated_dispatch(host, argv, options, *, identity_options): + fixed = ["-F", os.devnull, "-oPreferredAuthentications=none", + "-oIdentityAgent=none", "-oIdentityFile=none", "-oBatchMode=yes", + "-oConnectTimeout=5", "-oLogLevel=ERROR", *identity_options] + return original(host, argv, options, identity_options=fixed) + result = error = None + # Paramiko's expected disconnect diagnostics do not need stdout. + with mock.patch.object(peer, "ssh_identity_configuration", + return_value={"destination": "127.0.0.1", "port": port, "keyLookupName": "fixture"}), \ + mock.patch.object(peer, "ssh_user_metadata", return_value={"sshUser": "fixture", "sshUserSource": "fixture"}), \ + mock.patch.object(peer, "_run_remote_dispatch", side_effect=isolated_dispatch): + try: + result = peer.run_remote_with_identity("fixture@127.0.0.1", ["send"], ["-p", str(port)], required) + except peer.CcPeerError as exc: + error = exc + worker.join(16) + self.assertFalse(worker.is_alive(), "owned fixture did not stop") + self.assertEqual(errors, []) + return result, error, facts, fingerprint + + def test_actual_callback_same_key_and_changed_key_before_remote_exec(self): + result, error, facts, fingerprint = self.connect("correct") + self.assertIsNone(error, str(error)) + self.assertEqual(result["sshIdentity"]["fingerprint"], fingerprint) + self.assertEqual(result["sshIdentity"]["status"], "verified") + self.assertEqual(facts, {"auth": 1, "exec": 1, "completed": True}) + result, error, facts, _ = self.connect("wrong") + self.assertIsNone(result) + self.assertIsNotNone(error) + self.assertEqual(error.details["sshIdentity"]["status"], "refused") + self.assertEqual(error.details["reason"], "ssh_host_key_mismatch") + self.assertEqual(error.details["status"], "refused") + self.assertFalse(error.details["submitted"]) + self.assertEqual(facts, {"auth": 0, "exec": 0, "completed": False}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/docs/test_release_workflows.py b/tests/docs/test_release_workflows.py index 86c22fbc..db00bcb2 100644 --- a/tests/docs/test_release_workflows.py +++ b/tests/docs/test_release_workflows.py @@ -102,6 +102,16 @@ def test_core_matrix_installs_only_the_pinned_source_test_dependency(self): self.assertLess(matrix.index(install), matrix.index("- name: Distribution source tests")) self.assertLess(matrix.index(install), matrix.index("- name: Complete default discovery contract")) + def test_ssh_handshake_dependency_is_test_only_and_scoped(self): + requirements = ROOT / ".github/requirements" + declared = [line for line in (requirements / "ssh-test.txt").read_text().splitlines() + if line and not line.startswith("#")] + self.assertEqual(declared, ["paramiko==4.0.0"]) + text = (ROOT / ".github/workflows/ci.yml").read_text() + self.assertIn("if: matrix.os != 'windows-latest' && matrix.python == '3.13'", text) + self.assertIn("run: python -m pip install --requirement .github/requirements/ssh-test.txt", text) + self.assertNotIn("paramiko", (ROOT / "pyproject.toml").read_text()) + def test_every_release_checkout_uses_the_immutable_event_commit(self): for name, count in (("prepare-release.yml", 4), ("publish.yml", 6)): text = (ROOT / ".github/workflows" / name).read_text() diff --git a/tools/generate_session_peer.py b/tools/generate_session_peer.py index d2ee612f..0d8b6ce8 100644 --- a/tools/generate_session_peer.py +++ b/tools/generate_session_peer.py @@ -19,6 +19,7 @@ "claude.py", "replies.py", "ssh.py", + "ssh_identity.py", "output.py", "diagnostics.py", "adapters.py", From 7fef9288ad752458791f7709527d742d333030b8 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Fri, 9 Oct 2026 09:37:49 +0900 Subject: [PATCH 2/3] docs(ssh): verify localized host-key command continuation --- docs/cli-reference.md | 2 +- docs/i18n-manifest.json | 2 +- docs/ja/cli-reference.md | 2 +- docs/ko/cli-reference.md | 2 +- docs/zh-CN/cli-reference.md | 2 +- tests/docs/test_ssh_identity_commands.py | 34 ++++++++++++++++++++++++ 6 files changed, 39 insertions(+), 5 deletions(-) create mode 100644 tests/docs/test_ssh_identity_commands.py diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 75eb91af..e67b1f5d 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -889,7 +889,7 @@ completed remote result is only `observed`, not `verified`. ```sh session-peer list --host workstation --ssh-identity --json -session-peer send --host workstation --to reviewer --message="Review this change" \\ +session-peer send --host workstation --to reviewer --message="Review this change" \ --require-ssh-host-key 'SHA256:' --json ``` diff --git a/docs/i18n-manifest.json b/docs/i18n-manifest.json index 933925b4..4a901725 100644 --- a/docs/i18n-manifest.json +++ b/docs/i18n-manifest.json @@ -15,7 +15,7 @@ "docs/ai-assistant-guide.md": "9624ec96b92fb688fae3be3c94cb3564c77985091be451fbf1f056a1af9b9d9c", "docs/antigravity.md": "f2b5122a0ce6e6fb5bd4d8ed50ce2c8bd2dc9c98ec8ce328405cf7c2cd24229d", "docs/architecture/agent-transports.md": "01a4e3942e87acb476595859f2e37f9653ea1e3bc19ebc68dced1adf20a6966f", - "docs/cli-reference.md": "af27829e43ba5a17aed0d155dfeb79ae900496506b185ee13d24ca86a3beebcc", + "docs/cli-reference.md": "254533b6f1c1d717f3da0ae61a6a5be558ad093335daf67dce15a651081961ee", "docs/compatibility-v1.md": "7e551aad37a369faff7e370728376763efb54c2c9e9cfd8556a0c19aabc3d103", "docs/diagnostics.md": "0949e53612eeab2cd7c7bbda498e14d71e9cb1b1189aa7722030cb8702cd8481", "docs/download-metrics.md": "ded60e7b84d14ce779f8d639516b4e390358a06ef76b677dc9c1f997fb626a9f", diff --git a/docs/ja/cli-reference.md b/docs/ja/cli-reference.md index 0a57f09e..befc9b2f 100644 --- a/docs/ja/cli-reference.md +++ b/docs/ja/cli-reference.md @@ -868,7 +868,7 @@ SSHは起動前に最終的なshell引用済みコマンドのUTF-8バイト数 ```sh session-peer list --host workstation --ssh-identity --json -session-peer send --host workstation --to reviewer --message="Review this change" \\ +session-peer send --host workstation --to reviewer --message="Review this change" \ --require-ssh-host-key 'SHA256:' --json ``` diff --git a/docs/ko/cli-reference.md b/docs/ko/cli-reference.md index 2b8201a7..490854e5 100644 --- a/docs/ko/cli-reference.md +++ b/docs/ko/cli-reference.md @@ -859,7 +859,7 @@ SSH는 하위 프로세스 시작 전에 최종 셸 인용 명령의 UTF-8 바 ```sh session-peer list --host workstation --ssh-identity --json -session-peer send --host workstation --to reviewer --message="Review this change" \\ +session-peer send --host workstation --to reviewer --message="Review this change" \ --require-ssh-host-key 'SHA256:' --json ``` diff --git a/docs/zh-CN/cli-reference.md b/docs/zh-CN/cli-reference.md index af2c018f..41871eb9 100644 --- a/docs/zh-CN/cli-reference.md +++ b/docs/zh-CN/cli-reference.md @@ -500,7 +500,7 @@ SSH在启动子进程前检查最终shell引用命令的UTF-8字节数。保守 ```sh session-peer list --host workstation --ssh-identity --json -session-peer send --host workstation --to reviewer --message="Review this change" \\ +session-peer send --host workstation --to reviewer --message="Review this change" \ --require-ssh-host-key 'SHA256:' --json ``` diff --git a/tests/docs/test_ssh_identity_commands.py b/tests/docs/test_ssh_identity_commands.py new file mode 100644 index 00000000..62c455d0 --- /dev/null +++ b/tests/docs/test_ssh_identity_commands.py @@ -0,0 +1,34 @@ +"""Run SSH identity examples against a shell function, never a real client.""" +import os +from pathlib import Path +import re +import subprocess +import unittest + + +ROOT = Path(__file__).resolve().parents[2] + + +@unittest.skipUnless(os.name == "posix", "POSIX documented shell examples") +class SshIdentityCommands(unittest.TestCase): + def test_all_locales_preserve_send_argument_boundaries(self): + expected = ["send", "--host", "workstation", "--to", "reviewer", + "--message=Review this change", "--require-ssh-host-key", + "SHA256:", "--json"] + for locale in ("", "ko/", "ja/", "zh-CN/"): + with self.subTest(locale=locale or "en"): + source = (ROOT / "docs" / locale / "cli-reference.md").read_text(encoding="utf-8") + example = re.search(r"^session-peer send --host workstation.*?\n```", source, + re.MULTILINE | re.DOTALL) + self.assertIsNotNone(example) + block = example.group(0).removesuffix("```") + harness = 'session-peer() { printf "%s\\n" "$@"; }\n' + result = subprocess.run(["/bin/sh", "-c", harness + block], + capture_output=True, text=True, + env={"PATH": os.defpath}) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.splitlines(), expected) + + +if __name__ == "__main__": + unittest.main() From cc07af69ff0dfe796298c8386c4375e5696e5dfa Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Fri, 9 Oct 2026 09:38:16 +0900 Subject: [PATCH 3/3] test(docs): intercept examples using a POSIX function identifier --- tests/docs/test_ssh_identity_commands.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/docs/test_ssh_identity_commands.py b/tests/docs/test_ssh_identity_commands.py index 62c455d0..9e130422 100644 --- a/tests/docs/test_ssh_identity_commands.py +++ b/tests/docs/test_ssh_identity_commands.py @@ -21,8 +21,11 @@ def test_all_locales_preserve_send_argument_boundaries(self): example = re.search(r"^session-peer send --host workstation.*?\n```", source, re.MULTILINE | re.DOTALL) self.assertIsNotNone(example) - block = example.group(0).removesuffix("```") - harness = 'session-peer() { printf "%s\\n" "$@"; }\n' + # POSIX function identifiers cannot contain a hyphen. Replace + # only the command word; shell argument/continuation syntax is + # still executed exactly as documented, with no client access. + block = example.group(0).removesuffix("```").replace("session-peer ", "capture ", 1) + harness = 'capture() { printf "%s\\n" "$@"; }\n' result = subprocess.run(["/bin/sh", "-c", harness + block], capture_output=True, text=True, env={"PATH": os.defpath})