ssh-over-sse-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,131 @@
1
+ Metadata-Version: 2.5
2
+ Name: ssh-over-sse-cli
3
+ Version: 0.1.0
4
+ Summary: OpenSSH ProxyCommand client for ssh-sse/v1
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: cryptography>=45
7
+ Requires-Dist: httpx<1,>=0.28
8
+ Requires-Dist: pyyaml<7,>=6
9
+ Description-Content-Type: text/markdown
10
+
11
+ # ssh-sse-cli
12
+
13
+ Connect to SSH servers through an HTTPS gateway with your usual OpenSSH commands.
14
+
15
+ ## Install
16
+
17
+ Requires Linux or macOS, Python 3.12 or newer, and OpenSSH.
18
+
19
+ With uv:
20
+
21
+ ```sh
22
+ uv tool install --python 3.12 ssh-over-sse-cli
23
+ ```
24
+
25
+ Or with pip:
26
+
27
+ ```sh
28
+ pip install 'ssh-over-sse[cli]'
29
+ ```
30
+
31
+ Use `ssh-over-sse[all]` with pip to install the gateway command too.
32
+ Supported keys are Ed25519, RSA (2048–8192 bits), and ECDSA (P-256, P-384, P-521).
33
+ Load encrypted keys into `ssh-agent` before use.
34
+
35
+ ## Configure SSH
36
+
37
+ Ask your administrator for the gateway URL and an allowed destination. Add an
38
+ entry to your SSH configuration, replacing the example values:
39
+
40
+ ```sshconfig
41
+ Host my-server
42
+ HostName 192.0.2.20
43
+ Port 22
44
+ User alice
45
+ IdentityFile ~/.ssh/id_ed25519
46
+ IdentitiesOnly yes
47
+ StrictHostKeyChecking yes
48
+ ProxyCommand ssh-sse-cli --url https://gateway.example.com --ssh-host %n
49
+ ```
50
+
51
+ Connect or transfer files normally:
52
+
53
+ ```sh
54
+ ssh my-server
55
+ scp report.txt my-server:report.txt
56
+ sftp my-server
57
+ ```
58
+
59
+ The client reads the destination, port, SSH user, and first available identity
60
+ from the selected host. `--target`, `--user`, and `-i` override those values.
61
+ If you use `ssh -F`, pass the same file using `--ssh-config` in the ProxyCommand.
62
+ To pass outer command-line user or port overrides, add `--user %r --target %h:%p`.
63
+
64
+ Your public key must be authorized by the gateway account and destination server.
65
+ Your private key stays on your computer. Keep SSH host-key verification enabled.
66
+
67
+ ## Check your key
68
+
69
+ Open the gateway URL in a browser to view the landing page. Then run:
70
+
71
+ ```sh
72
+ ssh-sse-cli --url https://gateway.example.com check
73
+ ```
74
+
75
+ Without a host alias, the identity defaults to `~/.ssh/id_ed25519`. To select a
76
+ key or use an existing SSH host's identity:
77
+
78
+ ```sh
79
+ ssh-sse-cli --url https://gateway.example.com -i ~/.ssh/id_ecdsa check
80
+ ssh-sse-cli --url https://gateway.example.com --ssh-host my-server check
81
+ ```
82
+
83
+ The check requires no destination or SSH user. It verifies gateway access without
84
+ connecting to a destination. Success prints
85
+ `Gateway SSH key authentication succeeded.` and exits with status 0. Errors exit
86
+ with status 1. HTTP 401 means the key was rejected; HTTP 500 indicates a gateway
87
+ or server error.
88
+
89
+ For a direct backend check, use its HTTP address:
90
+
91
+ ```sh
92
+ ssh-sse-cli --url http://192.0.2.10:7010 -i ~/.ssh/id_ecdsa check
93
+ ```
94
+
95
+ SSH connections use the public HTTPS URL. A successful key check does not confirm
96
+ destination permissions or the destination's SSH login.
97
+
98
+ ## Options
99
+
100
+ | Option | Purpose |
101
+ | --- | --- |
102
+ | `--url` | Gateway root URL |
103
+ | `--ssh-host` | Read connection and identity settings from an SSH host alias |
104
+ | `--ssh-config` | Read an alternate SSH configuration file |
105
+ | `--target` | Override the destination as `HOST:PORT`; bracket IPv6 addresses |
106
+ | `--user` | Override the destination SSH username |
107
+ | `-i`, `--identity` | Select an identity file |
108
+ | `--config` | Read client settings from a file |
109
+ | `--ca-file` | Trust a private HTTPS certificate authority |
110
+ | `--log-level` | Set diagnostics to `DEBUG`, `INFO`, `WARNING`, or `ERROR` |
111
+
112
+ Client settings default to `~/.config/ssh-over-sse/client.yaml` when that file
113
+ exists. Store your gateway URL there to omit `--url`:
114
+
115
+ ```yaml
116
+ url: https://gateway.example.com
117
+ ```
118
+
119
+ Command-line options take precedence. Connection diagnostics appear on stderr;
120
+ `check` prints its success message on stdout.
121
+
122
+ ## Troubleshooting
123
+
124
+ If authentication fails, check the URL, selected key, and gateway authorization.
125
+ For encrypted keys, check that the identity is loaded into `ssh-agent`. Ask your
126
+ administrator to confirm destination permissions if the key check succeeds but
127
+ SSH cannot connect. After an interruption, start a new connection.
128
+
129
+ ```sh
130
+ ssh-sse-cli --help
131
+ ```
@@ -0,0 +1,12 @@
1
+ ssh_sse_tool/__init__.py,sha256=yEo61nlPcB0HIPTn9MtlKWVxnZkOmbJnEvH0aI-L8OQ,40
2
+ ssh_sse_tool/auth.py,sha256=u_j3DMvebsfIWNSoWqkXtLeBDvfQCUXgWBimxrfY-bQ,2609
3
+ ssh_sse_tool/cli.py,sha256=C6AB86o2y3XDMKQ6lt17UNjIFh1BBWJfzTQ58kVFHyA,2558
4
+ ssh_sse_tool/config.py,sha256=zeBamyb4UO2GdFRMMi6BxaApGIyBXxIMdC63e1NdP_U,8915
5
+ ssh_sse_tool/protocol.py,sha256=GlY3t-rF4zK0Dy7RxeFqpLDgikjDsh3QlY5aaLZaMA8,1290
6
+ ssh_sse_tool/sse.py,sha256=iP69t9XzPgj_suqIq68VdT-n2wDhq78gC-iwryB0Eg8,3320
7
+ ssh_sse_tool/stdio.py,sha256=1ZtTzLib7ngXuQRoTkn2L8uyYS9KbAZB6YdBQRz-eP4,1515
8
+ ssh_sse_tool/transport.py,sha256=u4ZBudzn8zdWDlKKlspcUvMmCuc8LF7oI-u1qQjA7m0,11189
9
+ ssh_over_sse_cli-0.1.0.dist-info/METADATA,sha256=b47afg0-kYRBfveLbERxHf3R_MBErSjMeqGjlrGHrEk,4040
10
+ ssh_over_sse_cli-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
11
+ ssh_over_sse_cli-0.1.0.dist-info/entry_points.txt,sha256=wO_cvxhdFSS0MHg0sjy8koJDvVwSLUjkVhx7V1mRsQk,54
12
+ ssh_over_sse_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ssh-sse-cli = ssh_sse_tool.cli:main
@@ -0,0 +1 @@
1
+ """SSH-over-SSE ProxyCommand client."""
ssh_sse_tool/auth.py ADDED
@@ -0,0 +1,65 @@
1
+ """OpenSSH SSHSIG signer, including public-key path + ssh-agent support."""
2
+
3
+ import asyncio
4
+ import base64
5
+ import hashlib
6
+ from pathlib import Path
7
+
8
+ from cryptography.exceptions import UnsupportedAlgorithm
9
+ from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey
10
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
11
+ from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
12
+ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat, load_ssh_public_key
13
+
14
+
15
+ async def command(*args, data=None):
16
+ process = await asyncio.create_subprocess_exec(
17
+ *args,
18
+ stdin=asyncio.subprocess.PIPE if data is not None else asyncio.subprocess.DEVNULL,
19
+ stdout=asyncio.subprocess.PIPE,
20
+ stderr=asyncio.subprocess.DEVNULL,
21
+ )
22
+ try:
23
+ stdout, _ = await asyncio.wait_for(process.communicate(data), 10)
24
+ if process.returncode:
25
+ raise ValueError("SSH key operation failed; load your identity into ssh-agent")
26
+ return stdout
27
+ finally:
28
+ if process.returncode is None:
29
+ process.kill()
30
+ await process.wait()
31
+
32
+
33
+ async def identity_key(identity):
34
+ path = Path(identity)
35
+ pub = path if path.suffix == ".pub" else Path(str(path) + ".pub")
36
+ if pub.is_file():
37
+ if pub.stat().st_size > 8192:
38
+ raise ValueError("public key too large")
39
+ line = pub.read_bytes().strip()
40
+ else:
41
+ line = (await command("ssh-keygen", "-y", "-f", str(path))).strip()
42
+ try:
43
+ key = load_ssh_public_key(line)
44
+ except UnsupportedAlgorithm:
45
+ raise ValueError("unsupported SSH public key") from None
46
+ if not isinstance(key, (Ed25519PublicKey, RSAPublicKey, EllipticCurvePublicKey)):
47
+ raise ValueError("unsupported SSH public key")
48
+ if isinstance(key, RSAPublicKey) and not 2048 <= key.key_size <= 8192:
49
+ raise ValueError("RSA keys must be 2048 to 8192 bits")
50
+ if isinstance(key, EllipticCurvePublicKey) and key.curve.name not in (
51
+ "secp256r1",
52
+ "secp384r1",
53
+ "secp521r1",
54
+ ):
55
+ raise ValueError("unsupported ECDSA curve")
56
+ normalized = key.public_bytes(Encoding.OpenSSH, PublicFormat.OpenSSH).decode()
57
+ blob = base64.b64decode(normalized.split()[1], validate=True)
58
+ fp = "SHA256:" + base64.b64encode(hashlib.sha256(blob).digest()).decode().rstrip("=")
59
+ return normalized, fp
60
+
61
+
62
+ async def sign(identity, namespace, message):
63
+ return base64.b64encode(
64
+ await command("ssh-keygen", "-Y", "sign", "-f", identity, "-n", namespace, data=message)
65
+ ).decode()
ssh_sse_tool/cli.py ADDED
@@ -0,0 +1,86 @@
1
+ """Local OpenSSH ProxyCommand entrypoint. stdout is raw SSH bytes only."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import logging
6
+ import signal
7
+ import sys
8
+
9
+ import httpx
10
+ import yaml
11
+
12
+ from .config import settings
13
+ from .stdio import Stdio
14
+ from .transport import GatewayError, Transport
15
+
16
+
17
+ async def run(config):
18
+ stdio = Stdio()
19
+ task = asyncio.current_task()
20
+ assert task is not None
21
+ loop = asyncio.get_running_loop()
22
+ for sig in (signal.SIGINT, signal.SIGTERM):
23
+ loop.add_signal_handler(sig, task.cancel)
24
+ try:
25
+ await Transport(config).run(stdio)
26
+ finally:
27
+ stdio.close()
28
+ for sig in (signal.SIGINT, signal.SIGTERM):
29
+ loop.remove_signal_handler(sig)
30
+
31
+
32
+ def main():
33
+ parser = argparse.ArgumentParser(
34
+ prog="ssh-sse-cli", description="OpenSSH ProxyCommand over HTTPS POST + SSE"
35
+ )
36
+ parser.add_argument(
37
+ "command",
38
+ nargs="?",
39
+ choices=["check"],
40
+ help="verify gateway access with your SSH key without connecting to a target",
41
+ )
42
+ for name in (
43
+ "url",
44
+ "target",
45
+ "user",
46
+ "config",
47
+ "log-level",
48
+ "ca-file",
49
+ "ssh-host",
50
+ "ssh-config",
51
+ ):
52
+ parser.add_argument("--" + name)
53
+ parser.add_argument("-i", "--identity", help="SSH identity file")
54
+ parser.add_argument(
55
+ "--test-only-allow-http-localhost", action="store_true", help=argparse.SUPPRESS
56
+ )
57
+ args = parser.parse_args()
58
+ try:
59
+ config = settings(args)
60
+ # HTTP-library debug logs can contain authorization headers: never enable them.
61
+ logging.basicConfig(level=getattr(logging, config.log_level), stream=sys.stderr)
62
+ logging.getLogger("httpx").setLevel(logging.CRITICAL)
63
+ logging.getLogger("httpcore").setLevel(logging.CRITICAL)
64
+ if args.command == "check":
65
+ asyncio.run(Transport(config).check())
66
+ print("Gateway SSH key authentication succeeded.")
67
+ else:
68
+ asyncio.run(run(config))
69
+ except GatewayError as error:
70
+ parser.exit(1, f"ssh-sse-cli: {error}\n")
71
+ except (
72
+ ValueError,
73
+ OSError,
74
+ TimeoutError,
75
+ TypeError,
76
+ KeyError,
77
+ RecursionError,
78
+ yaml.YAMLError,
79
+ httpx.HTTPError,
80
+ ):
81
+ parser.exit(
82
+ 1,
83
+ "ssh-sse-cli: authentication or transport failed; check configuration, key agent and gateway audit logs\n",
84
+ )
85
+ except (KeyboardInterrupt, asyncio.CancelledError):
86
+ parser.exit(130, "ssh-sse-cli: interrupted\n")
ssh_sse_tool/config.py ADDED
@@ -0,0 +1,242 @@
1
+ """Client arguments/config validation, with no production TLS bypass."""
2
+
3
+ import getpass
4
+ import ipaddress
5
+ import os
6
+ import re
7
+ import socket
8
+ import subprocess
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from urllib.parse import urlsplit
12
+
13
+ import yaml
14
+
15
+
16
+ class UniqueLoader(yaml.SafeLoader):
17
+ pass
18
+
19
+
20
+ def unique_mapping(loader, node):
21
+ result = {}
22
+ for k, v in node.value:
23
+ key = loader.construct_object(k)
24
+ if not isinstance(key, str) or key in result:
25
+ raise ValueError("duplicate config key")
26
+ result[key] = loader.construct_object(v)
27
+ return result
28
+
29
+
30
+ UniqueLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, unique_mapping)
31
+
32
+
33
+ def target(value):
34
+ if not isinstance(value, str) or any(c.isspace() for c in value):
35
+ raise ValueError("invalid target")
36
+ pattern = r"\[([^\]]+)\]:([0-9]+)" if value.startswith("[") else r"([^:\[\]]+):([0-9]+)"
37
+ match = re.fullmatch(pattern, value)
38
+ if not match or not 1 <= int(match[2]) <= 65535:
39
+ raise ValueError("target must be HOST:PORT (bracket IPv6)")
40
+ host = match[1]
41
+ try:
42
+ host = str(ipaddress.ip_address(host))
43
+ except ValueError:
44
+ if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}", host):
45
+ raise ValueError("invalid target host") from None
46
+ return host, int(match[2])
47
+
48
+
49
+ def base_url(value, test_only_http=False, *, allow_http_check=False):
50
+ if not isinstance(value, str) or any(c.isspace() for c in value):
51
+ raise ValueError("invalid gateway URL")
52
+ parsed = urlsplit(value)
53
+ if not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment:
54
+ raise ValueError("invalid gateway URL")
55
+ if parsed.scheme != "https":
56
+ if (not test_only_http and not allow_http_check) or parsed.scheme != "http":
57
+ raise ValueError("HTTPS is required")
58
+ try:
59
+ loopback = ipaddress.ip_address(parsed.hostname).is_loopback
60
+ except ValueError:
61
+ loopback = parsed.hostname == "localhost"
62
+ if not loopback and not allow_http_check:
63
+ raise ValueError("test HTTP requires loopback")
64
+ if not re.fullmatch(r"(?:/[a-zA-Z0-9_-]+)*/?", parsed.path):
65
+ raise ValueError("invalid base URL path")
66
+ _ = parsed.port # validate port syntax/range
67
+ return value.rstrip("/")
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class Settings:
72
+ url: str
73
+ host: str
74
+ port: int
75
+ user: str
76
+ principal: str | None
77
+ identity: str
78
+ namespace: str = "ssh-sse-gateway@your-organization.example"
79
+ ca_file: str | None = None
80
+ request_timeout: float = 30
81
+ stream_timeout: float = 75
82
+ log_level: str = "WARNING"
83
+
84
+
85
+ def ssh_defaults(alias, config_file=None, user=None):
86
+ """Read effective local OpenSSH configuration without opening a connection."""
87
+ if not isinstance(alias, str) or not re.fullmatch(r"[A-Za-z0-9_.:@\[\]-]{1,253}", alias):
88
+ raise ValueError("invalid SSH host alias")
89
+ command = ["ssh", "-G"]
90
+ if config_file is not None:
91
+ if not isinstance(config_file, str):
92
+ raise ValueError("invalid SSH config path")
93
+ command.extend(["-F", str(Path(config_file).expanduser())])
94
+ if user is not None:
95
+ if not isinstance(user, str) or not re.fullmatch(r"[a-zA-Z0-9_@.+-]{1,128}", user):
96
+ raise ValueError("invalid SSH user")
97
+ command.extend(["-l", user])
98
+ command.extend(["--", alias])
99
+ try:
100
+ result = subprocess.run(
101
+ command,
102
+ stdin=subprocess.DEVNULL,
103
+ stdout=subprocess.PIPE,
104
+ stderr=subprocess.DEVNULL,
105
+ text=True,
106
+ timeout=5,
107
+ check=False,
108
+ )
109
+ except (OSError, subprocess.SubprocessError):
110
+ raise ValueError("cannot read effective SSH configuration") from None
111
+ if result.returncode or len(result.stdout) > 1048576:
112
+ raise ValueError("cannot read effective SSH configuration")
113
+ values: dict[str, str] = {}
114
+ identities = []
115
+ for line in result.stdout.splitlines():
116
+ name, separator, value = line.partition(" ")
117
+ if not separator:
118
+ continue
119
+ if name == "identityfile":
120
+ identities.append(value)
121
+ elif name in ("hostname", "port", "user"):
122
+ values.setdefault(name, value)
123
+ if set(values) != {"hostname", "port", "user"}:
124
+ raise ValueError("incomplete effective SSH configuration")
125
+ hostname = values["hostname"]
126
+ tokens = {
127
+ "%": "%",
128
+ "d": str(Path.home()),
129
+ "h": hostname,
130
+ "n": alias,
131
+ "r": values["user"],
132
+ "p": values["port"],
133
+ "u": getpass.getuser(),
134
+ "i": str(os.getuid()),
135
+ "l": socket.gethostname(),
136
+ }
137
+ for identity in identities:
138
+ if identity.lower() == "none":
139
+ continue
140
+
141
+ def expand(match):
142
+ if match[1] not in tokens:
143
+ raise ValueError("unsupported IdentityFile token; specify --identity")
144
+ return tokens[match[1]]
145
+
146
+ path = Path(os.path.expandvars(re.sub(r"%(.)", expand, identity))).expanduser()
147
+ if path.is_file():
148
+ values["identity"] = str(path)
149
+ break
150
+ public = Path(str(path) + ".pub")
151
+ if public.is_file():
152
+ values["identity"] = str(public)
153
+ break
154
+ host = f"[{hostname}]" if ":" in hostname else hostname
155
+ values["target"] = host + ":" + values["port"]
156
+ return values
157
+
158
+
159
+ def settings(args):
160
+ raw = {}
161
+ default_config = Path.home() / ".config/ssh-over-sse/client.yaml"
162
+ config_path = args.config or (str(default_config) if default_config.is_file() else None)
163
+ if config_path:
164
+ path = Path(config_path).expanduser()
165
+ if path.stat().st_size > 65536:
166
+ raise ValueError("config too large")
167
+ raw = yaml.load(path.read_text(), Loader=UniqueLoader)
168
+ allowed = {
169
+ "url",
170
+ "target",
171
+ "user",
172
+ "principal",
173
+ "identity",
174
+ "namespace",
175
+ "ca_file",
176
+ "request_timeout",
177
+ "stream_timeout",
178
+ "log_level",
179
+ "ssh_host",
180
+ "ssh_config",
181
+ }
182
+ if not isinstance(raw, dict) or set(raw) - allowed:
183
+ raise ValueError("invalid client config")
184
+ for key in (
185
+ "url",
186
+ "target",
187
+ "user",
188
+ "identity",
189
+ "log_level",
190
+ "ca_file",
191
+ "ssh_host",
192
+ "ssh_config",
193
+ ):
194
+ value = getattr(args, key, None)
195
+ if value is not None:
196
+ raw[key] = value
197
+ alias = raw.pop("ssh_host", None)
198
+ ssh_config = raw.pop("ssh_config", None)
199
+ if alias is not None:
200
+ defaults = ssh_defaults(alias, ssh_config, raw.get("user"))
201
+ for key in ("target", "user", "identity"):
202
+ if key in defaults:
203
+ raw.setdefault(key, defaults[key])
204
+ if "identity" not in raw:
205
+ raise ValueError("no usable IdentityFile for this host; specify --identity")
206
+ elif ssh_config is not None:
207
+ raise ValueError("--ssh-config requires --ssh-host")
208
+ if getattr(args, "command", None) == "check":
209
+ # Settings retains its transport fields; check proofs carry no destination.
210
+ raw.setdefault("target", "localhost:22")
211
+ raw.setdefault("user", getpass.getuser())
212
+ raw.setdefault("identity", "~/.ssh/id_ed25519")
213
+ for key in ("url", "target", "user", "identity"):
214
+ if not isinstance(raw.get(key), str) or not raw[key]:
215
+ raise ValueError(f"{key} is required")
216
+ host, port = target(raw.pop("target"))
217
+ raw["url"] = base_url(
218
+ raw["url"],
219
+ args.test_only_allow_http_localhost,
220
+ allow_http_check=getattr(args, "command", None) == "check",
221
+ )
222
+ raw.setdefault("principal", None)
223
+ for key in ("user", "principal"):
224
+ if key == "principal" and raw[key] is None:
225
+ continue
226
+ if not isinstance(raw[key], str) or not re.fullmatch(r"[a-zA-Z0-9_@.+-]{1,128}", raw[key]):
227
+ raise ValueError("invalid user/principal")
228
+ for key in ("request_timeout", "stream_timeout"):
229
+ if key in raw and (type(raw[key]) not in (int, float) or not 0 < raw[key] <= 604800):
230
+ raise ValueError("invalid timeout")
231
+ if "namespace" in raw and (
232
+ not isinstance(raw["namespace"], str) or not re.fullmatch(r"[^\s]{1,255}", raw["namespace"])
233
+ ):
234
+ raise ValueError("invalid namespace")
235
+ if raw.get("log_level", "WARNING") not in ("DEBUG", "INFO", "WARNING", "ERROR"):
236
+ raise ValueError("invalid log level")
237
+ if "ca_file" in raw and not isinstance(raw["ca_file"], str):
238
+ raise ValueError("invalid CA file")
239
+ raw["identity"] = str(Path(raw["identity"]).expanduser().resolve())
240
+ if raw.get("ca_file"):
241
+ raw["ca_file"] = str(Path(raw["ca_file"]).expanduser())
242
+ return Settings(host=host, port=port, **raw)
@@ -0,0 +1,48 @@
1
+ """Canonical authentication transcript contract for ssh-sse/v1."""
2
+
3
+ import json
4
+
5
+ PROTOCOL = "ssh-sse/v1"
6
+ TRANSCRIPT_FIELDS = (
7
+ "protocol",
8
+ "purpose",
9
+ "audience",
10
+ "namespace",
11
+ "challenge_id",
12
+ "nonce",
13
+ "principal",
14
+ "target_host",
15
+ "target_port",
16
+ "ssh_user",
17
+ "expires_at",
18
+ "public_key_fingerprint",
19
+ )
20
+
21
+
22
+ def transcript(challenge, request, *, check=False):
23
+ data = {"purpose": "ssh-sse-gateway/check/v1" if check else "ssh-sse-gateway/v1"}
24
+ for key in TRANSCRIPT_FIELDS:
25
+ if key in challenge:
26
+ data[key] = challenge[key]
27
+ elif key in request:
28
+ data[key] = request[key]
29
+ if set(data) != set(TRANSCRIPT_FIELDS) or data["protocol"] != PROTOCOL:
30
+ raise ValueError("invalid transcript")
31
+ return json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode(
32
+ "ascii"
33
+ )
34
+
35
+
36
+ def strict_json(data):
37
+ def unique(pairs):
38
+ result = {}
39
+ for key, value in pairs:
40
+ if key in result:
41
+ raise ValueError("duplicate JSON field")
42
+ result[key] = value
43
+ return result
44
+
45
+ def constant(value):
46
+ raise ValueError("invalid JSON constant")
47
+
48
+ return json.loads(data, object_pairs_hook=unique, parse_constant=constant)
ssh_sse_tool/sse.py ADDED
@@ -0,0 +1,87 @@
1
+ """Incremental bounded SSE decoder with strict binary sequence validation."""
2
+
3
+ import base64
4
+ import binascii
5
+
6
+ from .protocol import strict_json
7
+
8
+
9
+ class SSE:
10
+ def __init__(self, max_chunk=16384):
11
+ self.buffer = bytearray()
12
+ self.fields = {}
13
+ self.event_size = 0
14
+ self.seq = 0
15
+ self.eof = False
16
+ self.max_chunk = max_chunk
17
+ self.max_event = max_chunk * 2 + 1024
18
+
19
+ def feed(self, chunk):
20
+ if self.eof and chunk:
21
+ raise ValueError("bytes after EOF event")
22
+ self.buffer.extend(chunk)
23
+ result = []
24
+ while b"\n" in self.buffer:
25
+ raw, _, rest = self.buffer.partition(b"\n")
26
+ self.buffer = bytearray(rest)
27
+ if len(raw) > self.max_event:
28
+ raise ValueError("oversized SSE line")
29
+ line = raw.rstrip(b"\r").decode("utf-8", errors="strict")
30
+ self.event_size += len(raw) + 1
31
+ if self.event_size > self.max_event:
32
+ raise ValueError("oversized SSE event")
33
+ if not line:
34
+ if self.fields:
35
+ result.append(self.event())
36
+ self.fields = {}
37
+ self.event_size = 0
38
+ elif line.startswith(":"):
39
+ continue
40
+ else:
41
+ name, sep, value = line.partition(":")
42
+ if not sep or name not in ("event", "id", "data"):
43
+ raise ValueError("invalid SSE field")
44
+ if value.startswith(" "):
45
+ value = value[1:]
46
+ if name in self.fields:
47
+ if name != "data":
48
+ raise ValueError("duplicate SSE field")
49
+ self.fields[name] += "\n" + value
50
+ else:
51
+ self.fields[name] = value
52
+ if len(self.buffer) > self.max_event:
53
+ raise ValueError("oversized SSE line")
54
+ return result
55
+
56
+ def event(self):
57
+ if self.eof:
58
+ raise ValueError("event after EOF")
59
+ kind = self.fields.get("event")
60
+ data = strict_json(self.fields.get("data", ""))
61
+ if kind == "data":
62
+ if (
63
+ set(self.fields) != {"event", "id", "data"}
64
+ or not isinstance(data, dict)
65
+ or set(data) != {"seq", "payload_b64"}
66
+ or type(data["seq"]) is not int
67
+ or data["seq"] != self.seq
68
+ or self.fields["id"] != str(self.seq)
69
+ or not isinstance(data["payload_b64"], str)
70
+ ):
71
+ raise ValueError("invalid SSE sequence")
72
+ try:
73
+ decoded = base64.b64decode(data["payload_b64"], validate=True)
74
+ except binascii.Error:
75
+ raise ValueError("invalid SSE Base64") from None
76
+ if not 0 < len(decoded) <= self.max_chunk:
77
+ raise ValueError("invalid SSE payload size")
78
+ self.seq += 1
79
+ return decoded
80
+ if kind == "eof" and set(self.fields) == {"event", "data"} and data == {}:
81
+ self.eof = True
82
+ return None
83
+ raise ValueError("gateway transport error or unexpected SSE event")
84
+
85
+ def finish(self):
86
+ if self.buffer or self.fields or not self.eof:
87
+ raise ValueError("SSE interrupted before explicit EOF")
ssh_sse_tool/stdio.py ADDED
@@ -0,0 +1,54 @@
1
+ """Cancelable bounded nonblocking pipe I/O for OpenSSH ProxyCommand."""
2
+
3
+ import asyncio
4
+ import os
5
+
6
+
7
+ async def ready(fd, write=False):
8
+ loop = asyncio.get_running_loop()
9
+ future = loop.create_future()
10
+
11
+ def wake():
12
+ if not future.done():
13
+ future.set_result(None)
14
+
15
+ add = loop.add_writer if write else loop.add_reader
16
+ remove = loop.remove_writer if write else loop.remove_reader
17
+ add(fd, wake)
18
+ try:
19
+ await future
20
+ finally:
21
+ remove(fd)
22
+
23
+
24
+ class Stdio:
25
+ def __init__(self, input_fd=0, output_fd=1):
26
+ self.input_fd, self.output_fd = input_fd, output_fd
27
+ self.previous = {fd: os.get_blocking(fd) for fd in (input_fd, output_fd)}
28
+ for fd in self.previous:
29
+ os.set_blocking(fd, False)
30
+
31
+ async def read(self, size):
32
+ while True:
33
+ try:
34
+ return os.read(self.input_fd, size)
35
+ except BlockingIOError:
36
+ await ready(self.input_fd)
37
+
38
+ async def write(self, data):
39
+ view = memoryview(data)
40
+ while view:
41
+ try:
42
+ count = os.write(self.output_fd, view)
43
+ if not count:
44
+ raise BrokenPipeError()
45
+ view = view[count:]
46
+ except BlockingIOError:
47
+ await ready(self.output_fd, write=True)
48
+
49
+ def close(self):
50
+ for fd, blocking in self.previous.items():
51
+ try:
52
+ os.set_blocking(fd, blocking)
53
+ except OSError:
54
+ pass
@@ -0,0 +1,274 @@
1
+ """One input POST at a time; no retries, resume, or redirects."""
2
+
3
+ import asyncio
4
+ import re
5
+ import ssl
6
+ import time
7
+ from urllib.parse import urlsplit
8
+
9
+ import httpx
10
+
11
+ from .auth import identity_key, sign
12
+ from .config import base_url
13
+ from .protocol import PROTOCOL, strict_json, transcript
14
+ from .sse import SSE
15
+
16
+
17
+ class GatewayError(ValueError):
18
+ """Sanitized gateway status, without response bodies or credentials."""
19
+
20
+
21
+ def json_response(response):
22
+ if response.status_code >= 300:
23
+ # Only expose stable codes, never server response bodies or tokens.
24
+ code = "GATEWAY_ERROR"
25
+ try:
26
+ candidate = strict_json(response.content).get("code")
27
+ if isinstance(candidate, str) and re.fullmatch(r"[A-Z][A-Z0-9_]{0,62}", candidate):
28
+ code = candidate
29
+ except (ValueError, AttributeError):
30
+ pass
31
+ raise GatewayError(f"{code} (HTTP {response.status_code})")
32
+ if len(response.content) > 16384:
33
+ raise ValueError("oversized gateway control response")
34
+ return strict_json(response.content)
35
+
36
+
37
+ def require_identity_encoding(response):
38
+ if response.headers.get("content-encoding", "identity").strip().lower() != "identity":
39
+ raise ValueError("compressed gateway responses are not supported")
40
+
41
+
42
+ async def control_response(client, method, url, **kwargs):
43
+ # Streaming prevents a malicious gateway from forcing an unbounded control read.
44
+ timeout = kwargs.get("timeout", client.timeout)
45
+ deadline = timeout.read if isinstance(timeout, httpx.Timeout) else timeout
46
+ async with asyncio.timeout(30 if deadline is None else deadline):
47
+ async with client.stream(method, url, **kwargs) as response:
48
+ require_identity_encoding(response)
49
+ content = bytearray()
50
+ async for chunk in response.aiter_bytes():
51
+ if len(content) + len(chunk) > 16384:
52
+ raise ValueError("oversized gateway control response")
53
+ content.extend(chunk)
54
+ if response.status_code >= 300:
55
+ json_response(httpx.Response(response.status_code, content=bytes(content)))
56
+ if response.headers.get("content-type", "").split(";")[0] != "application/json":
57
+ raise ValueError("invalid control response type")
58
+ return json_response(httpx.Response(response.status_code, content=bytes(content)))
59
+
60
+
61
+ class Transport:
62
+ def __init__(self, settings):
63
+ self.settings = settings
64
+ self.path = settings.url + "/v1"
65
+ self.session_path = None
66
+ self.headers = {}
67
+ self.remote_eof = False
68
+ self.input_task: asyncio.Task | None = None
69
+
70
+ async def authenticate(self, client, *, check=False):
71
+ s = self.settings
72
+ public, fp = await identity_key(s.identity)
73
+ challenge = await control_response(
74
+ client,
75
+ "POST",
76
+ self.path + "/challenges",
77
+ json={"principal": s.principal or "", "public_key": public},
78
+ )
79
+ expected = {
80
+ "protocol",
81
+ "audience",
82
+ "namespace",
83
+ "challenge_id",
84
+ "nonce",
85
+ "expires_at",
86
+ "principal",
87
+ "public_key_fingerprint",
88
+ }
89
+ if (
90
+ not isinstance(challenge, dict)
91
+ or set(challenge) != expected
92
+ or challenge["protocol"] != PROTOCOL
93
+ or (
94
+ challenge["audience"] != s.url
95
+ and not (
96
+ check
97
+ and s.url.startswith("http://")
98
+ and isinstance(challenge["audience"], str)
99
+ and base_url(challenge["audience"]) == challenge["audience"]
100
+ )
101
+ )
102
+ or challenge["namespace"] != s.namespace
103
+ or not isinstance(challenge["principal"], str)
104
+ or not re.fullmatch(r"[a-zA-Z0-9_@.+-]{1,128}", challenge["principal"])
105
+ or (s.principal is not None and challenge["principal"] != s.principal)
106
+ or challenge["public_key_fingerprint"] != fp
107
+ or type(challenge["expires_at"]) is not int
108
+ or not time.time() < challenge["expires_at"] <= time.time() + 604800
109
+ or any(
110
+ not isinstance(challenge[k], str) or not 32 <= len(challenge[k]) <= 128
111
+ for k in ("nonce", "challenge_id")
112
+ )
113
+ ):
114
+ raise ValueError("invalid gateway challenge binding")
115
+ request = dict(
116
+ protocol=PROTOCOL,
117
+ challenge_id=challenge["challenge_id"],
118
+ principal=challenge["principal"],
119
+ target_host="" if check else s.host,
120
+ target_port=0 if check else s.port,
121
+ ssh_user="" if check else s.user,
122
+ public_key_fingerprint=fp,
123
+ signature_format="sshsig",
124
+ )
125
+ request["signature_b64"] = await sign(
126
+ s.identity, s.namespace, transcript(challenge, request, check=check)
127
+ )
128
+ if check:
129
+ result = await control_response(client, "POST", self.path + "/check", json=request)
130
+ if result != {"authenticated": True} or type(result.get("authenticated")) is not bool:
131
+ raise ValueError("invalid gateway key-check response")
132
+ return None
133
+ session_url = s.url if urlsplit(s.url).path else s.url + "/"
134
+ session = await control_response(client, "POST", session_url, json=request)
135
+ if not isinstance(session, dict) or set(session) != {
136
+ "session_id",
137
+ "session_token",
138
+ "expires_at",
139
+ "max_chunk_bytes",
140
+ "max_in_flight_bytes",
141
+ }:
142
+ raise ValueError("invalid gateway session")
143
+ for key in ("session_id", "session_token"):
144
+ if not isinstance(session[key], str) or not re.fullmatch(
145
+ r"[A-Za-z0-9_-]{32,128}", session[key]
146
+ ):
147
+ raise ValueError("invalid session capability")
148
+ chunk = session["max_chunk_bytes"]
149
+ if type(chunk) is not int or not 1 <= chunk <= 16384:
150
+ raise ValueError("invalid chunk size")
151
+ self.session_path = self.path + "/sessions/" + session["session_id"]
152
+ self.headers = {"Authorization": "Bearer " + session["session_token"]}
153
+ return chunk
154
+
155
+ async def post_chunk(self, client, data, seq):
156
+ assert self.session_path is not None
157
+ response = await control_response(
158
+ client,
159
+ "POST",
160
+ self.session_path + "/input",
161
+ content=data,
162
+ headers={
163
+ **self.headers,
164
+ "Content-Type": "application/octet-stream",
165
+ "X-SSH-SSE-Seq": str(seq),
166
+ },
167
+ )
168
+ if (
169
+ response != {"acked_seq": seq, "bytes": len(data)}
170
+ or type(response.get("acked_seq")) is not int
171
+ or type(response.get("bytes")) is not int
172
+ ):
173
+ raise ValueError("ambiguous input acknowledgement")
174
+
175
+ async def post_eof(self, client):
176
+ assert self.session_path is not None
177
+ result = await control_response(
178
+ client, "POST", self.session_path + "/input-eof", headers=self.headers
179
+ )
180
+ if result != {"eof": True} or type(result.get("eof")) is not bool:
181
+ raise ValueError("ambiguous input EOF acknowledgement")
182
+
183
+ async def send(self, client, stdio, chunk):
184
+ assert self.session_path is not None
185
+ seq = 0
186
+ while not self.remote_eof:
187
+ data = await stdio.read(chunk)
188
+ if self.remote_eof:
189
+ return
190
+ if not data:
191
+ self.input_task = asyncio.create_task(self.post_eof(client))
192
+ await self.input_task
193
+ return
194
+ self.input_task = asyncio.create_task(self.post_chunk(client, data, seq))
195
+ await self.input_task
196
+ seq += 1
197
+
198
+ async def receive(self, client, stdio, chunk):
199
+ assert self.session_path is not None
200
+ parser = SSE(chunk)
201
+ async with client.stream(
202
+ "GET",
203
+ self.session_path + "/events",
204
+ headers=self.headers,
205
+ timeout=httpx.Timeout(
206
+ self.settings.stream_timeout, connect=self.settings.request_timeout
207
+ ),
208
+ ) as response:
209
+ require_identity_encoding(response)
210
+ if (
211
+ response.status_code != 200
212
+ or response.headers.get("content-type", "").split(";")[0] != "text/event-stream"
213
+ ):
214
+ raise ValueError("gateway SSE subscription failed")
215
+ async for part in response.aiter_bytes(chunk_size=None):
216
+ for data in parser.feed(part):
217
+ if data is not None:
218
+ await asyncio.wait_for(stdio.write(data), self.settings.stream_timeout)
219
+ if parser.eof:
220
+ parser.finish()
221
+ return
222
+ parser.finish()
223
+
224
+ async def check(self):
225
+ async with self.client() as client:
226
+ await self.authenticate(client, check=True)
227
+
228
+ def client(self):
229
+ context = ssl.create_default_context(cafile=self.settings.ca_file)
230
+ return httpx.AsyncClient(
231
+ verify=context,
232
+ trust_env=False,
233
+ follow_redirects=False,
234
+ timeout=self.settings.request_timeout,
235
+ limits=httpx.Limits(max_connections=4),
236
+ headers={"Accept-Encoding": "identity"},
237
+ )
238
+
239
+ async def run(self, stdio):
240
+ tasks = []
241
+ async with self.client() as client:
242
+ try:
243
+ chunk = await self.authenticate(client)
244
+ assert chunk is not None
245
+ sender = asyncio.create_task(self.send(client, stdio, chunk))
246
+ receiver = asyncio.create_task(self.receive(client, stdio, chunk))
247
+ tasks = [sender, receiver]
248
+ done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
249
+ if receiver in done:
250
+ await receiver
251
+ self.remote_eof = True
252
+ # Remote EOF does not prove an outstanding POST was acknowledged.
253
+ # Finish that request before cancelling any blocked stdin read.
254
+ if self.input_task is not None:
255
+ await asyncio.wait_for(
256
+ asyncio.shield(self.input_task), self.settings.request_timeout
257
+ )
258
+ if sender.done():
259
+ await sender
260
+ else:
261
+ await sender
262
+ await receiver
263
+ finally:
264
+ for task in tasks:
265
+ task.cancel()
266
+ await asyncio.gather(*tasks, return_exceptions=True)
267
+ if self.session_path:
268
+ try:
269
+ async with client.stream(
270
+ "DELETE", self.session_path, headers=self.headers, timeout=2
271
+ ):
272
+ pass
273
+ except (httpx.HTTPError, asyncio.CancelledError):
274
+ pass