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/gcm.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AES-GCM for SSH, RFC 5647. Live-verified against OpenSSH.
|
|
3
|
+
|
|
4
|
+
Layout: PT = padding_length || payload || padding (multiple of 16),
|
|
5
|
+
AAD = packet_length (4 bytes, sent in the clear),
|
|
6
|
+
nonce12 = fixed4 (from KDF) || counter8BE, where the counter STARTS at the
|
|
7
|
+
KDF-derived value (proven by offline brute force against a live sshd;
|
|
8
|
+
starting at zero fails interop).
|
|
9
|
+
Wire: packet_length || CT || AT(16). Keys from KDF letters C/D, IV from A/B.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
import os
|
|
13
|
+
import struct
|
|
14
|
+
|
|
15
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
16
|
+
|
|
17
|
+
from ccssh.crypto import kdf, HASH_sha256, pad_len_aad
|
|
18
|
+
|
|
19
|
+
GCM_SPECS = {
|
|
20
|
+
"aes128-gcm@openssh.com": {"key_len": 16},
|
|
21
|
+
"aes256-gcm@openssh.com": {"key_len": 32},
|
|
22
|
+
}
|
|
23
|
+
IV_LEN = 12
|
|
24
|
+
TAG_LEN = 16
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def derive_gcm_keys(K_encoded: bytes, H: bytes, session_id: bytes, cipher: str, hashfn=None) -> dict[str, bytes]:
|
|
28
|
+
# Production callers must pass the KEX hash; the SHA-256 fallback exists
|
|
29
|
+
# only for unit tests / legacy callers (RFC 5647: KDF hash comes from KEX).
|
|
30
|
+
if hashfn is None:
|
|
31
|
+
hashfn = HASH_sha256
|
|
32
|
+
try:
|
|
33
|
+
kl = GCM_SPECS[cipher]["key_len"]
|
|
34
|
+
except KeyError as e:
|
|
35
|
+
raise ValueError(f"unsupported gcm cipher {cipher}") from e
|
|
36
|
+
hf = hashfn
|
|
37
|
+
return {
|
|
38
|
+
"iv_c2s": kdf(hf, K_encoded, H, session_id, b"A", IV_LEN),
|
|
39
|
+
"iv_s2c": kdf(hf, K_encoded, H, session_id, b"B", IV_LEN),
|
|
40
|
+
"enc_c2s": kdf(hf, K_encoded, H, session_id, b"C", kl),
|
|
41
|
+
"enc_s2c": kdf(hf, K_encoded, H, session_id, b"D", kl),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AesGcmCtx:
|
|
46
|
+
"""One direction. Never share an instance between directions or resets."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, cipher: str, enc_key: bytes, iv12: bytes):
|
|
49
|
+
try:
|
|
50
|
+
want = GCM_SPECS[cipher]["key_len"]
|
|
51
|
+
except KeyError as e:
|
|
52
|
+
raise ValueError(f"unsupported gcm cipher {cipher}") from e
|
|
53
|
+
if len(enc_key) != want or len(iv12) != IV_LEN:
|
|
54
|
+
raise ValueError("lengths must be: key 16/32, iv 12")
|
|
55
|
+
self.fixed = iv12[:4]
|
|
56
|
+
self._aead = AESGCM(enc_key)
|
|
57
|
+
self._counter = int.from_bytes(iv12[4:], "big")
|
|
58
|
+
|
|
59
|
+
def _nonce(self) -> bytes:
|
|
60
|
+
if self._counter >= 0xFFFFFFFFFFFFFFFF:
|
|
61
|
+
raise ValueError("GCM nonce exhausted: rekey required")
|
|
62
|
+
n = self.fixed + struct.pack(">Q", self._counter)
|
|
63
|
+
self._counter = (self._counter + 1) & 0xFFFFFFFFFFFFFFFF
|
|
64
|
+
return n
|
|
65
|
+
|
|
66
|
+
def seal(self, payload: bytes) -> bytes:
|
|
67
|
+
pad = pad_len_aad(len(payload), 16)
|
|
68
|
+
pt = bytes([pad]) + payload + os.urandom(pad)
|
|
69
|
+
aad = struct.pack(">I", 1 + len(payload) + pad)
|
|
70
|
+
ct_at = self._aead.encrypt(self._nonce(), pt, aad)
|
|
71
|
+
return aad + ct_at # packet_length in the clear + CT + AT
|
|
72
|
+
|
|
73
|
+
def open(self, wire: bytes) -> bytes:
|
|
74
|
+
"""
|
|
75
|
+
Returns the raw PT (padding_length || payload || padding); the
|
|
76
|
+
transport strips padding. Raises InvalidTag/ValueError.
|
|
77
|
+
"""
|
|
78
|
+
if len(wire) < 4 + 1 + TAG_LEN:
|
|
79
|
+
raise ValueError("wire packet too short")
|
|
80
|
+
aad, ct_at = wire[:4], wire[4:]
|
|
81
|
+
(pl,) = struct.unpack(">I", aad)
|
|
82
|
+
if pl < 16 or pl > 35000 or pl % 16 != 0 or len(ct_at) != pl + TAG_LEN:
|
|
83
|
+
raise ValueError(f"bad packet_length {pl}")
|
|
84
|
+
pt = self._aead.decrypt(self._nonce(), ct_at, aad) # raises InvalidTag
|
|
85
|
+
if pt[0] < 4 or 1 + pt[0] > pl:
|
|
86
|
+
raise ValueError("bad padding")
|
|
87
|
+
return pt
|
ccssh/hostcheck.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared host-key checking for the sync and async clients.
|
|
3
|
+
|
|
4
|
+
Policies: strict (must be in known_hosts or @cert-authority), accept-new
|
|
5
|
+
(appends plain keys; certificates are only verified, never written),
|
|
6
|
+
off (accepts everything; lab use only).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
import base64
|
|
10
|
+
import hashlib
|
|
11
|
+
import hmac
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
from ccssh import hostkeys as HK
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HostKeyError(Exception):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _match_host(patterns: str, host: str, port: int) -> bool:
|
|
22
|
+
"""Match plain lists (host or [host]:port) and hashed |1| entries."""
|
|
23
|
+
for pat in patterns.split(","):
|
|
24
|
+
pat = pat.strip()
|
|
25
|
+
if pat.startswith("|1|"):
|
|
26
|
+
try:
|
|
27
|
+
_, salt_b64, hash_b64 = pat.split("|")[1:]
|
|
28
|
+
salt = base64.b64decode(salt_b64)
|
|
29
|
+
want = base64.b64decode(hash_b64)
|
|
30
|
+
except Exception:
|
|
31
|
+
continue
|
|
32
|
+
if hmac.compare_digest(
|
|
33
|
+
hmac.new(salt, host.encode(), hashlib.sha1).digest(), want):
|
|
34
|
+
return True
|
|
35
|
+
if port != 22 and hmac.compare_digest(
|
|
36
|
+
hmac.new(salt, f"[{host}]:{port}".encode(), hashlib.sha1).digest(), want):
|
|
37
|
+
return True
|
|
38
|
+
elif pat == host or (port != 22 and pat == f"[{host}]:{port}"):
|
|
39
|
+
return True
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _revoked_match(lines: list[str], host: str, port: int, blob_b64: str) -> bool:
|
|
44
|
+
"""
|
|
45
|
+
True if a @revoked line pins this exact key blob to this host.
|
|
46
|
+
|
|
47
|
+
Revocation is fail-closed under every policy: checked before any
|
|
48
|
+
trust decision, including accept-new (a revoked key must never be
|
|
49
|
+
re-learned).
|
|
50
|
+
"""
|
|
51
|
+
for ln in lines:
|
|
52
|
+
s = ln.strip()
|
|
53
|
+
if not s.startswith("@revoked"):
|
|
54
|
+
continue
|
|
55
|
+
p = s.split()
|
|
56
|
+
if len(p) < 4:
|
|
57
|
+
continue
|
|
58
|
+
if p[3] == blob_b64 and _match_host(p[1], host, port):
|
|
59
|
+
return True
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
_RSA_FAMILY = {"ssh-rsa", "rsa-sha2-256", "rsa-sha2-512"}
|
|
63
|
+
|
|
64
|
+
MAX_KNOWN_HOSTS_SIZE = 1 << 20
|
|
65
|
+
MAX_KNOWN_HOSTS_LINES = 20000
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _split_known_hosts(known_hosts: str | list[str] | None) -> list[str]:
|
|
69
|
+
"""
|
|
70
|
+
One path or many (OpenSSH splits UserKnownHostsFile on whitespace;
|
|
71
|
+
commas accepted too). The magic name `none` disables file use.
|
|
72
|
+
"""
|
|
73
|
+
if known_hosts is None:
|
|
74
|
+
return [os.path.expanduser("~/.ssh/known_hosts")]
|
|
75
|
+
if isinstance(known_hosts, str):
|
|
76
|
+
parts = [p for p in known_hosts.replace(",", " ").split() if p]
|
|
77
|
+
return [os.path.expanduser(p) for p in parts] or [os.path.expanduser("~/.ssh/known_hosts")]
|
|
78
|
+
return [os.path.expanduser(p) for p in known_hosts]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _read_known_hosts(kh: str) -> list[str]:
|
|
82
|
+
if kh == "/dev/null" or os.path.basename(kh).lower() == "none":
|
|
83
|
+
return []
|
|
84
|
+
if os.path.getsize(kh) > MAX_KNOWN_HOSTS_SIZE:
|
|
85
|
+
raise HostKeyError(f"{kh} too large, refusing to parse")
|
|
86
|
+
with open(kh, encoding="utf-8") as f:
|
|
87
|
+
lines = f.readlines()
|
|
88
|
+
if len(lines) > MAX_KNOWN_HOSTS_LINES:
|
|
89
|
+
raise HostKeyError(f"{kh} has too many entries")
|
|
90
|
+
return lines
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _append_known_host(kh: str, host: str, port: int, inner_alg: str, want: str) -> None:
|
|
94
|
+
"""Append a TOFU entry with symlink/permission hardening."""
|
|
95
|
+
if kh == "/dev/null" or os.path.basename(kh).lower() == "none":
|
|
96
|
+
return
|
|
97
|
+
if os.path.islink(kh):
|
|
98
|
+
raise HostKeyError(f"{kh} is a symlink, refusing to append (possible hijack)")
|
|
99
|
+
parent = os.path.dirname(kh)
|
|
100
|
+
if parent:
|
|
101
|
+
if os.path.islink(parent):
|
|
102
|
+
raise HostKeyError(f"{parent} is a symlink, refusing to append (possible hijack)")
|
|
103
|
+
os.makedirs(parent, mode=0o700, exist_ok=True)
|
|
104
|
+
try:
|
|
105
|
+
os.chmod(parent, 0o700)
|
|
106
|
+
except OSError:
|
|
107
|
+
pass
|
|
108
|
+
entry = f"{host} {inner_alg} {want}\n" if port == 22 else f"[{host}]:{port} {inner_alg} {want}\n"
|
|
109
|
+
# O_NOFOLLOW is POSIX-only; on Windows the flag is 0 (no symlink
|
|
110
|
+
# protection there - the islink() check above still applies).
|
|
111
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND | getattr(os, "O_NOFOLLOW", 0)
|
|
112
|
+
fd = os.open(kh, flags, 0o600)
|
|
113
|
+
try:
|
|
114
|
+
try:
|
|
115
|
+
os.fchmod(fd, 0o600)
|
|
116
|
+
except (OSError, AttributeError):
|
|
117
|
+
pass
|
|
118
|
+
with os.fdopen(fd, "a", encoding="utf-8") as f:
|
|
119
|
+
f.write(entry)
|
|
120
|
+
except Exception:
|
|
121
|
+
try:
|
|
122
|
+
os.close(fd)
|
|
123
|
+
except Exception:
|
|
124
|
+
pass
|
|
125
|
+
raise
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def check_hostkey(host: str, port: int, Ks: bytes, alg: str, *, policy: str, known_hosts: str | list[str] | None, ask_callback=None) -> None:
|
|
129
|
+
if policy not in ("strict", "accept-new", "off", "ask"):
|
|
130
|
+
raise HostKeyError(f"bad host policy {policy!r} (must be strict|accept-new|ask|off)")
|
|
131
|
+
if policy == "off":
|
|
132
|
+
return
|
|
133
|
+
if not host or any(c.isspace() or ord(c) < 32 for c in host):
|
|
134
|
+
raise HostKeyError(f"bad host name {host!r}")
|
|
135
|
+
try:
|
|
136
|
+
inner_alg, _ = HK.parse_host_blob(Ks)
|
|
137
|
+
except Exception as e:
|
|
138
|
+
raise HostKeyError(f"malformed K_S: {e}") from e
|
|
139
|
+
if inner_alg != alg and not (inner_alg in _RSA_FAMILY and alg in _RSA_FAMILY):
|
|
140
|
+
raise HostKeyError(f"host key type {inner_alg!r} != negotiated {alg!r}")
|
|
141
|
+
paths = _split_known_hosts(known_hosts)
|
|
142
|
+
# (path, lineno, text) so CHANGED errors cite the offending entry.
|
|
143
|
+
entries: list[tuple[str, int, str]] = []
|
|
144
|
+
for kh in paths:
|
|
145
|
+
try:
|
|
146
|
+
for lineno, text in enumerate(_read_known_hosts(kh), 1):
|
|
147
|
+
entries.append((kh, lineno, text))
|
|
148
|
+
except FileNotFoundError:
|
|
149
|
+
continue
|
|
150
|
+
except HostKeyError:
|
|
151
|
+
raise
|
|
152
|
+
except OSError as e:
|
|
153
|
+
# An unreadable file fails closed under every policy: treating
|
|
154
|
+
# it as empty would hand a MitM a learning window.
|
|
155
|
+
raise HostKeyError(f"cannot read {kh}: {e}") from e
|
|
156
|
+
lines = [text for _, _, text in entries]
|
|
157
|
+
want = base64.b64encode(Ks).decode()
|
|
158
|
+
if _revoked_match(lines, host, port, want):
|
|
159
|
+
raise HostKeyError(f"host key for {host} is REVOKED")
|
|
160
|
+
if inner_alg.endswith("-cert-v01@openssh.com") or inner_alg.endswith("-cert"):
|
|
161
|
+
from ccssh import certs as CERT
|
|
162
|
+
cas = CERT.load_ca_keys(paths, host, port)
|
|
163
|
+
if not cas:
|
|
164
|
+
raise HostKeyError(f"no @cert-authority for {host} in {','.join(paths)}")
|
|
165
|
+
try:
|
|
166
|
+
c = CERT.verify_cert(Ks, expected_principal=host, is_host=True, ca_blobs=cas)
|
|
167
|
+
except CERT.CertError as e:
|
|
168
|
+
raise HostKeyError(f"host certificate rejected: {e}") from e
|
|
169
|
+
ca_b64 = base64.b64encode(c["sig_key"]).decode()
|
|
170
|
+
if _revoked_match(lines, host, port, ca_b64):
|
|
171
|
+
raise HostKeyError(f"host certificate CA for {host} is REVOKED")
|
|
172
|
+
return
|
|
173
|
+
for kh, lineno, raw in entries:
|
|
174
|
+
ln = raw.strip()
|
|
175
|
+
if not ln or ln.startswith("#") or ln.startswith("@"):
|
|
176
|
+
continue
|
|
177
|
+
p = ln.split()
|
|
178
|
+
if len(p) < 3:
|
|
179
|
+
continue
|
|
180
|
+
if p[2] == want and _match_host(p[0], host, port):
|
|
181
|
+
return
|
|
182
|
+
same = (p[1] == inner_alg) or (p[1] in _RSA_FAMILY and inner_alg in _RSA_FAMILY)
|
|
183
|
+
if p[2] != want and _match_host(p[0], host, port) and same:
|
|
184
|
+
try:
|
|
185
|
+
old_fp = HK.fingerprint(base64.b64decode(p[2]))
|
|
186
|
+
except Exception:
|
|
187
|
+
old_fp = "<unparseable>"
|
|
188
|
+
try:
|
|
189
|
+
new_fp = HK.fingerprint(Ks)
|
|
190
|
+
except Exception:
|
|
191
|
+
new_fp = "<unparseable>"
|
|
192
|
+
raise HostKeyError(
|
|
193
|
+
f"HOST KEY CHANGED for {host} (possible MitM!)\n"
|
|
194
|
+
f"Offending key in {kh}:{lineno}\n"
|
|
195
|
+
f"Old {p[1]} fingerprint: {old_fp}\n"
|
|
196
|
+
f"New {inner_alg} fingerprint: {new_fp}\n"
|
|
197
|
+
f"Remove the old key with: ssh-keygen -R {host} "
|
|
198
|
+
f"(verify the new fingerprint out-of-band first!)")
|
|
199
|
+
if policy == "strict":
|
|
200
|
+
raise HostKeyError(f"{host} not in {','.join(paths)} (strict)")
|
|
201
|
+
if policy == "ask":
|
|
202
|
+
# Unknown host under ask: prompt (ssh-like). Changed keys above
|
|
203
|
+
# always fail, even under ask. Non-interactive callers pass
|
|
204
|
+
# ask_callback=None and fail closed like strict.
|
|
205
|
+
if ask_callback is None:
|
|
206
|
+
raise HostKeyError(f"{host} not in {','.join(paths)} (strict: no prompt available)")
|
|
207
|
+
try:
|
|
208
|
+
answer = ask_callback(
|
|
209
|
+
f"The authenticity of host '{host}' can't be established. "
|
|
210
|
+
f"{inner_alg} key fingerprint is {want[:44]}... "
|
|
211
|
+
f"Are you sure you want to continue connecting (yes/no)? ")
|
|
212
|
+
except Exception as e:
|
|
213
|
+
raise HostKeyError(f"host key prompt aborted: {e}") from e
|
|
214
|
+
if str(answer).strip().lower() not in ("yes", "y"):
|
|
215
|
+
raise HostKeyError(f"{host} not trusted (answer was not yes)")
|
|
216
|
+
append_to = next((kh for kh in paths if kh != "/dev/null" and os.path.basename(kh).lower() != "none"), paths[0] if paths else os.path.expanduser("~/.ssh/known_hosts"))
|
|
217
|
+
_append_known_host(append_to, host, port, inner_alg, want)
|
ccssh/hostkeys.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Host keys and userauth signatures: ssh-ed25519, ssh-mldsa44-ed25519,
|
|
3
|
+
ecdsa-sha2-nistp*, rsa-sha2-256/512, ssh-dss (legacy: disabled by default,
|
|
4
|
+
explicit opt-in only), plus *-cert variants.
|
|
5
|
+
|
|
6
|
+
Certificate blobs are parsed structurally here; CA trust (authorities,
|
|
7
|
+
principals, validity) lives in certs.py / hostcheck.py.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from cryptography.hazmat.primitives import hashes
|
|
12
|
+
from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa
|
|
13
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
14
|
+
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature, encode_dss_signature
|
|
15
|
+
|
|
16
|
+
from ccssh import mlkem
|
|
17
|
+
from ccssh import wire as W
|
|
18
|
+
from ccssh.mldsa import KeyPair as MldsaKeyPair
|
|
19
|
+
from ccssh.sk import SkKey
|
|
20
|
+
|
|
21
|
+
EC_HASH = {"ecdsa-sha2-nistp256": hashes.SHA256, "ecdsa-sha2-nistp384": hashes.SHA384, "ecdsa-sha2-nistp521": hashes.SHA512}
|
|
22
|
+
EC_CURVE = {"ecdsa-sha2-nistp256": ec.SECP256R1(), "ecdsa-sha2-nistp384": ec.SECP384R1(), "ecdsa-sha2-nistp521": ec.SECP521R1()}
|
|
23
|
+
EC_SIZE = {"ecdsa-sha2-nistp256": 32, "ecdsa-sha2-nistp384": 48, "ecdsa-sha2-nistp521": 66}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def fingerprint(blob: bytes) -> str:
|
|
27
|
+
"""OpenSSH-style key fingerprint: ``SHA256:<base64-without-padding>``."""
|
|
28
|
+
import base64 as _b64
|
|
29
|
+
import hashlib as _hl
|
|
30
|
+
digest = _hl.sha256(blob).digest()
|
|
31
|
+
return "SHA256:" + _b64.b64encode(digest).decode().rstrip("=")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_host_blob(blob: bytes) -> tuple[str, dict]:
|
|
35
|
+
r = W.Reader(blob)
|
|
36
|
+
alg = r.string().decode()
|
|
37
|
+
if alg == "ssh-ed25519":
|
|
38
|
+
pub = r.string()
|
|
39
|
+
if len(pub) != 32 or r.rest():
|
|
40
|
+
raise ValueError("bad ed25519 key length")
|
|
41
|
+
return alg, {"pub": pub}
|
|
42
|
+
if alg in EC_CURVE:
|
|
43
|
+
curve_name = r.string().decode() # e.g. "nistp256"
|
|
44
|
+
q = r.string()
|
|
45
|
+
exp = {"ecdsa-sha2-nistp256": "nistp256", "ecdsa-sha2-nistp384": "nistp384", "ecdsa-sha2-nistp521": "nistp521"}[alg]
|
|
46
|
+
if curve_name != exp:
|
|
47
|
+
raise ValueError(f"curve mismatch: {alg} carries {curve_name!r}")
|
|
48
|
+
if r.rest():
|
|
49
|
+
raise ValueError("trailing bytes in ecdsa host key")
|
|
50
|
+
return alg, {"curve_name": curve_name, "q": q}
|
|
51
|
+
if alg == "ssh-rsa":
|
|
52
|
+
e, n = r.mpint(), r.mpint()
|
|
53
|
+
if r.rest():
|
|
54
|
+
raise ValueError("trailing bytes in ssh-rsa host key")
|
|
55
|
+
return alg, {"e": e, "n": n}
|
|
56
|
+
if alg == "ssh-dss":
|
|
57
|
+
# Legacy algorithm (1024-bit DSA, removed from OpenSSH 10.x): parsed
|
|
58
|
+
# and verified when explicitly forced, never offered by default.
|
|
59
|
+
p, q, g, y = r.mpint(), r.mpint(), r.mpint(), r.mpint()
|
|
60
|
+
if r.rest():
|
|
61
|
+
raise ValueError("trailing bytes in ssh-dss host key")
|
|
62
|
+
return alg, {"p": p, "q": q, "g": g, "y": y}
|
|
63
|
+
if alg in ("ssh-mldsa44-ed25519",):
|
|
64
|
+
from ccssh import mldsa as _ML
|
|
65
|
+
pub = r.string()
|
|
66
|
+
if len(pub) != _ML.PK_LEN or r.rest():
|
|
67
|
+
raise ValueError("bad mldsa pub length")
|
|
68
|
+
return alg, {"pub": pub}
|
|
69
|
+
if alg.endswith("-cert-v01@openssh.com") or alg.endswith("-cert"):
|
|
70
|
+
return alg, {"cert_blob": blob} # opaque; see certs.py / cert_inner_plain
|
|
71
|
+
raise ValueError(f"unknown host key {alg!r}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _ec_pubkey(alg: str, q: bytes):
|
|
75
|
+
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey
|
|
76
|
+
curve = EC_CURVE[alg]
|
|
77
|
+
return EllipticCurvePublicKey.from_encoded_point(curve, q)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _dss_pubkey(p: int, q: int, g: int, y: int):
|
|
81
|
+
from cryptography.hazmat.primitives.asymmetric import dsa as _dsa
|
|
82
|
+
nums = _dsa.DSAPublicNumbers(y, _dsa.DSAParameterNumbers(p, q, g))
|
|
83
|
+
return nums.public_key()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cert_inner_plain(Ks: bytes) -> tuple[str, bytes]:
|
|
87
|
+
"""
|
|
88
|
+
Extract (inner_alg, plain_pub_blob) from a certificate blob.
|
|
89
|
+
|
|
90
|
+
CA trust is NOT checked here; see certs.py.
|
|
91
|
+
"""
|
|
92
|
+
from ccssh import certs as CERT
|
|
93
|
+
c = CERT.parse_cert(Ks)
|
|
94
|
+
kt = c["keytype"]
|
|
95
|
+
# ssh-keygen writes "ssh-ed25519-cert-v01@openssh.com" or bare "-cert" (mldsa)
|
|
96
|
+
if kt.endswith("-cert-v01@openssh.com"):
|
|
97
|
+
base = kt[:-len("-cert-v01@openssh.com")]
|
|
98
|
+
elif kt.endswith("-cert"):
|
|
99
|
+
base = kt[:-len("-cert")]
|
|
100
|
+
else:
|
|
101
|
+
raise ValueError(f"not a certificate type {kt!r}")
|
|
102
|
+
pub = c["pub"]
|
|
103
|
+
if base == "ssh-ed25519":
|
|
104
|
+
blob = W.enc_string(b"ssh-ed25519") + W.enc_string(pub["pub"])
|
|
105
|
+
elif base == "ssh-mldsa44-ed25519":
|
|
106
|
+
blob = W.enc_string(b"ssh-mldsa44-ed25519") + W.enc_string(pub["pub"])
|
|
107
|
+
elif base in EC_CURVE:
|
|
108
|
+
curve_ssh = {"ecdsa-sha2-nistp256": b"nistp256", "ecdsa-sha2-nistp384": b"nistp384", "ecdsa-sha2-nistp521": b"nistp521"}[base]
|
|
109
|
+
blob = W.enc_string(base.encode()) + W.enc_string(curve_ssh) + W.enc_string(pub["q"])
|
|
110
|
+
elif base == "ssh-rsa":
|
|
111
|
+
blob = W.enc_string(b"ssh-rsa") + W.enc_mpint(pub["e"]) + W.enc_mpint(pub["n"])
|
|
112
|
+
elif base == "ssh-dss":
|
|
113
|
+
blob = (W.enc_string(b"ssh-dss") + W.enc_mpint(pub["p"]) + W.enc_mpint(pub["q"]) + W.enc_mpint(pub["g"]) + W.enc_mpint(pub["y"]))
|
|
114
|
+
else:
|
|
115
|
+
raise ValueError(f"unsupported certificate inner key {base!r}")
|
|
116
|
+
return base, blob
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def verify_host_signature(Ks: bytes, host_alg: str, Hh: bytes, sig_outer: bytes) -> bool:
|
|
120
|
+
"""
|
|
121
|
+
Verify a KEX signature. ``host_alg`` is the negotiated algorithm.
|
|
122
|
+
|
|
123
|
+
For certificates the KEX signature is checked against the INNER key;
|
|
124
|
+
CA trust is verified separately by hostcheck/certs. Never raises.
|
|
125
|
+
"""
|
|
126
|
+
try:
|
|
127
|
+
r = W.Reader(sig_outer)
|
|
128
|
+
sig_alg = r.string().decode()
|
|
129
|
+
sig_blob = r.string()
|
|
130
|
+
except Exception:
|
|
131
|
+
return False
|
|
132
|
+
try:
|
|
133
|
+
alg, parts = parse_host_blob(Ks)
|
|
134
|
+
except Exception:
|
|
135
|
+
return False
|
|
136
|
+
if alg.endswith("-cert-v01@openssh.com") or alg.endswith("-cert"):
|
|
137
|
+
try:
|
|
138
|
+
alg, Ks = cert_inner_plain(Ks)
|
|
139
|
+
parts = parse_host_blob(Ks)[1]
|
|
140
|
+
except Exception:
|
|
141
|
+
return False
|
|
142
|
+
try:
|
|
143
|
+
if alg == "ssh-ed25519":
|
|
144
|
+
if sig_alg != "ssh-ed25519":
|
|
145
|
+
return False
|
|
146
|
+
return mlkem.verify_exchange_hash(parts["pub"], Hh, W.enc_string(b"ssh-ed25519") + W.enc_string(sig_blob))
|
|
147
|
+
if alg in EC_CURVE:
|
|
148
|
+
if sig_alg != alg:
|
|
149
|
+
return False
|
|
150
|
+
try:
|
|
151
|
+
rr = W.Reader(sig_blob)
|
|
152
|
+
r_int, s_int = rr.mpint(), rr.mpint()
|
|
153
|
+
rr.assert_end()
|
|
154
|
+
except Exception:
|
|
155
|
+
return False
|
|
156
|
+
der = encode_dss_signature(r_int, s_int)
|
|
157
|
+
_ec_pubkey(alg, parts["q"]).verify(der, Hh, ec.ECDSA(EC_HASH[alg]()))
|
|
158
|
+
return True
|
|
159
|
+
if alg == "ssh-rsa":
|
|
160
|
+
if host_alg in ("rsa-sha2-256", "rsa-sha2-512"):
|
|
161
|
+
if sig_alg != host_alg:
|
|
162
|
+
return False
|
|
163
|
+
elif sig_alg not in ("rsa-sha2-256", "rsa-sha2-512", "ssh-rsa"):
|
|
164
|
+
return False
|
|
165
|
+
h = hashes.SHA256() if sig_alg == "rsa-sha2-256" else (hashes.SHA512() if sig_alg == "rsa-sha2-512" else hashes.SHA1())
|
|
166
|
+
pub = rsa.RSAPublicNumbers(parts["e"], parts["n"]).public_key()
|
|
167
|
+
# SSH omits leading zeroes of s - pad back to modulus length.
|
|
168
|
+
# Longer-than-modulus signatures are rejected (no silent truncation).
|
|
169
|
+
k = (pub.key_size + 7) // 8
|
|
170
|
+
if len(sig_blob) > k:
|
|
171
|
+
return False
|
|
172
|
+
sig = sig_blob.rjust(k, b"\x00")
|
|
173
|
+
pub.verify(sig, Hh, padding.PKCS1v15(), h)
|
|
174
|
+
return True
|
|
175
|
+
if alg == "ssh-mldsa44-ed25519":
|
|
176
|
+
if sig_alg != "ssh-mldsa44-ed25519":
|
|
177
|
+
return False
|
|
178
|
+
from ccssh import mldsa as _ML
|
|
179
|
+
blob = W.enc_string(b"ssh-mldsa44-ed25519") + W.enc_string(parts["pub"])
|
|
180
|
+
return _ML.verify_nested(blob, Hh, sig_outer)
|
|
181
|
+
if alg == "ssh-dss":
|
|
182
|
+
# Legacy: 1024-bit DSA with SHA-1. Verified only when forced.
|
|
183
|
+
if sig_alg != "ssh-dss":
|
|
184
|
+
return False
|
|
185
|
+
if len(sig_blob) != 40:
|
|
186
|
+
return False
|
|
187
|
+
r_int = int.from_bytes(sig_blob[:20], "big")
|
|
188
|
+
s_int = int.from_bytes(sig_blob[20:], "big")
|
|
189
|
+
der = encode_dss_signature(r_int, s_int)
|
|
190
|
+
try:
|
|
191
|
+
_dss_pubkey(parts["p"], parts["q"], parts["g"], parts["y"]).verify(der, Hh, hashes.SHA1())
|
|
192
|
+
except Exception:
|
|
193
|
+
return False
|
|
194
|
+
return True
|
|
195
|
+
except Exception:
|
|
196
|
+
return False
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def sign_userauth(priv, alg: str, data: bytes) -> bytes:
|
|
201
|
+
"""
|
|
202
|
+
Sign a userauth blob. Returns the nested buffer as sshkey_sign does:
|
|
203
|
+
string(alg) + string(sigblob). The caller wraps it into the signature
|
|
204
|
+
string field. Raises ValueError for unsupported key types.
|
|
205
|
+
"""
|
|
206
|
+
if isinstance(priv, MldsaKeyPair):
|
|
207
|
+
if alg != "ssh-mldsa44-ed25519":
|
|
208
|
+
raise ValueError(f"mldsa key with alg {alg}")
|
|
209
|
+
return priv.sign_nested(data)
|
|
210
|
+
if isinstance(priv, SkKey):
|
|
211
|
+
if alg != priv.alg:
|
|
212
|
+
raise ValueError(f"sk key with alg {alg}")
|
|
213
|
+
return priv.sign_nested(data)
|
|
214
|
+
if isinstance(priv, Ed25519PrivateKey):
|
|
215
|
+
sig = priv.sign(data)
|
|
216
|
+
if len(sig) != 64:
|
|
217
|
+
raise ValueError("bad ed25519 signature length")
|
|
218
|
+
return W.enc_string(b"ssh-ed25519") + W.enc_string(sig)
|
|
219
|
+
if isinstance(priv, ec.EllipticCurvePrivateKey):
|
|
220
|
+
try:
|
|
221
|
+
hash_cls = EC_HASH[alg]
|
|
222
|
+
except KeyError:
|
|
223
|
+
raise ValueError(f"unsupported EC user key {alg}") from None
|
|
224
|
+
der = priv.sign(data, ec.ECDSA(hash_cls()))
|
|
225
|
+
r_int, s_int = decode_dss_signature(der)
|
|
226
|
+
return W.enc_string(alg.encode()) + W.enc_string(W.enc_mpint(r_int) + W.enc_mpint(s_int))
|
|
227
|
+
if isinstance(priv, rsa.RSAPrivateKey):
|
|
228
|
+
sigtype = "rsa-sha2-256" if alg in ("rsa-sha2-256", "ssh-rsa") else "rsa-sha2-512"
|
|
229
|
+
h = hashes.SHA256() if sigtype == "rsa-sha2-256" else hashes.SHA512()
|
|
230
|
+
sig = priv.sign(data, padding.PKCS1v15(), h)
|
|
231
|
+
k = (priv.key_size + 7) // 8
|
|
232
|
+
if len(sig) < k:
|
|
233
|
+
sig = sig.rjust(k, b"\x00")
|
|
234
|
+
return W.enc_string(sigtype.encode()) + W.enc_string(sig)
|
|
235
|
+
try:
|
|
236
|
+
from cryptography.hazmat.primitives.asymmetric import dsa as _dsa_mod
|
|
237
|
+
_dsa_types = (_dsa_mod.DSAPrivateKey,)
|
|
238
|
+
except Exception:
|
|
239
|
+
_dsa_types = ()
|
|
240
|
+
if _dsa_types and isinstance(priv, _dsa_types):
|
|
241
|
+
# Legacy user key: raw 40-byte r||s (RFC 4253 section 6.6), SHA-1.
|
|
242
|
+
if alg != "ssh-dss":
|
|
243
|
+
raise ValueError(f"dss key with alg {alg}")
|
|
244
|
+
der = priv.sign(data, hashes.SHA1())
|
|
245
|
+
r_int, s_int = decode_dss_signature(der)
|
|
246
|
+
raw = r_int.to_bytes(20, "big") + s_int.to_bytes(20, "big")
|
|
247
|
+
if len(raw) != 40:
|
|
248
|
+
raise ValueError("bad dss signature length")
|
|
249
|
+
return W.enc_string(b"ssh-dss") + W.enc_string(raw)
|
|
250
|
+
raise ValueError(f"unsupported private key type {type(priv)}")
|