ccssh 1.0.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.
ccssh/shell.py ADDED
@@ -0,0 +1,257 @@
1
+ """
2
+ Interactive shell over a ShellSession: an `ssh host` experience.
3
+
4
+ Single-reader model: a background pump thread owns transport receives and
5
+ writes server output to stdout/stderr; the main thread forwards stdin.
6
+ Terminal is put into raw mode (POSIX) so editors, arrows and Ctrl-C behave;
7
+ SIGWINCH propagates the new size via window-change. The stdin loop polls
8
+ with select(2) so a server-side close (e.g. remote bash exiting after
9
+ Ctrl+D) ends the session promptly instead of hanging on a stdin read.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import select
15
+ import shutil
16
+ import signal
17
+ import sys
18
+ import threading
19
+
20
+ from ccssh import channel as CH
21
+ from ccssh import wire as W
22
+ from ccssh.transport import CCSSHError
23
+
24
+
25
+ def _termsize() -> tuple[int, int]:
26
+ s = shutil.get_terminal_size()
27
+ return max(1, s.columns), max(1, s.lines)
28
+
29
+
30
+ def _write_all(fd: int, data: bytes) -> None:
31
+ """Write the full buffer, retrying partial writes (EAGAIN-safe)."""
32
+ mv = memoryview(data)
33
+ while mv:
34
+ n = os.write(fd, mv)
35
+ if n <= 0:
36
+ raise OSError("short write to output")
37
+ mv = mv[n:]
38
+
39
+
40
+ def run_interactive(client, sess=None, *, pty: bool = True, command: str | None = None, stdin_fd: int | None = None) -> int:
41
+ """
42
+ Run a shell session interactively. Opens one if not given.
43
+
44
+ With command set, runs it on a pty channel instead of a shell
45
+ (`ssh -t host command`). With pty=False no pty is requested
46
+ (`ssh -T`). stdin_fd overrides standard input (`ssh -n` passes
47
+ /dev/null). Returns the remote exit status (0 when none is sent).
48
+ Restores the terminal on all exits.
49
+ """
50
+ own = sess is None
51
+ if own:
52
+ cols, rows = _termsize()
53
+ sess = client.open_shell(cols=cols, rows=rows, pty=pty, command=command)
54
+ if sess is None:
55
+ raise CCSSHError("no shell session")
56
+ t = client._t
57
+ if t is None:
58
+ raise CCSSHError("connection closed")
59
+ done = threading.Event()
60
+ err: list[BaseException] = []
61
+ # Through the mux when one is active (forwarding shares the
62
+ # connection); direct transport reads otherwise (test fakes included).
63
+ _rx = getattr(client, "_rx", None)
64
+
65
+ def _recv_one():
66
+ if callable(_rx):
67
+ return _rx(sess.chan)
68
+ return t.recv_packet()
69
+ if stdin_fd is None:
70
+ stdin_fd = sys.stdin.fileno() if hasattr(sys.stdin, "fileno") else -1
71
+ stdin_no = stdin_fd if stdin_fd >= 0 else 0
72
+ tty_mode = stdin_fd >= 0 and sys.stdin.isatty()
73
+ old_attrs = None
74
+
75
+ def pump() -> None:
76
+ try:
77
+ while not done.is_set():
78
+ try:
79
+ p = _recv_one()
80
+ except Exception as e:
81
+ err.append(e)
82
+ done.set()
83
+ break
84
+ if not p:
85
+ continue
86
+ m = p[0]
87
+ try:
88
+ rr = W.Reader(p)
89
+ rr.byte()
90
+ want = getattr(sess, "chan", sess.srv)
91
+ if m == CH.MSG_CHANNEL_DATA:
92
+ if rr.u32() != want:
93
+ continue
94
+ chunk = rr.string()
95
+ _write_all(1, chunk)
96
+ try:
97
+ add = getattr(sess, "add_consumed", None)
98
+ if callable(add):
99
+ add(len(chunk))
100
+ else:
101
+ sess.consumed += len(chunk)
102
+ except AttributeError:
103
+ pass
104
+ sess.window_adjust()
105
+ elif m == CH.MSG_CHANNEL_EXTENDED_DATA:
106
+ if rr.u32() != want:
107
+ continue
108
+ if rr.u32() != 1:
109
+ continue
110
+ chunk = rr.string()
111
+ _write_all(2, chunk)
112
+ # stderr consumes the same server window as stdout:
113
+ # without crediting it, >2MB on stderr deadlocks.
114
+ try:
115
+ add = getattr(sess, "add_consumed", None)
116
+ if callable(add):
117
+ add(len(chunk))
118
+ else:
119
+ sess.consumed += len(chunk)
120
+ except AttributeError:
121
+ pass
122
+ sess.window_adjust()
123
+ elif m == CH.MSG_WINDOW_ADJUST:
124
+ if rr.u32() != want:
125
+ continue
126
+ sess.credit(rr.u32())
127
+ elif m == CH.MSG_CHANNEL_REQUEST:
128
+ if rr.u32() != want:
129
+ continue
130
+ name = rr.string()
131
+ if name in (b"exit-status", b"exit-signal"):
132
+ rr.bool()
133
+ if name == b"exit-status":
134
+ try:
135
+ sess.exit_status = rr.u32()
136
+ except Exception:
137
+ pass
138
+ else:
139
+ # A signalled remote process: ssh reports 128
140
+ # (unknown layouts can't be parsed for a
141
+ # FAILURE reply, so anything else is ignored).
142
+ sess.exit_status = 128
143
+ elif m == CH.MSG_CHANNEL_EOF:
144
+ if rr.u32() != want:
145
+ continue
146
+ sess.eof_received = True
147
+ elif m == CH.MSG_CHANNEL_CLOSE:
148
+ if rr.u32() != want:
149
+ continue
150
+ _peer_closed = getattr(sess, "_peer_closed", None)
151
+ if callable(_peer_closed):
152
+ try:
153
+ _peer_closed()
154
+ except Exception:
155
+ pass
156
+ done.set()
157
+ break
158
+ elif m == 20: # no rekey with an open channel (see exec)
159
+ err.append(RuntimeError("peer started rekey with an open channel"))
160
+ done.set()
161
+ break
162
+ # IGNORE/DEBUG/GLOBAL_REQUEST never reach here (transport).
163
+ except Exception as e:
164
+ err.append(e)
165
+ done.set()
166
+ break
167
+ finally:
168
+ done.set()
169
+
170
+ th = threading.Thread(target=pump, daemon=True)
171
+ if tty_mode:
172
+ try:
173
+ import termios
174
+ import tty as _tty
175
+ old_attrs = termios.tcgetattr(stdin_fd)
176
+ _tty.setraw(stdin_fd)
177
+ except Exception:
178
+ old_attrs = None
179
+ tty_mode = False
180
+
181
+ def _on_winch(_signo, _frame) -> None:
182
+ # No pty was allocated (ssh -T): window-change is meaningless.
183
+ if getattr(sess, "no_pty", False):
184
+ return
185
+ try:
186
+ cols, rows = _termsize()
187
+ sess.window_change(cols, rows)
188
+ except Exception:
189
+ pass
190
+
191
+ old_winch = None
192
+ _sigwinch = getattr(signal, "SIGWINCH", None) # None on Windows
193
+ if tty_mode and _sigwinch is not None:
194
+ try:
195
+ old_winch = signal.signal(_sigwinch, _on_winch)
196
+ except Exception:
197
+ old_winch = None
198
+ stdin_open = True
199
+ try:
200
+ th.start()
201
+ while not done.is_set():
202
+ try:
203
+ ready, _, _ = select.select([stdin_no], [], [], 0.2)
204
+ except (OSError, ValueError):
205
+ ready = [stdin_no] # not selectable: blocking read fallback
206
+ if done.is_set(): # server closed first (e.g. after Ctrl+D)
207
+ break
208
+ if not ready:
209
+ continue
210
+ try:
211
+ chunk = os.read(stdin_no, 4096)
212
+ except (OSError, ValueError):
213
+ chunk = b""
214
+ if not chunk:
215
+ if stdin_open: # stdin EOF -> half-close, keep reading output
216
+ stdin_open = False
217
+ sess.send_eof()
218
+ # Wait for the server to close (bounded: no infinite hang
219
+ # if the shell ignores EOF).
220
+ import time as _time
221
+ _deadline = _time.monotonic() + 10.0
222
+ while not done.is_set() and _time.monotonic() < _deadline:
223
+ done.wait(0.05)
224
+ if not done.is_set():
225
+ try:
226
+ sess.close()
227
+ except Exception:
228
+ pass
229
+ break
230
+ try:
231
+ sess.send(chunk)
232
+ except Exception as e:
233
+ err.append(e)
234
+ break
235
+ done.wait()
236
+ th.join(timeout=5)
237
+ except KeyboardInterrupt:
238
+ pass
239
+ finally:
240
+ done.set()
241
+ sess.close()
242
+ if old_winch is not None and _sigwinch is not None:
243
+ try:
244
+ signal.signal(_sigwinch, old_winch)
245
+ except Exception:
246
+ pass
247
+ if old_attrs is not None:
248
+ try:
249
+ import termios
250
+ termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_attrs)
251
+ sys.stdout.write("\n")
252
+ sys.stdout.flush()
253
+ except Exception:
254
+ pass
255
+ if err and sess.exit_status is None:
256
+ raise err[0]
257
+ return sess.exit_status if sess.exit_status is not None else 0
ccssh/sk.py ADDED
@@ -0,0 +1,224 @@
1
+ """
2
+ FIDO2 security-key user authentication (sk-ssh-ed25519, sk-ecdsa-sha2-nistp256).
3
+
4
+ Formats follow OpenSSH PROTOCOL.u2f exactly (fetched 2026-10-11, v1.26):
5
+
6
+ pub blob: string(alg) + key fields + string(application, usually "ssh:")
7
+ priv half: string(alg) + key fields + string(application)
8
+ + byte(flags) + string(key_handle) + string(reserved)
9
+ preimage: SHA256(application) + byte(flags) + u32(counter)
10
+ + ext(empty) + SHA256(message)
11
+ wire sig: string(alg) + string(sig) + byte(flags) + u32(counter),
12
+ where ecdsa sig = mpint(r) + mpint(s) (RFC 5656) and ed25519
13
+ sig is raw 64 B.
14
+
15
+ The touch itself cannot happen in software: signing needs a FIDO2 token.
16
+ SkKey therefore carries an injectable `signer(application, key_handle,
17
+ message) -> (sig_raw, flags, counter)`; without one, sign_nested raises
18
+ a clear error instead of failing obscurely server-side. Parsing,
19
+ verification and userauth assembly are fully testable without hardware.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import base64
24
+ import hashlib
25
+ import os
26
+
27
+ from cryptography.hazmat.primitives import hashes
28
+ from cryptography.hazmat.primitives.asymmetric import ec
29
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
30
+ from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
31
+
32
+ from ccssh import wire as W
33
+
34
+ ALG_ED = "sk-ssh-ed25519@openssh.com"
35
+ ALG_EC = "sk-ecdsa-sha2-nistp256@openssh.com"
36
+ ALGS = (ALG_ED, ALG_EC)
37
+ DEFAULT_APPLICATION = b"ssh:"
38
+ EC_CURVE_SSH = b"nistp256"
39
+
40
+
41
+ def preimage(application: bytes, flags: int, counter: int, message: bytes) -> bytes:
42
+ """
43
+ The exact bytes a security key signs (PROTOCOL.u2f, no extensions).
44
+ """
45
+ if not 0 <= flags <= 255:
46
+ raise ValueError("flags must be one byte")
47
+ if not 0 <= counter < 2**32:
48
+ raise ValueError("counter must fit uint32")
49
+ return (hashlib.sha256(application).digest()
50
+ + bytes([flags])
51
+ + counter.to_bytes(4, "big")
52
+ + hashlib.sha256(message).digest())
53
+
54
+
55
+ class SkKey:
56
+ """
57
+ A security key: public parts + key handle + optional signer callback.
58
+
59
+ `signer(application, key_handle, message)` returns (sig_raw, flags,
60
+ counter); hardware builds the preimage itself. Pass None when no
61
+ token is attached - signing then raises instead of misbehaving.
62
+ """
63
+
64
+ def __init__(self, alg: str, application: bytes = DEFAULT_APPLICATION, flags: int = 0, key_handle: bytes = b"", pub: bytes = b"", curve: bytes = b"", q: bytes = b"", signer=None):
65
+ if alg not in ALGS:
66
+ raise ValueError(f"not a sk key type: {alg!r}")
67
+ if not 0 <= flags <= 255:
68
+ raise ValueError("flags must be one byte")
69
+ self.alg = alg
70
+ self.application = application
71
+ self.flags = flags
72
+ self.key_handle = key_handle
73
+ self.signer = signer
74
+ if alg == ALG_ED:
75
+ if len(pub) != 32:
76
+ raise ValueError("sk ed25519 pub must be 32 bytes")
77
+ self.pub = pub
78
+ self.curve, self.q = b"", b""
79
+ else:
80
+ if curve != EC_CURVE_SSH or not q:
81
+ raise ValueError("sk ecdsa needs nistp256 + point")
82
+ self.pub, self.curve, self.q = b"", curve, q
83
+
84
+ def public_blob(self) -> bytes:
85
+ if self.alg == ALG_ED:
86
+ return W.enc_string(self.alg.encode()) + W.enc_string(self.pub) + W.enc_string(self.application)
87
+ return W.enc_string(self.alg.encode()) + W.enc_string(self.curve) + W.enc_string(self.q) + W.enc_string(self.application)
88
+
89
+ def public_line(self, comment: str = "") -> str:
90
+ # OpenSSH .pub format: "alg base64(full_blob_with_alg) [comment]".
91
+ inner = self.public_blob()
92
+ suffix = f" {comment}" if comment else ""
93
+ return f"{self.alg} {base64.b64encode(inner).decode()}{suffix}"
94
+
95
+ def sign_nested(self, message: bytes) -> bytes:
96
+ """
97
+ Wire signature buffer: string(alg) + string(sig) + byte(flags) +
98
+ u32(counter). Verifies its own output against the public key so
99
+ a broken middleware fails here, not as a server rejection.
100
+ """
101
+ if self.signer is None:
102
+ raise ValueError("no security-key signer configured (FIDO2 touch required)")
103
+ sig_raw, flags, counter = self.signer(self.application, self.key_handle, message)
104
+ if self.alg == ALG_ED:
105
+ if len(sig_raw) != 64:
106
+ raise ValueError("sk ed25519 signature must be 64 bytes")
107
+ else:
108
+ r = W.Reader(sig_raw)
109
+ r.mpint()
110
+ r.mpint()
111
+ if r.rest():
112
+ raise ValueError("sk ecdsa signature must be mpint(r)+mpint(s)")
113
+ if not 0 <= flags <= 255 or not 0 <= counter < 2**32:
114
+ raise ValueError("signer returned bad flags/counter")
115
+ out = (W.enc_string(self.alg.encode()) + W.enc_string(sig_raw)
116
+ + bytes([flags]) + counter.to_bytes(4, "big"))
117
+ if not verify_nested(self.public_blob(), message, out):
118
+ raise ValueError("signer output does not verify")
119
+ return out
120
+
121
+
122
+ def parse_pub_blob(blob: bytes) -> SkKey:
123
+ r = W.Reader(blob)
124
+ alg = r.string().decode()
125
+ if alg == ALG_ED:
126
+ pub, app = r.string(), r.string()
127
+ if r.rest():
128
+ raise ValueError("trailing bytes in sk pub blob")
129
+ return SkKey(alg, application=app, pub=pub)
130
+ if alg == ALG_EC:
131
+ curve, q, app = r.string(), r.string(), r.string()
132
+ if r.rest():
133
+ raise ValueError("trailing bytes in sk pub blob")
134
+ return SkKey(alg, application=app, curve=curve, q=q)
135
+ raise ValueError(f"not a sk public key: {alg!r}")
136
+
137
+
138
+ def parse_private_blob(blob: bytes, *, strict: bool = True) -> SkKey:
139
+ """
140
+ Private half: pub fields + application + flags + key_handle + reserved.
141
+ Cert-form halves are refused: pass the plain key file with --cert.
142
+ strict=False tolerates the openssh-key-v1 block padding that follows
143
+ the half inside key files (used by the file loader only).
144
+ """
145
+ r = W.Reader(blob)
146
+ alg = r.string().decode()
147
+ if alg in ALGS:
148
+ pub_part = r.string() if alg == ALG_ED else None
149
+ if alg == ALG_EC:
150
+ curve, q = r.string(), r.string()
151
+ else:
152
+ curve, q = b"", b""
153
+ app = r.string()
154
+ flags = r.byte()
155
+ handle = r.string()
156
+ r.string() # reserved
157
+ if strict and r.rest():
158
+ raise ValueError("trailing bytes in sk private half")
159
+ kw = {"pub": pub_part} if alg == ALG_ED else {"curve": curve, "q": q}
160
+ return SkKey(alg, application=app, flags=flags, key_handle=handle, **kw)
161
+ raise ValueError(f"not a sk private key: {alg!r}")
162
+
163
+
164
+ def verify_nested(pub_blob: bytes, message: bytes, sig_nested: bytes) -> bool:
165
+ """
166
+ Verify an sk wire signature. Returns True/False, never raises.
167
+ """
168
+ try:
169
+ r = W.Reader(sig_nested)
170
+ sig_alg = r.string().decode()
171
+ sig_raw = r.string()
172
+ flags = r.byte()
173
+ counter = r.u32()
174
+ if r.rest():
175
+ return False
176
+ key = parse_pub_blob(pub_blob)
177
+ if key.alg != sig_alg:
178
+ return False
179
+ pre = preimage(key.application, flags, counter, message)
180
+ if key.alg == ALG_ED:
181
+ if len(sig_raw) != 64:
182
+ return False
183
+ Ed25519PublicKey.from_public_bytes(key.pub).verify(sig_raw, pre)
184
+ return True
185
+ rr = W.Reader(sig_raw)
186
+ der = encode_dss_signature(rr.mpint(), rr.mpint())
187
+ if rr.rest():
188
+ return False
189
+ ec.EllipticCurvePublicKey.from_encoded_point(
190
+ ec.SECP256R1(), key.q).verify(der, pre, ec.ECDSA(hashes.SHA256()))
191
+ return True
192
+ except Exception:
193
+ return False
194
+
195
+
196
+ def load_openssh_private(path: str, passphrase: bytes | None = None) -> SkKey:
197
+ """
198
+ Load an sk openssh-key-v1 file (unencrypted only, like the mldsa
199
+ loader: encrypted keys raise a clear error).
200
+ """
201
+ with open(os.path.expanduser(path), "rb") as f:
202
+ pem = f.read().decode()
203
+ b64 = "".join(l for l in pem.splitlines() if l and not l.startswith("-----"))
204
+ try:
205
+ raw = base64.b64decode(b64, validate=True)
206
+ except Exception as e:
207
+ raise ValueError(f"bad sk key file {path}: {e}") from e
208
+ if not raw.startswith(b"openssh-key-v1\x00"):
209
+ raise ValueError("not an openssh-key-v1 file")
210
+ r = W.Reader(raw[15:])
211
+ if r.string() != b"none":
212
+ # Encrypted sk keys are unsupported (passphrase or not): callers
213
+ # passing one unconditionally get the same clear error.
214
+ raise ValueError("encrypted sk keys are unsupported: decrypt with ssh-keygen first")
215
+ r.string()
216
+ r.string()
217
+ if r.u32() != 1:
218
+ raise ValueError("expected exactly 1 key")
219
+ r.string() # public blob
220
+ priv = r.string()
221
+ pr = W.Reader(priv)
222
+ if pr.u32() != pr.u32():
223
+ raise ValueError("checkint mismatch (wrong passphrase?)")
224
+ return parse_private_blob(priv[pr.tell():], strict=False)