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/__init__.py +21 -0
- ccssh/__main__.py +5 -0
- ccssh/algs.py +290 -0
- ccssh/async_client.py +419 -0
- ccssh/auth.py +170 -0
- ccssh/certs.py +283 -0
- ccssh/chacha.py +117 -0
- ccssh/channel.py +124 -0
- ccssh/chansock.py +227 -0
- ccssh/cli.py +889 -0
- ccssh/client.py +1274 -0
- ccssh/crypto.py +316 -0
- ccssh/dh_groups.py +92 -0
- ccssh/forward.py +872 -0
- ccssh/gcm.py +87 -0
- ccssh/hostcheck.py +217 -0
- ccssh/hostkeys.py +250 -0
- ccssh/kex.py +222 -0
- ccssh/kex_ext.py +429 -0
- ccssh/mldsa.py +210 -0
- ccssh/mlkem.py +367 -0
- ccssh/mux.py +301 -0
- ccssh/sftp.py +270 -0
- ccssh/shell.py +257 -0
- ccssh/sk.py +224 -0
- ccssh/sntrup.py +325 -0
- ccssh/stub.py +710 -0
- ccssh/transport.py +737 -0
- ccssh/umac.py +262 -0
- ccssh/wire.py +199 -0
- ccssh-1.0.0.dist-info/METADATA +442 -0
- ccssh-1.0.0.dist-info/RECORD +36 -0
- ccssh-1.0.0.dist-info/WHEEL +5 -0
- ccssh-1.0.0.dist-info/entry_points.txt +2 -0
- ccssh-1.0.0.dist-info/licenses/LICENSE +21 -0
- ccssh-1.0.0.dist-info/top_level.txt +1 -0
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)
|