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/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)}")