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/certs.py ADDED
@@ -0,0 +1,283 @@
1
+ """
2
+ OpenSSH certificates (draft-miller-ssh-cert, *-cert-v01@openssh.com,
3
+ ssh-mldsa44-ed25519-cert).
4
+
5
+ Implemented: parsing, CA signatures (ed25519/mldsa/ecdsa/rsa), validity
6
+ periods, principals, critical options (unknown ones REJECT the cert),
7
+ @cert-authority lines from known_hosts.
8
+ Not implemented: verify-required touch checks (such certs are rejected),
9
+ source-address enforcement (a server-side concern for user certs).
10
+ """
11
+ from __future__ import annotations
12
+ import base64
13
+ import fnmatch
14
+ import os
15
+ import time
16
+
17
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
18
+
19
+ from ccssh import hostkeys as HK
20
+ from ccssh import mldsa as ML
21
+ from ccssh import wire as W
22
+
23
+ CERT_TYPE_USER = 1
24
+ CERT_TYPE_HOST = 2
25
+
26
+ # Known user critical options (draft section 2.3). Unknown ones REJECT the cert.
27
+ KNOWN_CRITICAL = {"force-command", "source-address", "verify-required"}
28
+
29
+
30
+ class CertError(ValueError):
31
+ pass
32
+
33
+
34
+ def _parse_section(raw: bytes) -> dict[str, bytes]:
35
+ """A critical-options/extensions section: sequence of {string key, string value}."""
36
+ r = W.Reader(raw)
37
+ out: dict[str, bytes] = {}
38
+ while r.rest():
39
+ k = r.string().decode()
40
+ v = r.string()
41
+ out[k] = v
42
+ return out
43
+
44
+
45
+ def parse_cert(blob: bytes) -> dict:
46
+ """
47
+ Parse and structurally validate a certificate blob.
48
+
49
+ Raises CertError (never bare WireError/UnicodeDecodeError) so callers
50
+ get one clean failure type for attacker-controlled input.
51
+ """
52
+ try:
53
+ return _parse_cert_inner(blob)
54
+ except CertError:
55
+ raise
56
+ except Exception as e:
57
+ raise CertError(f"malformed certificate: {e}") from e
58
+
59
+
60
+ def _parse_cert_inner(blob: bytes) -> dict:
61
+ r = W.Reader(blob)
62
+ keytype = r.string().decode()
63
+ if not (keytype.endswith("-cert-v01@openssh.com") or keytype.endswith("-cert")):
64
+ raise CertError(f"not a certificate type {keytype!r}")
65
+ nonce = r.string()
66
+ pub: dict = {}
67
+ if keytype.startswith("ssh-ed25519"):
68
+ pub = {"pub": r.string()}
69
+ if len(pub["pub"]) != 32:
70
+ raise CertError("bad ed25519 pub length")
71
+ elif keytype.startswith("ssh-mldsa44-ed25519"):
72
+ pub = {"pub": r.string()}
73
+ if len(pub["pub"]) != ML.PK_LEN:
74
+ raise CertError("bad mldsa pub length")
75
+ elif keytype.startswith("ecdsa-sha2-"):
76
+ pub = {"curve": r.string().decode(), "q": r.string()}
77
+ elif keytype.startswith("ssh-rsa"):
78
+ pub = {"e": r.mpint(), "n": r.mpint()}
79
+ else:
80
+ raise CertError(f"unsupported certificate type {keytype!r}")
81
+ serial = r.u64()
82
+ role = r.u32()
83
+ key_id = r.string()
84
+ """
85
+ Principals is a string CONTAINING cstrings (see sshkey_certify_custom
86
+ in OpenSSH sshkey.c), NOT comma-joined text as the draft suggests.
87
+ """
88
+ principals_raw = r.string()
89
+ principals: list[str] = []
90
+ if principals_raw:
91
+ pr = W.Reader(principals_raw)
92
+ while pr.rest():
93
+ principals.append(pr.string().decode())
94
+ valid_after = r.u64()
95
+ valid_before = r.u64()
96
+ crit_raw = r.string()
97
+ ext_raw = r.string()
98
+ _reserved = r.string()
99
+ if _reserved:
100
+ raise CertError("certificate reserved field must be empty")
101
+ sig_key = r.string()
102
+ sig_off = r.tell() # the signature covers everything up to this point
103
+ signature = r.string()
104
+ if r.rest():
105
+ raise CertError("trailing bytes in certificate")
106
+ return {"keytype": keytype,
107
+ "nonce": nonce,
108
+ "pub": pub,
109
+ "serial": serial,
110
+ "role": role,
111
+ "key_id": key_id,
112
+ "principals": principals,
113
+ "valid_after": valid_after,
114
+ "valid_before": valid_before,
115
+ "critical": _parse_section(crit_raw),
116
+ "extensions": _parse_section(ext_raw),
117
+ "sig_key": sig_key,
118
+ "signature": signature,
119
+ "signed_data": blob[:sig_off]}
120
+
121
+
122
+ def _ca_verify_signed(signed: bytes, sig_key_blob: bytes, signature: bytes) -> bool:
123
+ """Verify a CA signature according to the CA key type. Never raises."""
124
+ r = W.Reader(sig_key_blob)
125
+ try:
126
+ ca_type = r.string().decode()
127
+ except Exception:
128
+ return False
129
+ if ca_type in ("ssh-ed25519",):
130
+ rr = W.Reader(signature)
131
+ try:
132
+ alg, sig_raw = rr.string(), rr.string()
133
+ except Exception:
134
+ return False
135
+ if alg != b"ssh-ed25519":
136
+ return False
137
+ r2 = W.Reader(sig_key_blob)
138
+ r2.string()
139
+ pub = r2.string()
140
+ try:
141
+ Ed25519PublicKey.from_public_bytes(pub).verify(sig_raw, signed)
142
+ return True
143
+ except Exception:
144
+ return False
145
+ if ca_type in ("ssh-mldsa44-ed25519",):
146
+ try:
147
+ inner = W.Reader(sig_key_blob)
148
+ inner.string()
149
+ ca_pub = inner.string()
150
+ if len(ca_pub) != ML.PK_LEN:
151
+ return False
152
+ ca_blob = W.enc_string(b"ssh-mldsa44-ed25519") + W.enc_string(ca_pub)
153
+ return ML.verify_nested(ca_blob, signed, signature)
154
+ except Exception:
155
+ return False
156
+ # ECDSA/RSA CAs: signature has the same nested shape as hostkeys.
157
+ rr = W.Reader(signature)
158
+ try:
159
+ outer_alg = rr.string().decode()
160
+ except Exception:
161
+ return False
162
+ host_alg = outer_alg if ca_type == "ssh-rsa" else ca_type
163
+ return HK.verify_host_signature(sig_key_blob, host_alg, signed, signature)
164
+
165
+
166
+ def verify_cert(blob: bytes, *, expected_principal: str, is_host: bool, ca_blobs: list[bytes], now: int | None = None) -> dict:
167
+ """Full verification. Returns the parsed cert or raises CertError."""
168
+ c = parse_cert(blob)
169
+ want_role = CERT_TYPE_HOST if is_host else CERT_TYPE_USER
170
+ if c["role"] != want_role:
171
+ raise CertError(f"role {c['role']}, expected {want_role}")
172
+ if c["valid_after"] > c["valid_before"]:
173
+ raise CertError("certificate has inverted validity interval")
174
+ t = now if now is not None else int(time.time())
175
+ if not (c["valid_after"] <= t <= c["valid_before"]):
176
+ raise CertError("certificate outside validity period")
177
+ if expected_principal not in c["principals"]:
178
+ raise CertError(f"principal {expected_principal!r} not in {c['principals']}")
179
+ unknown_crit = set(c["critical"]) - KNOWN_CRITICAL
180
+ if unknown_crit:
181
+ raise CertError(f"unknown critical options (REJECT): {sorted(unknown_crit)}")
182
+ if "verify-required" in c["critical"]:
183
+ raise CertError("verify-required needs a security-key touch, unsupported")
184
+ if c["sig_key"] not in ca_blobs:
185
+ raise CertError("certificate CA not trusted (@cert-authority)")
186
+ if not _ca_verify_signed(c["signed_data"], c["sig_key"], c["signature"]):
187
+ raise CertError("CA signature mismatch")
188
+ return c
189
+
190
+
191
+ def load_ca_keys(known_hosts, host: str, port: int = 22) -> list[bytes]:
192
+ """
193
+ CA blobs from @cert-authority lines matching host (fnmatch *?).
194
+
195
+ Accepts one path or many (mirrors hostcheck's UserKnownHostsFile
196
+ lists). Malformed lines are skipped, never fatal: a broken local
197
+ file must not crash host verification (it just yields no CAs, then
198
+ strict fails closed). Non-22 ports also match [host]:port patterns
199
+ like OpenSSH. Hashed |1| patterns match like plain ones.
200
+ """
201
+ from ccssh import hostcheck as _HC
202
+ paths = [known_hosts] if isinstance(known_hosts, str) else list(known_hosts or [])
203
+ out: list[bytes] = []
204
+ for kh in paths:
205
+ if kh == "/dev/null" or os.path.basename(os.path.expanduser(kh)).lower() == "none":
206
+ continue
207
+ try:
208
+ if os.path.getsize(os.path.expanduser(kh)) > _HC.MAX_KNOWN_HOSTS_SIZE:
209
+ continue
210
+ with open(os.path.expanduser(kh), encoding="utf-8") as f:
211
+ lines = f.readlines()
212
+ if len(lines) > _HC.MAX_KNOWN_HOSTS_LINES:
213
+ continue
214
+ except OSError:
215
+ continue
216
+ cands = [host] if port == 22 else [host, f"[{host}]:{port}"]
217
+ for ln in lines:
218
+ ln = ln.strip()
219
+ if not ln.startswith("@cert-authority"):
220
+ continue
221
+ parts = ln.split()
222
+ if len(parts) < 4:
223
+ continue
224
+ patterns, b64 = parts[1], parts[3] # [2] is the key-type column (informational)
225
+ try:
226
+ blob = base64.b64decode(b64, validate=True)
227
+ except Exception:
228
+ continue
229
+ matched = False
230
+ for c in cands:
231
+ for pat in patterns.split(","):
232
+ if _match_host_ci(pat.strip(), c, host, port):
233
+ matched = True
234
+ break
235
+ if matched:
236
+ break
237
+ if matched:
238
+ out.append(blob)
239
+ return out
240
+
241
+
242
+ def _match_host_ci(pat: str, cand: str, host: str, port: int) -> bool:
243
+ """
244
+ Host pattern match: hashed |1| entries via hostcheck, plain/glob
245
+ entries case-insensitively (DNS names are case-insensitive).
246
+ """
247
+ from ccssh import hostcheck as _HC
248
+ if pat.startswith("|1|"):
249
+ return _HC._match_host(pat, host, port)
250
+ return fnmatch.fnmatchcase(cand.lower(), pat.lower())
251
+
252
+
253
+ def build_cert(*, keytype: str, pub_fields: bytes, serial: int = 1, role: int = CERT_TYPE_HOST, key_id: bytes = b"test", principals: str | list[str] = "host.example.com", valid_after: int = 0, valid_before: int = 0xFFFFFFFFFFFFFFFF, critical: bytes = b"", extensions: bytes = b"", ca_priv=None, nonce: bytes | None = None) -> bytes:
254
+ """Assemble and CA-sign a certificate (for tests). pub_fields pre-encoded."""
255
+ nonce = os.urandom(32) if nonce is None else nonce
256
+ if isinstance(principals, str):
257
+ principals = [principals]
258
+ princ_blob = b"".join(W.enc_string(p.encode()) for p in principals)
259
+ body = (W.enc_string(keytype.encode())
260
+ + W.enc_string(nonce)
261
+ + pub_fields
262
+ + W.enc_u64(serial)
263
+ + W.enc_u32(role)
264
+ + W.enc_string(key_id)
265
+ + W.enc_string(princ_blob)
266
+ + W.enc_u64(valid_after)
267
+ + W.enc_u64(valid_before)
268
+ + W.enc_string(critical)
269
+ + W.enc_string(extensions)
270
+ + W.enc_string(b""))
271
+ if isinstance(ca_priv, Ed25519PrivateKey):
272
+ ca_pub = ca_priv.public_key().public_bytes_raw()
273
+ sig_key = W.enc_string(b"ssh-ed25519") + W.enc_string(ca_pub)
274
+ sig_key_field = W.enc_string(sig_key)
275
+ sig_raw = ca_priv.sign(body + sig_key_field)
276
+ sig = W.enc_string(b"ssh-ed25519") + W.enc_string(sig_raw)
277
+ elif type(ca_priv).__name__ == "KeyPair":
278
+ sig_key = ca_priv.public_blob()
279
+ sig_key_field = W.enc_string(sig_key)
280
+ sig = ca_priv.sign_nested(body + sig_key_field)
281
+ else:
282
+ raise CertError("build_cert supports Ed25519 and mldsa CA keys")
283
+ return body + sig_key_field + W.enc_string(sig)
ccssh/chacha.py ADDED
@@ -0,0 +1,117 @@
1
+ """
2
+ chacha20-poly1305@openssh.com (IETF draft-ietf-sshm-chacha20-poly1305).
3
+
4
+ Bernstein's original ChaCha20 (8-byte nonce, 64-bit counter) combined with
5
+ Poly1305 into the SSH AEAD. Layout (K_1 = first 32 key bytes for payload and
6
+ the Poly1305 one-time key, K_2 = second 32 for the length field):
7
+
8
+ enc_length = packet_length XOR ChaCha(K_2, counter=0, nonce=seq)[:4]
9
+ enc_payload = payload XOR ChaCha(K_1, counter=1.., nonce=seq)
10
+ poly_key = ChaCha(K_1, counter=0, nonce=seq)[:32]
11
+ tag = Poly1305(poly_key, enc_length || enc_payload)
12
+
13
+ The raw ChaCha block function is OpenSSL's (via `cryptography`), which is
14
+ byte-identical to the original for these parameters: original state words
15
+ x12=ctr_lo, x13=ctr_hi, x14/x15=nonce map onto OpenSSL's IV layout of
16
+ LE32(counter) || LE32(counter_hi) || nonce8. Verified against the
17
+ independent pure-Python reference and the draft section 8 test vector,
18
+ plus live sessions against OpenSSH.
19
+ """
20
+ from __future__ import annotations
21
+ import hmac as pyhmac
22
+ import struct
23
+
24
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms
25
+ from cryptography.hazmat.primitives.poly1305 import Poly1305
26
+
27
+ from ccssh.crypto import kdf, HASH_sha256
28
+
29
+
30
+ def _xor(a: bytes, b: bytes) -> bytes:
31
+ if len(a) != len(b):
32
+ raise ValueError("xor inputs must have equal length")
33
+ out = bytearray(len(a))
34
+ mv_a, mv_b = memoryview(a), memoryview(b)
35
+ for i in range(len(a)):
36
+ out[i] = mv_a[i] ^ mv_b[i]
37
+ return bytes(out)
38
+
39
+
40
+ def keystream(key32: bytes, counter0: int, nonce8: bytes, nbytes: int) -> bytes:
41
+ """
42
+ ChaCha keystream for consecutive block counters starting at counter0.
43
+
44
+ A single OpenSSL call covers the whole span (the counter advances
45
+ internally), so this is fast even for tens of kilobytes.
46
+ """
47
+ if len(key32) != 32 or len(nonce8) != 8:
48
+ raise ValueError("key must be 32 bytes, nonce 8 bytes")
49
+ if not 0 <= counter0 < 2**64:
50
+ raise ValueError("counter out of 64-bit range")
51
+ iv = struct.pack("<I", counter0 & 0xFFFFFFFF) + struct.pack("<I", counter0 >> 32) + nonce8
52
+ enc = Cipher(algorithms.ChaCha20(key32, iv), mode=None).encryptor()
53
+ return enc.update(b"\x00" * nbytes) + enc.finalize()
54
+
55
+
56
+ def chacha_block(key32: bytes, counter: int, nonce8: bytes) -> bytes:
57
+ """One 64-byte keystream block (convenience wrapper over keystream)."""
58
+ return keystream(key32, counter, nonce8, 64)
59
+
60
+
61
+ class ChachaSshCtx:
62
+ """One direction. Takes the 64-byte KDF key material (K_1 || K_2)."""
63
+
64
+ def __init__(self, key64: bytes):
65
+ if len(key64) != 64:
66
+ raise ValueError("key material must be 64 bytes")
67
+ self.k1, self.k2 = key64[:32], key64[32:]
68
+
69
+ def seal(self, seq: int, packet_len: bytes, payload_block: bytes) -> bytes:
70
+ """
71
+ packet_len: 4 cleartext length bytes; payload_block holds
72
+ padding_length || payload || padding. Returns the full wire packet.
73
+ """
74
+ if not 0 <= seq < 2**32:
75
+ raise ValueError("chacha sequence exhausted: rekey required")
76
+ nonce = struct.pack(">Q", seq)
77
+ enc_len = _xor(packet_len, keystream(self.k2, 0, nonce, 4))
78
+ enc_pay = _xor(payload_block, keystream(self.k1, 1, nonce, len(payload_block)))
79
+ polykey = keystream(self.k1, 0, nonce, 32)
80
+ tag = Poly1305(polykey)
81
+ tag.update(enc_len + enc_pay)
82
+ return enc_len + enc_pay + tag.finalize()
83
+
84
+ def open(self, seq: int, wire: bytes) -> tuple[bytes, bytes]:
85
+ """Returns (packet_len, payload_block). Raises on bad MAC."""
86
+ if not 0 <= seq < 2**32:
87
+ raise ValueError("chacha sequence exhausted: rekey required")
88
+ if len(wire) < 4 + 1 + 16:
89
+ raise ValueError("wire packet too short")
90
+ nonce = struct.pack(">Q", seq)
91
+ enc_len, rest = wire[:4], wire[4:]
92
+ enc_pay, tag_recv = rest[:-16], rest[-16:]
93
+ polykey = keystream(self.k1, 0, nonce, 32)
94
+ tag = Poly1305(polykey)
95
+ tag.update(enc_len + enc_pay)
96
+ if not pyhmac.compare_digest(tag.finalize(), tag_recv):
97
+ raise ValueError("Poly1305 mismatch")
98
+ packet_len = _xor(enc_len, keystream(self.k2, 0, nonce, 4))
99
+ payload_block = _xor(enc_pay, keystream(self.k1, 1, nonce, len(enc_pay)))
100
+ return packet_len, payload_block
101
+
102
+ def decrypt_length(self, seq: int, enc_len: bytes) -> bytes:
103
+ """Decrypt the 4 length bytes without touching payload state (stateless)."""
104
+ if not 0 <= seq < 2**32:
105
+ raise ValueError("chacha sequence exhausted: rekey required")
106
+ return _xor(enc_len, keystream(self.k2, 0, struct.pack(">Q", seq), 4))
107
+
108
+
109
+ def derive_chacha_keys(K_encoded: bytes, H: bytes, session_id: bytes, hashfn=None) -> dict[str, bytes]:
110
+ # Production callers must pass the KEX hash; fallback kept for tests.
111
+ if hashfn is None:
112
+ hashfn = HASH_sha256
113
+ hf = hashfn
114
+ return {
115
+ "enc_c2s": kdf(hf, K_encoded, H, session_id, b"C", 64),
116
+ "enc_s2c": kdf(hf, K_encoded, H, session_id, b"D", 64),
117
+ }
ccssh/channel.py ADDED
@@ -0,0 +1,124 @@
1
+ """
2
+ Channel message codecs, RFC 4254: session open, exec/subsystem requests,
3
+ direct-tcpip open, data, window adjust, close.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from ccssh import wire as W
8
+
9
+ MSG_CHANNEL_OPEN = 90
10
+ MSG_CHANNEL_OPEN_CONFIRM = 91
11
+ MSG_CHANNEL_OPEN_FAILURE = 92
12
+ MSG_WINDOW_ADJUST = 93
13
+ MSG_CHANNEL_DATA = 94
14
+ MSG_CHANNEL_EXTENDED_DATA = 95
15
+ MSG_CHANNEL_EOF = 96
16
+ MSG_CHANNEL_CLOSE = 97
17
+ MSG_CHANNEL_REQUEST = 98
18
+ MSG_CHANNEL_SUCCESS = 99
19
+ MSG_CHANNEL_FAILURE = 100
20
+
21
+ WINDOW = 2 * 1024 * 1024
22
+ MAXPKT = 32768
23
+ # Send WINDOW_ADJUST after consuming this many channel bytes (half a window).
24
+ WINDOW_THRESHOLD = 1024 * 1024
25
+ # Per-channel relay backlog cap (forwarders, jump sockets): a slow reader
26
+ # must fail its channel, never OOM the process.
27
+ MAX_BUFFER = 16 * 1024 * 1024
28
+
29
+
30
+ def enc_open_session(sender: int) -> bytes:
31
+ return (bytes([MSG_CHANNEL_OPEN]) + W.enc_string(b"session") + W.enc_u32(sender) + W.enc_u32(WINDOW) + W.enc_u32(MAXPKT))
32
+
33
+
34
+ def enc_exec(recipient: int, command: str, *, want_reply: bool = True) -> bytes:
35
+ return (bytes([MSG_CHANNEL_REQUEST])
36
+ + W.enc_u32(recipient)
37
+ + W.enc_string(b"exec")
38
+ + W.enc_bool(want_reply)
39
+ + W.enc_string(command.encode()))
40
+
41
+
42
+ def enc_subsystem(recipient: int, name: str = "sftp", *, want_reply: bool = True) -> bytes:
43
+ return (bytes([MSG_CHANNEL_REQUEST])
44
+ + W.enc_u32(recipient)
45
+ + W.enc_string(b"subsystem")
46
+ + W.enc_bool(want_reply)
47
+ + W.enc_string(name.encode()))
48
+
49
+
50
+ def enc_pty_req(recipient: int, term: str = "xterm", cols: int = 80, rows: int = 24, width_px: int = 0, height_px: int = 0, modes: bytes = b"", *, want_reply: bool = True) -> bytes:
51
+ """RFC 4254 section 6.2 pty-req. Empty modes = all opcodes stay default."""
52
+ return (bytes([MSG_CHANNEL_REQUEST])
53
+ + W.enc_u32(recipient)
54
+ + W.enc_string(b"pty-req")
55
+ + W.enc_bool(want_reply)
56
+ + W.enc_string(term.encode())
57
+ + W.enc_u32(cols)
58
+ + W.enc_u32(rows)
59
+ + W.enc_u32(width_px)
60
+ + W.enc_u32(height_px)
61
+ + W.enc_string(modes))
62
+
63
+
64
+ def enc_shell(recipient: int, *, want_reply: bool = True) -> bytes:
65
+ """RFC 4254 section 6.5 shell request."""
66
+ return (bytes([MSG_CHANNEL_REQUEST])
67
+ + W.enc_u32(recipient)
68
+ + W.enc_string(b"shell")
69
+ + W.enc_bool(want_reply))
70
+
71
+
72
+ def enc_window_change(recipient: int, cols: int, rows: int, width_px: int = 0, height_px: int = 0) -> bytes:
73
+ """RFC 4254 section 6.7. Always want_reply=False (no reply is sent)."""
74
+ return (bytes([MSG_CHANNEL_REQUEST])
75
+ + W.enc_u32(recipient)
76
+ + W.enc_string(b"window-change")
77
+ + W.enc_bool(False)
78
+ + W.enc_u32(cols)
79
+ + W.enc_u32(rows)
80
+ + W.enc_u32(width_px)
81
+ + W.enc_u32(height_px))
82
+
83
+
84
+ def enc_eof(recipient: int) -> bytes:
85
+ """RFC 4254 section 5.3: no more data will be sent (stdin EOF)."""
86
+ return bytes([MSG_CHANNEL_EOF]) + W.enc_u32(recipient)
87
+
88
+
89
+ def enc_open_direct_tcpip(sender: int, host: str, port: int, orig_host: str = "127.0.0.1", orig_port: int = 0) -> bytes:
90
+ return (bytes([MSG_CHANNEL_OPEN])
91
+ + W.enc_string(b"direct-tcpip")
92
+ + W.enc_u32(sender)
93
+ + W.enc_u32(WINDOW)
94
+ + W.enc_u32(MAXPKT)
95
+ + W.enc_string(host.encode())
96
+ + W.enc_u32(port)
97
+ + W.enc_string(orig_host.encode())
98
+ + W.enc_u32(orig_port))
99
+
100
+
101
+ def enc_data(recipient: int, data: bytes) -> bytes:
102
+ if len(data) > MAXPKT:
103
+ raise ValueError(f"channel data chunk exceeds MAXPKT ({len(data)} > {MAXPKT})")
104
+ return bytes([MSG_CHANNEL_DATA]) + W.enc_u32(recipient) + W.enc_string(data)
105
+
106
+
107
+ def enc_window_adjust(recipient: int, delta: int) -> bytes:
108
+ if not 0 < delta < 2**32:
109
+ raise ValueError(f"window adjust delta out of range: {delta}")
110
+ return bytes([MSG_WINDOW_ADJUST]) + W.enc_u32(recipient) + W.enc_u32(delta)
111
+
112
+
113
+ def check_window(window: int, delta: int) -> int:
114
+ """Add a WINDOW_ADJUST credit, enforcing RFC 4254 section 5.2 limits."""
115
+ if not 0 < delta < 2**32:
116
+ raise ValueError(f"bad window increment {delta}")
117
+ new = window + delta
118
+ if new > 0xFFFFFFFF:
119
+ raise ValueError("channel window exceeds 2^32-1")
120
+ return new
121
+
122
+
123
+ def enc_close(recipient: int) -> bytes:
124
+ return bytes([MSG_CHANNEL_CLOSE]) + W.enc_u32(recipient)