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/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
|