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/mldsa.py ADDED
@@ -0,0 +1,210 @@
1
+ """
2
+ ssh-mldsa44-ed25519 composite signatures (OpenSSH 10.6).
3
+
4
+ Format reverse-engineered from OpenSSH 10.6 (ssh-mldsa-eddsa.c,
5
+ draft-miller-sshm-mldsa44-ed25519-composite-sigs-00, LAMPS composite-18):
6
+
7
+ M' = "CompositeAlgorithmSignatures2025"
8
+ + "COMPSIG-MLDSA44-Ed25519-SHA512"
9
+ + u8(0) + SHA512(message)
10
+ sig = ML-DSA-44(M', context=LABEL) [2420] || Ed25519(M') [64]
11
+ PK = mldsa_pub [1312] || ed_pub [32]
12
+ SK seeds = mldsa_seed [32] || ed_seed [32] (openssh-key-v1 private field)
13
+
14
+ Wire:
15
+ pubkey blob = string("ssh-mldsa44-ed25519") + string(PK)
16
+ sig field = string( string("ssh-mldsa44-ed25519") + string(sig) )
17
+
18
+ Validated byte-for-byte against real ssh-keygen(10.6) keys and a live
19
+ sshd(10.6)→client signature capture.
20
+ """
21
+ from __future__ import annotations
22
+ import base64
23
+ import hashlib
24
+ import os
25
+
26
+ from ccssh import wire as W
27
+
28
+ ALG = b"ssh-mldsa44-ed25519"
29
+ ALG_CERT = b"ssh-mldsa44-ed25519-cert"
30
+ PREFIX = b"CompositeAlgorithmSignatures2025"
31
+ LABEL = b"COMPSIG-MLDSA44-Ed25519-SHA512"
32
+
33
+ MLDSA_PK_LEN = 1312
34
+ ED_PK_LEN = 32
35
+ PK_LEN = MLDSA_PK_LEN + ED_PK_LEN
36
+ MLDSA_SIG_LEN = 2420
37
+ ED_SIG_LEN = 64
38
+ SIG_LEN = MLDSA_SIG_LEN + ED_SIG_LEN
39
+ SEED_LEN = 64 # mldsa_seed32 || ed_seed32
40
+
41
+
42
+ def _require_backend():
43
+ try:
44
+ from cryptography.hazmat.primitives.asymmetric.mldsa import MLDSA44PrivateKey, MLDSA44PublicKey
45
+ except ImportError as e:
46
+ raise ImportError("cryptography without ML-DSA support") from e
47
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
48
+ return MLDSA44PrivateKey, MLDSA44PublicKey, Ed25519PrivateKey, Ed25519PublicKey
49
+
50
+
51
+ def m_prime(message: bytes, context: bytes = b"") -> bytes:
52
+ if len(context) > 255:
53
+ raise ValueError("context too long")
54
+ return PREFIX + LABEL + bytes([len(context)]) + context + hashlib.sha512(message).digest()
55
+
56
+
57
+ class KeyPair:
58
+ """In-memory composite keypair."""
59
+
60
+ def __init__(self, mldsa_priv, ed_priv):
61
+ self.mldsa_priv = mldsa_priv
62
+ self.ed_priv = ed_priv
63
+
64
+ @classmethod
65
+ def generate(cls) -> "KeyPair":
66
+ MLDSA44PrivateKey, _, Ed25519PrivateKey, _ = _require_backend()
67
+ return cls(MLDSA44PrivateKey.generate(), Ed25519PrivateKey.generate())
68
+
69
+ @classmethod
70
+ def from_seeds(cls, mldsa_seed: bytes, ed_seed: bytes) -> "KeyPair":
71
+ MLDSA44PrivateKey, _, Ed25519PrivateKey, _ = _require_backend()
72
+ if len(mldsa_seed) != 32 or len(ed_seed) != 32:
73
+ raise ValueError("seeds must be 32 bytes each")
74
+ return cls(MLDSA44PrivateKey.from_seed_bytes(mldsa_seed), Ed25519PrivateKey.from_private_bytes(ed_seed))
75
+
76
+ def public_blob(self) -> bytes:
77
+ """Full SSH public key blob."""
78
+ _require_backend() # fail fast without ML-DSA support
79
+ mp = self.mldsa_priv.public_key().public_bytes_raw()
80
+ ep = self.ed_priv.public_key().public_bytes_raw()
81
+ return W.enc_string(ALG) + W.enc_string(mp + ep)
82
+
83
+ def public_line(self, comment: str = "") -> str:
84
+ body = self.public_blob()
85
+ r = W.Reader(body)
86
+ r.string()
87
+ pk = r.string()
88
+ suffix = f" {comment}" if comment else ""
89
+ return f"ssh-mldsa44-ed25519 {base64.b64encode(pk).decode()}{suffix}"
90
+
91
+ def sign_nested(self, message: bytes) -> bytes:
92
+ """sshkey_sign output: string(alg) + string(sig)."""
93
+ _require_backend() # fail fast without ML-DSA support
94
+ mp = m_prime(message)
95
+ s1 = self.mldsa_priv.sign(mp, context=LABEL)
96
+ s2 = self.ed_priv.sign(mp)
97
+ if len(s1) != MLDSA_SIG_LEN or len(s2) != ED_SIG_LEN:
98
+ raise ValueError("bad composite signature part lengths")
99
+ return W.enc_string(ALG) + W.enc_string(s1 + s2)
100
+
101
+ def seeds(self) -> bytes:
102
+ """mldsa_seed32 || ed_seed32 (openssh-key-v1 private field layout)."""
103
+ return self.mldsa_priv.private_bytes_raw() + self.ed_priv.private_bytes_raw()
104
+
105
+
106
+ def parse_pub_blob(blob: bytes) -> tuple[bytes, bytes]:
107
+ """Split public key blob -> (mldsa_pub, ed_pub)."""
108
+ r = W.Reader(blob)
109
+ alg = r.string()
110
+ if alg not in (ALG, ALG_CERT):
111
+ raise ValueError(f"not an mldsa key: {alg!r}")
112
+ pk = r.string()
113
+ if len(pk) != PK_LEN or r.rest():
114
+ raise ValueError("bad mldsa pub length")
115
+ return pk[:MLDSA_PK_LEN], pk[MLDSA_PK_LEN:]
116
+
117
+
118
+ def verify_nested(pub_blob: bytes, message: bytes, sig_nested: bytes) -> bool:
119
+ """Verify sshkey_sign-style nested signature. Never raises."""
120
+ try:
121
+ _, MLDSA44PublicKey, _, Ed25519PublicKey = _require_backend()
122
+ r = W.Reader(sig_nested)
123
+ if r.string() not in (ALG, ALG_CERT):
124
+ return False
125
+ sig = r.string()
126
+ if len(sig) != SIG_LEN or r.rest():
127
+ return False
128
+ mp1, ep = parse_pub_blob(pub_blob)
129
+ mp = m_prime(message)
130
+ MLDSA44PublicKey.from_public_bytes(mp1).verify(sig[:MLDSA_SIG_LEN], mp, context=LABEL)
131
+ Ed25519PublicKey.from_public_bytes(ep).verify(sig[MLDSA_SIG_LEN:], mp)
132
+ return True
133
+ except Exception:
134
+ return False
135
+
136
+
137
+ def load_openssh_private(path: str, passphrase: bytes | None = None) -> KeyPair:
138
+ """Load ssh-keygen(10.6+) mldsa44-ed25519 openssh-key-v1 file."""
139
+ with open(os.path.expanduser(path), "rb") as f:
140
+ pem = f.read().decode()
141
+ if passphrase is not None:
142
+ raise ValueError("encrypted mldsa keys: use ssh-agent or decrypt first (TODO)")
143
+ b64 = "".join(l for l in pem.splitlines() if l and not l.startswith("-----"))
144
+ raw = base64.b64decode(b64)
145
+ if not raw.startswith(b"openssh-key-v1\x00"):
146
+ raise ValueError("not an openssh-key-v1 file")
147
+ r = W.Reader(raw[15:])
148
+ if r.string() != b"none":
149
+ raise ValueError("encrypted key (TODO passphrase support)")
150
+ r.string()
151
+ r.string()
152
+ if r.u32() != 1:
153
+ raise ValueError("expected exactly 1 key")
154
+ r.string() # public blob
155
+ priv = r.string()
156
+ pr = W.Reader(priv)
157
+ if pr.u32() != pr.u32():
158
+ raise ValueError("checkint mismatch (wrong passphrase?)")
159
+ if pr.string() != ALG:
160
+ raise ValueError("not an mldsa44-ed25519 key")
161
+ pr.string() # public part inside private section
162
+ seeds = pr.string()
163
+ if len(seeds) != SEED_LEN:
164
+ raise ValueError("bad seed length")
165
+ return KeyPair.from_seeds(seeds[:32], seeds[32:])
166
+
167
+
168
+ def save_openssh_private(path: str, kp: KeyPair, comment: str = "") -> None:
169
+ """Write unencrypted openssh-key-v1 file (inverse of load_openssh_private)."""
170
+ check = int.from_bytes(os.urandom(4), "big") or 1
171
+ pub_blob = kp.public_blob()
172
+ # pub blob inside private section = the 1344B pk string (NOT the full blob)
173
+ r = W.Reader(pub_blob)
174
+ r.string()
175
+ pk = r.string()
176
+ priv_sec = (W.enc_u32(check)
177
+ + W.enc_u32(check)
178
+ + W.enc_string(ALG)
179
+ + W.enc_string(pk)
180
+ + W.enc_string(kp.seeds())
181
+ + W.enc_string(comment.encode()))
182
+ padlen = (-len(priv_sec)) % 8
183
+ priv_sec += bytes(range(1, padlen + 1))
184
+ raw = (b"openssh-key-v1\x00"
185
+ + W.enc_string(b"none")
186
+ + W.enc_string(b"none")
187
+ + W.enc_string(b"")
188
+ + W.enc_u32(1)
189
+ + W.enc_string(pub_blob)
190
+ + W.enc_string(priv_sec))
191
+ pem = "-----BEGIN OPENSSH PRIVATE KEY-----\n"
192
+ b64 = base64.b64encode(raw).decode()
193
+ pem += "\n".join(b64[i:i + 70] for i in range(0, len(b64), 70)) + "\n"
194
+ pem += "-----END OPENSSH PRIVATE KEY-----\n"
195
+ fd = os.open(os.path.expanduser(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
196
+ try:
197
+ os.fchmod(fd, 0o600)
198
+ except Exception:
199
+ pass
200
+ with os.fdopen(fd, "w") as f:
201
+ f.write(pem)
202
+
203
+
204
+ # Backwards-compatible helper.
205
+ def available() -> bool:
206
+ try:
207
+ _require_backend()
208
+ return True
209
+ except Exception:
210
+ return False
ccssh/mlkem.py ADDED
@@ -0,0 +1,367 @@
1
+ """
2
+ Hybrid post-quantum key exchange mlkem768x25519-sha256.
3
+
4
+ Matches OpenSSH >= 9.9 / 10.0 (IETF draft-kampanakis-curdle-ssh-pq-ke,
5
+ OpenSSH kexmlkem768x25519.c):
6
+
7
+ client -> server: SSH_MSG_KEX_HYBRID_INIT (30)
8
+ C_INIT = ek_pq (1184 B) || ephemeral_pub_x25519 (32 B) = 1216 B
9
+ server -> client: SSH_MSG_KEX_HYBRID_REPLY (31)
10
+ K_S (host key blob), S_REPLY = ct_pq (1088 B) || ephemeral_pub_x25519_s
11
+ (32 B) = 1120 B, signature over H with the host key
12
+
13
+ K_PQ = ML-KEM-768 shared secret, 32 B
14
+ K_ECDH = X25519 shared secret, 32 B
15
+ K = SHA256(K_PQ || K_ECDH), 32 B
16
+
17
+ H = SHA256(string(V_C) || string(V_S) || string(I_C) || string(I_S)
18
+ || string(K_S) || string(C_INIT) || string(S_REPLY) || string(K))
19
+
20
+ Note: K is encoded as an SSH string (uint32 BE length + raw bytes), NOT as
21
+ an mpint. The same encoding feeds both H and the RFC 4253 section 7.2 KDF.
22
+
23
+ Pure in-memory library without I/O; tests drive both sides in one process.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import hashlib
28
+ import os
29
+ from dataclasses import dataclass
30
+
31
+ from cryptography.exceptions import InvalidSignature
32
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
33
+ from cryptography.hazmat.primitives.asymmetric.mlkem import MLKEM768PrivateKey, MLKEM768PublicKey
34
+ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
35
+
36
+ from ccssh import wire as W
37
+ from ccssh.mldsa import KeyPair as MldsaKeyPair
38
+
39
+ # sizes in bytes, matching OpenSSH
40
+ MLKEM768_EK_LEN = 1184
41
+ MLKEM768_CT_LEN = 1088
42
+ MLKEM768_SS_LEN = 32
43
+ X25519_PUB_LEN = 32
44
+ X25519_SS_LEN = 32
45
+ C_INIT_LEN = MLKEM768_EK_LEN + X25519_PUB_LEN # 1216
46
+ S_REPLY_LEN = MLKEM768_CT_LEN + X25519_PUB_LEN # 1120
47
+ K_LEN = 32 # SHA256 digest
48
+
49
+ HOSTKEY_ALG = b"ssh-ed25519"
50
+
51
+ ssh_string = W.enc_string
52
+
53
+
54
+ def sha256(*parts: bytes) -> bytes:
55
+ h = hashlib.sha256()
56
+ for p in parts:
57
+ h.update(p)
58
+ return h.digest()
59
+
60
+
61
+ def generate_hostkey(seed: bytes | None = None) -> Ed25519PrivateKey:
62
+ """Generate an Ed25519 host key (32-byte seed for determinism, else random)."""
63
+ if seed is not None:
64
+ if len(seed) != 32:
65
+ raise ValueError("Ed25519 seed must be 32 bytes")
66
+ return Ed25519PrivateKey.from_private_bytes(seed)
67
+ return Ed25519PrivateKey.generate()
68
+
69
+
70
+ def hostkey_blob_from_pubkey(pub_raw: bytes) -> bytes:
71
+ """K_S blob: string('ssh-ed25519') + string(pub 32B) = 51 B."""
72
+ if len(pub_raw) != 32:
73
+ raise ValueError("Ed25519 public key must be 32 bytes")
74
+ return ssh_string(HOSTKEY_ALG) + ssh_string(pub_raw)
75
+
76
+
77
+ def hostkey_blob_from_priv(priv: Ed25519PrivateKey | MldsaKeyPair) -> bytes:
78
+ if isinstance(priv, MldsaKeyPair):
79
+ return priv.public_blob()
80
+ pub_raw = priv.public_key().public_bytes_raw()
81
+ return hostkey_blob_from_pubkey(pub_raw)
82
+
83
+
84
+ def parse_hostkey_blob(blob: bytes) -> bytes:
85
+ """Extract the 32-byte raw key from an ssh-ed25519 K_S blob."""
86
+ alg, off = W.dec_string(blob, 0)
87
+ pub, off2 = W.dec_string(blob, off)
88
+ if alg != HOSTKEY_ALG:
89
+ raise ValueError(f"unknown host key alg: {alg!r}")
90
+ if len(pub) != 32 or off2 != len(blob):
91
+ raise ValueError("malformed K_S blob")
92
+ return pub
93
+
94
+
95
+ def sign_exchange_hash(host_priv: Ed25519PrivateKey | MldsaKeyPair, H: bytes) -> bytes:
96
+ """
97
+ Sign H. Returns the inner signature blob: string(alg) + string(sig).
98
+
99
+ On the wire it is additionally wrapped in string(); the library and
100
+ tests pass the inner form around, which is what verify parses.
101
+ """
102
+ if isinstance(host_priv, MldsaKeyPair):
103
+ return host_priv.sign_nested(H)
104
+ sig_raw = host_priv.sign(H) # 64 B
105
+ return ssh_string(HOSTKEY_ALG) + ssh_string(sig_raw)
106
+
107
+
108
+ def verify_exchange_hash(host_pub_raw: bytes, H: bytes, sig_inner: bytes) -> bool:
109
+ """Verify a host signature over H. Returns True/False, never raises."""
110
+ try:
111
+ alg, off = W.dec_string(sig_inner, 0)
112
+ sig_raw, off2 = W.dec_string(sig_inner, off)
113
+ if alg != HOSTKEY_ALG or len(sig_raw) != 64 or off2 != len(sig_inner):
114
+ return False
115
+ Ed25519PublicKey.from_public_bytes(host_pub_raw).verify(sig_raw, H)
116
+ return True
117
+ except (ValueError, InvalidSignature):
118
+ return False
119
+
120
+
121
+ # core KEX
122
+ def check_secret_nonzero(secret: bytes, what: str = "X25519") -> None:
123
+ """
124
+ Reject an all-zero shared secret: a low-order (contributory-failure)
125
+ peer point. Some backends return zeros instead of raising.
126
+ """
127
+ if secret == bytes(len(secret)):
128
+ raise ValueError(f"{what} shared secret is zero")
129
+
130
+
131
+ def combine_shared(K_pq: bytes, K_ecdh: bytes) -> bytes:
132
+ """
133
+ K = SHA256(K_PQ || K_ECDH). Both inputs are 32 bytes.
134
+ """
135
+ if len(K_pq) != MLKEM768_SS_LEN or len(K_ecdh) != X25519_SS_LEN:
136
+ raise ValueError("K_PQ and K_ECDH must be 32 bytes each")
137
+ return sha256(K_pq, K_ecdh)
138
+
139
+
140
+ def compute_exchange_hash(V_C: bytes, V_S: bytes, I_C: bytes, I_S: bytes, K_S: bytes, C_INIT: bytes, S_REPLY: bytes, K_raw: bytes) -> bytes:
141
+ """Exchange hash exactly like OpenSSH: every field as ssh_string."""
142
+ if len(C_INIT) != C_INIT_LEN:
143
+ raise ValueError(f"C_INIT must be {C_INIT_LEN} bytes, got {len(C_INIT)}")
144
+ if len(S_REPLY) != S_REPLY_LEN:
145
+ raise ValueError(f"S_REPLY must be {S_REPLY_LEN} bytes, got {len(S_REPLY)}")
146
+ if len(K_raw) != K_LEN:
147
+ raise ValueError(f"K must be {K_LEN} bytes")
148
+ return sha256(
149
+ ssh_string(V_C),
150
+ ssh_string(V_S),
151
+ ssh_string(I_C),
152
+ ssh_string(I_S),
153
+ ssh_string(K_S),
154
+ ssh_string(C_INIT),
155
+ ssh_string(S_REPLY),
156
+ ssh_string(K_raw),
157
+ )
158
+
159
+
160
+ def derive_key(K_raw: bytes, H: bytes, session_id: bytes, letter: bytes, out_len: int) -> bytes:
161
+ """
162
+ RFC 4253 section 7.2 KDF with K passed as a string (hybrid profile).
163
+
164
+ K1 = HASH(K || H || X || session_id)
165
+ K2 = HASH(K || H || K1)
166
+ K3 = HASH(K || H || K1 || K2) ...
167
+ """
168
+ if len(letter) != 1 or len(K_raw) != K_LEN:
169
+ raise ValueError("letter must be 1 byte and K must be 32 bytes")
170
+ K_enc = ssh_string(K_raw)
171
+ out = sha256(K_enc, H, letter, session_id)
172
+ while len(out) < out_len:
173
+ out += sha256(K_enc, H, out)
174
+ return out[:out_len]
175
+
176
+
177
+ def derive_session_keys(K_raw: bytes, H: bytes, session_id: bytes, *, cipher: str = "aes256-ctr", mac: str = "hmac-sha2-256", hashfn=None) -> dict[str, bytes]:
178
+ """
179
+ Standard six RFC 4253 keys sized per cipher/MAC (not a flat 32B).
180
+
181
+ ``hashfn`` must be the KEX hash (no implicit default: SHA-256 would be
182
+ wrong for SHA-512 KEX methods).
183
+ """
184
+ from ccssh import crypto as _C
185
+ if hashfn is None:
186
+ raise ValueError("derive_session_keys requires the KEX hash")
187
+ try:
188
+ cs = _C.CIPHER_SPECS[cipher]
189
+ except KeyError:
190
+ try:
191
+ cs = _C.CBC_SPECS[cipher]
192
+ except KeyError as e:
193
+ raise ValueError(f"unsupported cipher {cipher}") from e
194
+ try:
195
+ ms = _C.MAC_SPECS[mac]
196
+ except KeyError as e:
197
+ raise ValueError(f"unsupported mac {mac}") from e
198
+ letters = (("iv_c2s", "A", cs["iv_len"]), ("iv_s2c", "B", cs["iv_len"]),
199
+ ("enc_c2s", "C", cs["key_len"]), ("enc_s2c", "D", cs["key_len"]),
200
+ ("mac_c2s", "E", ms["key_len"]), ("mac_s2c", "F", ms["key_len"]))
201
+ return {k: derive_key(K_raw, H, session_id, c.encode(), n) for k, c, n in letters}
202
+
203
+
204
+ #: X25519 low-order points (RFC 8731 §3.1): never accept as a peer share.
205
+ X25519_LOW_ORDER = frozenset({
206
+ bytes.fromhex("0000000000000000000000000000000000000000000000000000000000000000"),
207
+ bytes.fromhex("0100000000000000000000000000000000000000000000000000000000000000"),
208
+ bytes.fromhex("e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b800"),
209
+ bytes.fromhex("5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f1157"),
210
+ bytes.fromhex("ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"),
211
+ bytes.fromhex("edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"),
212
+ bytes.fromhex("eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"),
213
+ bytes.fromhex("cdeb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b880"),
214
+ bytes.fromhex("4c9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f11d7"),
215
+ bytes.fromhex("d9ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"),
216
+ bytes.fromhex("daffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"),
217
+ bytes.fromhex("dbffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"),
218
+ })
219
+
220
+
221
+ def reject_low_order_x25519(q: bytes) -> None:
222
+ if q in X25519_LOW_ORDER:
223
+ raise ValueError("low-order X25519 peer key rejected")
224
+
225
+
226
+ @dataclass
227
+ class ClientState:
228
+ V_C: bytes
229
+ I_C: bytes
230
+ mlkem_sk: MLKEM768PrivateKey
231
+ mlkem_ek: bytes # 1184
232
+ x_sk: X25519PrivateKey
233
+ x_pk_raw: bytes # 32
234
+ C_INIT: bytes # 1216
235
+
236
+
237
+ @dataclass
238
+ class Session:
239
+ role: str # "client" | "server"
240
+ K_pq: bytes
241
+ K_ecdh: bytes
242
+ K: bytes # 32, SHA256(K_PQ||K_ECDH)
243
+ H: bytes # 32, exchange hash
244
+ session_id: bytes # H of the first exchange
245
+ V_C: bytes
246
+ V_S: bytes
247
+ I_C: bytes
248
+ I_S: bytes
249
+ K_S: bytes
250
+ C_INIT: bytes
251
+ S_REPLY: bytes
252
+
253
+ def derive(self, letter: str, nbytes: int = 32) -> bytes:
254
+ return derive_key(self.K, self.H, self.session_id, letter.encode(), nbytes)
255
+
256
+
257
+ def client_create(V_C: bytes, I_C: bytes, *, mlkem_seed: bytes | None = None, x_seed: bytes | None = None) -> ClientState:
258
+ """Client step 1: generate ephemeral ML-KEM + ephemeral X25519, build C_INIT."""
259
+ if mlkem_seed is not None:
260
+ if len(mlkem_seed) != 64:
261
+ raise ValueError("mlkem_seed must be 64 bytes (private_bytes_raw)")
262
+ mlkem_sk = MLKEM768PrivateKey.from_seed_bytes(mlkem_seed)
263
+ else:
264
+ mlkem_sk = MLKEM768PrivateKey.generate()
265
+ mlkem_ek = mlkem_sk.public_key().public_bytes_raw()
266
+ if len(mlkem_ek) != MLKEM768_EK_LEN:
267
+ raise ValueError("bad ML-KEM-768 ek length")
268
+
269
+ if x_seed is not None:
270
+ if len(x_seed) != 32:
271
+ raise ValueError("x_seed must be 32 bytes")
272
+ x_sk = X25519PrivateKey.from_private_bytes(x_seed)
273
+ else:
274
+ x_sk = X25519PrivateKey.generate()
275
+ x_pk_raw = x_sk.public_key().public_bytes_raw()
276
+
277
+ c_init = mlkem_ek + x_pk_raw
278
+ if len(c_init) != C_INIT_LEN:
279
+ raise ValueError("bad C_INIT length")
280
+ return ClientState(V_C, I_C, mlkem_sk, mlkem_ek, x_sk, x_pk_raw, c_init)
281
+
282
+
283
+ def server_process_init(*, host_priv: Ed25519PrivateKey | MldsaKeyPair, V_C: bytes, V_S: bytes, I_C: bytes, I_S: bytes, C_INIT: bytes, x_seed: bytes | None = None) -> tuple[bytes, bytes, bytes, Session]:
284
+ """Server step 2. Returns (S_REPLY, K_S, sig_inner, server_session)."""
285
+ if len(C_INIT) != C_INIT_LEN:
286
+ raise ValueError(f"C_INIT must be {C_INIT_LEN} bytes")
287
+ ek = C_INIT[:MLKEM768_EK_LEN]
288
+ x_pk_c_raw = C_INIT[MLKEM768_EK_LEN:]
289
+
290
+ if x_seed is not None:
291
+ x_sk_s = X25519PrivateKey.from_private_bytes(x_seed)
292
+ else:
293
+ x_sk_s = X25519PrivateKey.generate()
294
+ x_pk_s_raw = x_sk_s.public_key().public_bytes_raw()
295
+
296
+ """
297
+ ML-KEM encapsulation to the client's ek: (ss 32B, ct 1088B).
298
+ Decapsulation failures never raise (implicit rejection per FIPS 203).
299
+ """
300
+ peer_ek = MLKEM768PublicKey.from_public_bytes(ek)
301
+ K_pq, ct = peer_ek.encapsulate()
302
+ if len(K_pq) != 32 or len(ct) != MLKEM768_CT_LEN:
303
+ raise ValueError("bad ML-KEM-768 encapsulate output")
304
+
305
+ reject_low_order_x25519(x_pk_c_raw)
306
+ K_ecdh = x_sk_s.exchange(X25519PublicKey.from_public_bytes(x_pk_c_raw))
307
+ check_secret_nonzero(K_ecdh)
308
+ K_raw = combine_shared(K_pq, K_ecdh)
309
+
310
+ s_reply = ct + x_pk_s_raw
311
+ k_s = hostkey_blob_from_priv(host_priv)
312
+ H = compute_exchange_hash(V_C, V_S, I_C, I_S, k_s, C_INIT, s_reply, K_raw)
313
+ sig = sign_exchange_hash(host_priv, H)
314
+
315
+ sess = Session("server", K_pq, K_ecdh, K_raw, H, H, V_C, V_S, I_C, I_S, k_s, C_INIT, s_reply)
316
+ return s_reply, k_s, sig, sess
317
+
318
+
319
+ def client_process_reply(st: ClientState, *, V_S: bytes, I_S: bytes, K_S: bytes, S_REPLY: bytes, sig_inner: bytes, host_alg: str = "ssh-ed25519") -> Session:
320
+ """Client step 3: decapsulate + ECDH + H + verify. Raises ValueError on bad signature."""
321
+ if len(S_REPLY) != S_REPLY_LEN:
322
+ raise ValueError(f"S_REPLY must be {S_REPLY_LEN} bytes")
323
+ ct = S_REPLY[:MLKEM768_CT_LEN]
324
+ x_pk_s_raw = S_REPLY[MLKEM768_CT_LEN:]
325
+
326
+ K_pq = st.mlkem_sk.decapsulate(ct)
327
+ reject_low_order_x25519(x_pk_s_raw)
328
+ K_ecdh = st.x_sk.exchange(X25519PublicKey.from_public_bytes(x_pk_s_raw))
329
+ check_secret_nonzero(K_ecdh)
330
+ K_raw = combine_shared(K_pq, K_ecdh)
331
+
332
+ H = compute_exchange_hash(st.V_C, V_S, st.I_C, I_S, K_S, st.C_INIT, S_REPLY, K_raw)
333
+
334
+ if host_alg == "ssh-ed25519":
335
+ host_pub = parse_hostkey_blob(K_S)
336
+ ok = verify_exchange_hash(host_pub, H, sig_inner)
337
+ else:
338
+ from ccssh import hostkeys as _HK # lazy: avoids an import cycle
339
+ ok = _HK.verify_host_signature(K_S, host_alg, H, sig_inner)
340
+ if not ok:
341
+ raise ValueError("exchange hash signature mismatch (MitM/damage?)")
342
+ return Session("client", K_pq, K_ecdh, K_raw, H, H, st.V_C, V_S, st.I_C, I_S, K_S, st.C_INIT, S_REPLY)
343
+
344
+
345
+ def run_handshake(*, V_C: bytes = b"SSH-2.0-OpenSSH_10.0", V_S: bytes = b"SSH-2.0-OpenSSH_10.0", I_C: bytes | None = None, I_S: bytes | None = None, host_priv: Ed25519PrivateKey | MldsaKeyPair | None = None, mlkem_seed: bytes | None = None, client_x_seed: bytes | None = None, server_x_seed: bytes | None = None) -> tuple[Session, Session, dict]:
346
+ """
347
+ Full client<->server handshake in one process. For tests.
348
+
349
+ I_C/I_S are random stand-ins, not full KEXINIT payloads: this proves
350
+ the KEM/ECDH math and framing agree, not the wire encoding of H
351
+ (covered live against OpenSSH).
352
+
353
+ Returns (client_session, server_session, wire) with
354
+ wire = {C_INIT, S_REPLY, K_S, sig}.
355
+ """
356
+ I_C = os.urandom(32) if I_C is None else I_C
357
+ I_S = os.urandom(32) if I_S is None else I_S
358
+ host_priv = generate_hostkey() if host_priv is None else host_priv
359
+
360
+ cl = client_create(V_C, I_C, mlkem_seed=mlkem_seed, x_seed=client_x_seed)
361
+ s_reply, k_s, sig, serv = server_process_init(
362
+ host_priv=host_priv, V_C=V_C, V_S=V_S, I_C=I_C, I_S=I_S,
363
+ C_INIT=cl.C_INIT, x_seed=server_x_seed,
364
+ )
365
+ cli = client_process_reply(cl, V_S=V_S, I_S=I_S, K_S=k_s, S_REPLY=s_reply, sig_inner=sig)
366
+ wire = {"C_INIT": cl.C_INIT, "S_REPLY": s_reply, "K_S": k_s, "sig": sig}
367
+ return cli, serv, wire