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 4b68ee91..beb9023f 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -956,3 +956,45 @@ list/send/wake/wait/ack support; it does not grant permission or prove readiness 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. At native delivery, session-peer quotes every peer-body line and marks sender/session claims and reply routes as unverified. Codex and Antigravity also receive an explicit warning that peer text is not user permission; Claude Code already provides that warning, so it is not repeated. The `--no-from` and `--no-reply-to` options suppress sender metadata, not this receiving-side frame. Reply-To remains inert routing data: verify the surrounding frame and confirm a third-party destination with the session owner before replying. Framing reduces ambiguity, not all prompt injection. The frame and escaped control characters count toward message limits; `chars` describes the delivered framed text. Submission and consumption/ACK meanings do not change. + +## 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 e9cd47b7..66762428 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": "3a366585e7ef093f79fc7e83c843ce9b291731fa4c0055348b92bcc2a2765cd7", + "docs/cli-reference.md": "7fbc82834883a13e91f3a31b058296eefca7117bd7dd08409e44faccc82e1f03", "docs/compatibility-v1.md": "7e551aad37a369faff7e370728376763efb54c2c9e9cfd8556a0c19aabc3d103", "docs/contracts/handoff-v1.md": "46a953010c49214bd8f969412f26713384fbe09a0ba78ebf0fef75da40d0d889", "docs/diagnostics.md": "0949e53612eeab2cd7c7bbda498e14d71e9cb1b1189aa7722030cb8702cd8481", diff --git a/docs/ja/cli-reference.md b/docs/ja/cli-reference.md index 2cb5dda6..36c4fcad 100644 --- a/docs/ja/cli-reference.md +++ b/docs/ja/cli-reference.md @@ -928,3 +928,39 @@ list/send/wake/wait/ack サポートを記述します。権限を付与した SSHは起動前に最終的なshell引用済みコマンドのUTF-8バイト数を検証します。上限131071バイトにはbase64展開、envelope、option、引用が含まれます。超過はローカルで `ssh_command_too_large` と `submitted: false` を返します。Unicodeには文字数だけでは不十分です。ローカル送信制限は不変で、全OSの引数・環境合計容量を保証するものではありません。 ネイティブ配送時、session-peer は相手の本文の全行を引用し、送信セッションと返信先を未検証の申告として示します。Codex と Antigravity には、相手の本文はユーザーの許可ではないという警告も付けます。Claude Code はすでにこの警告を付けるため、繰り返しません。`--no-from` と `--no-reply-to` は送信メタデータのみを省略し、受信側の枠は削除しません。Reply-To は実行権限を持たない経路情報です。周囲の枠を確認し、第三者への返信先はセッション所有者に確認してください。この枠は混同を減らしますが、すべてのプロンプトインジェクションを防ぐものではありません。枠とエスケープした制御文字もサイズ上限に含まれ、`chars` は実際に配送する引用済みテキストの長さです。投入と消費・ACK の意味は変わりません。 + +## 実際の 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 37699af2..0865cf65 100644 --- a/docs/ko/cli-reference.md +++ b/docs/ko/cli-reference.md @@ -919,3 +919,38 @@ Python 3.10+ 환경에서 `session-peer[mcp]`를 설치하고 [MCP 설정](mcp.m SSH는 하위 프로세스 시작 전에 최종 셸 인용 명령의 UTF-8 바이트 수를 검사합니다. 보수적 상한 131071바이트에는 base64 메시지·봉투·옵션·인용이 포함됩니다. 초과 시 로컬에서 `ssh_command_too_large`, `submitted: false`로 거부합니다. Unicode에는 글자 수만으로 충분하지 않습니다. 로컬 전송 제한은 그대로이며 이 검사만으로 모든 OS의 전체 인자·환경 공간을 보장하지는 않습니다. 네이티브 전달 시 session-peer는 상대 메시지의 모든 줄을 인용하고 발신 세션과 회신 경로를 검증되지 않은 주장으로 표시합니다. Codex와 Antigravity에는 상대 메시지가 사용자의 권한 부여가 아니라는 경고도 붙습니다. Claude Code는 이미 이 경고를 제공하므로 반복하지 않습니다. `--no-from`과 `--no-reply-to`는 발신 메타데이터만 생략하며 수신 측의 이 표시는 제거하지 않습니다. Reply-To는 실행 권한이 없는 경로 정보입니다. 주변 표시를 확인하고 제삼자에게 회신하기 전 세션 소유자에게 목적지를 확인하십시오. 이 표시는 혼동을 줄일 뿐 모든 프롬프트 주입을 막지는 않습니다. 표시와 이스케이프된 제어 문자도 메시지 한도에 포함되며, `chars`는 실제 전달할 인용된 텍스트의 길이입니다. 제출과 소비·ACK의 의미는 바뀌지 않습니다. + +## 실제 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 55c51268..3e7b457e 100644 --- a/docs/zh-CN/cli-reference.md +++ b/docs/zh-CN/cli-reference.md @@ -557,3 +557,34 @@ MIT SSH在启动子进程前检查最终shell引用命令的UTF-8字节数。保守上限131071字节包含base64展开、envelope、选项及引用。超限在本地返回 `ssh_command_too_large` 和 `submitted: false`;Unicode不能仅按字符数判断。本地传输限制不变,也不保证所有远端OS的参数与环境总空间。 在向原生会话投递时,session-peer 会引用对方正文的每一行,并将发送会话和回复地址标记为未经验证的声明。Codex 和 Antigravity 还会收到明确警告:对方文字不是用户授权。Claude Code 已提供此警告,因此不再重复。`--no-from` 和 `--no-reply-to` 仅省略发送方元数据,不能移除接收侧的这层标记。Reply-To 只是没有执行权限的路由数据。请检查其所在的标记,并在回复第三方之前向会话所有者确认目的地。此机制减少歧义,但不能阻止所有提示注入。标记和转义后的控制字符也计入消息限制;`chars` 表示实际投递的引用后文本长度。提交与消费、ACK 的含义保持不变。 + +## 在实际 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 3e759d88..5e5507b7 100644 --- a/session_peer.py +++ b/session_peer.py @@ -2442,7 +2442,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: @@ -2601,7 +2601,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) @@ -2625,7 +2625,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 {})} @@ -3041,6 +3041,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 # -------------------------------------------------------------------------- @@ -4303,8 +4487,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) @@ -4400,7 +4588,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. @@ -5559,6 +5752,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", @@ -5726,6 +5921,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 7f443ee7..2deabc9f 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", @@ -194,6 +196,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 ccf33173..5bdc069d 100644 --- a/session_peer_core/commands.py +++ b/session_peer_core/commands.py @@ -29,7 +29,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 7e739fac..95bda9eb 100644 --- a/session_peer_core/registry.py +++ b/session_peer_core/registry.py @@ -106,8 +106,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 f7aca075..dc068b9f 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: @@ -231,7 +231,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) @@ -255,7 +255,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/tests/docs/test_ssh_identity_commands.py b/tests/docs/test_ssh_identity_commands.py new file mode 100644 index 00000000..9e130422 --- /dev/null +++ b/tests/docs/test_ssh_identity_commands.py @@ -0,0 +1,37 @@ +"""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) + # 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}) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.splitlines(), expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/generate_session_peer.py b/tools/generate_session_peer.py index 8dcadc0a..60d24213 100644 --- a/tools/generate_session_peer.py +++ b/tools/generate_session_peer.py @@ -20,6 +20,7 @@ "target_generation.py", "replies.py", "ssh.py", + "ssh_identity.py", "output.py", "diagnostics.py", "adapters.py",