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/umac.py ADDED
@@ -0,0 +1,262 @@
1
+ """
2
+ UMAC message authentication (RFC 4418) + SSH wire mapping
3
+ (draft-miller-secsh-umac-01: mac = UMAC(key, seq-as-uint64, packet)).
4
+
5
+ Pure Python over AES-128-ECB from `cryptography` (correctness-first;
6
+ SSH packets are small). Verified against every RFC Appendix test
7
+ vector (32/64/96-bit tags plus all intermediate keys and L1/L2/L3
8
+ outputs) AND live against OpenSSH (umac-64/128, plain and -etm).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import struct
13
+
14
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
15
+
16
+ BLOCKLEN = 16
17
+ KEYLEN = 16
18
+
19
+ P36 = 2**36 - 5
20
+ P64 = 2**64 - 59
21
+ P128 = 2**128 - 159
22
+ M64 = (1 << 64) - 1
23
+
24
+
25
+ def _enc(key: bytes, block: bytes) -> bytes:
26
+ e = Cipher(algorithms.AES(key), modes.ECB()).encryptor()
27
+ return e.update(block) + e.finalize()
28
+
29
+
30
+ def _enc_with(cipher: Cipher, block: bytes) -> bytes:
31
+ e = cipher.encryptor()
32
+ return e.update(block) + e.finalize()
33
+
34
+
35
+ def _kdf_with(cipher: Cipher, index: int, numbytes: int) -> bytes:
36
+ out = b""
37
+ n = (numbytes + BLOCKLEN - 1) // BLOCKLEN
38
+ for i in range(1, n + 1):
39
+ out += _enc_with(cipher, index.to_bytes(BLOCKLEN - 8, "big") + i.to_bytes(8, "big"))
40
+ return out[:numbytes]
41
+
42
+
43
+ def _uhash_with_keys(l1key: bytes, l2key: bytes, l3k1: bytes, l3k2: bytes,
44
+ msg: bytes, taglen: int) -> bytes:
45
+ """UHASH with pre-derived L1/L2/L3 subkeys (same math as uhash)."""
46
+ iters = taglen // 4
47
+ out = b""
48
+ for i in range(iters):
49
+ k1i = l1key[i * 16:i * 16 + 1024]
50
+ k2i = l2key[i * 24:(i + 1) * 24]
51
+ k3a = l3k1[i * 64:(i + 1) * 64]
52
+ k3b = l3k2[i * 4:(i + 1) * 4]
53
+ a = _l1(k1i, msg)
54
+ b = b"\x00" * 8 + a if len(msg) <= 1024 else _l2(k2i, a)
55
+ out += _l3(k3a, k3b, b)
56
+ return out
57
+
58
+
59
+ def _pdf_with(pdf_cipher: Cipher, nonce: bytes, taglen: int) -> bytes:
60
+ if taglen not in (4, 8, 12, 16):
61
+ raise ValueError("taglen must be 4/8/12/16")
62
+ if not 1 <= len(nonce) <= BLOCKLEN:
63
+ raise ValueError("nonce must be 1..16 bytes")
64
+ index = 0
65
+ if taglen in (4, 8):
66
+ index = int.from_bytes(nonce, "big") % (BLOCKLEN // taglen)
67
+ nonce = bytes(a ^ b for a, b in zip(nonce, index.to_bytes(len(nonce), "big")))
68
+ nonce = nonce + b"\x00" * (BLOCKLEN - len(nonce))
69
+ # pdf encrypts with KDF(key, 0): derive the pad key once per context.
70
+ t = _enc_with(pdf_cipher, nonce)
71
+ if taglen in (4, 8):
72
+ return t[index * taglen:(index + 1) * taglen]
73
+ return t[:taglen]
74
+
75
+
76
+ def _u32(b: bytes) -> int:
77
+ return int.from_bytes(b, "big")
78
+
79
+
80
+ def _u64(b: bytes) -> int:
81
+ return int.from_bytes(b, "big")
82
+
83
+
84
+ def _pack32(n: int) -> bytes:
85
+ return (n & 0xFFFFFFFF).to_bytes(4, "big")
86
+
87
+
88
+ def _pack64(n: int) -> bytes:
89
+ return (n & M64).to_bytes(8, "big")
90
+
91
+
92
+ def _pack128(n: int) -> bytes:
93
+ return (n & ((1 << 128) - 1)).to_bytes(16, "big")
94
+
95
+
96
+ def _endian_swap(s: bytes) -> bytes:
97
+ return b"".join(s[i:i + 4][::-1] for i in range(0, len(s), 4))
98
+
99
+
100
+ def kdf(key: bytes, index: int, numbytes: int) -> bytes:
101
+ # Test/reference path (RFC vectors): production tags go through
102
+ # UMacCtx with precomputed subkeys below.
103
+ out = b""
104
+ n = (numbytes + BLOCKLEN - 1) // BLOCKLEN
105
+ for i in range(1, n + 1):
106
+ out += _enc(key, index.to_bytes(BLOCKLEN - 8, "big") + i.to_bytes(8, "big"))
107
+ return out[:numbytes]
108
+
109
+
110
+ def pdf(key: bytes, nonce: bytes, taglen: int) -> bytes:
111
+ if taglen not in (4, 8, 12, 16):
112
+ raise ValueError("taglen must be 4/8/12/16")
113
+ if not 1 <= len(nonce) <= BLOCKLEN:
114
+ raise ValueError("nonce must be 1..16 bytes")
115
+ index = 0
116
+ if taglen in (4, 8):
117
+ index = int.from_bytes(nonce, "big") % (BLOCKLEN // taglen)
118
+ nonce = bytes(a ^ b for a, b in zip(nonce, index.to_bytes(len(nonce), "big")))
119
+ nonce = nonce + b"\x00" * (BLOCKLEN - len(nonce))
120
+ t = _enc(kdf(key, 0, KEYLEN), nonce)
121
+ if taglen in (4, 8):
122
+ return t[index * taglen:(index + 1) * taglen]
123
+ return t[:taglen]
124
+
125
+
126
+ def _nh(key: bytes, msg: bytes) -> bytes:
127
+ """
128
+ NH over a 32-byte-multiple message with a 1024-byte key prefix.
129
+ """
130
+ t = len(msg) // 4
131
+ y = 0
132
+ m32 = 0xFFFFFFFF
133
+ for i in range(0, t, 8):
134
+ for j in range(4):
135
+ a = (_u32(msg[4 * (i + j):4 * (i + j) + 4])
136
+ + _u32(key[4 * (i + j):4 * (i + j) + 4])) & m32
137
+ b = (_u32(msg[4 * (i + 4 + j):4 * (i + 4 + j) + 4])
138
+ + _u32(key[4 * (i + 4 + j):4 * (i + 4 + j) + 4])) & m32
139
+ y += a * b
140
+ return _pack64(y & M64)
141
+
142
+
143
+ def _l1(key: bytes, msg: bytes) -> bytes:
144
+ t = max((len(msg) * 8 + 8191) // 8192, 1)
145
+ out = b""
146
+ for i in range(t - 1):
147
+ chunk = _endian_swap(msg[i * 1024:(i + 1) * 1024])
148
+ out += _pack64((_u64(_nh(key, chunk)) + 1024 * 8) & M64)
149
+ last = msg[(t - 1) * 1024:]
150
+ bitlen = len(last) * 8
151
+ pad = (-len(last)) % 32
152
+ if not last:
153
+ pad = 32 # zeropad() targets a *positive* multiple: empty -> 32 zeros
154
+ last += b"\x00" * pad
155
+ out += _pack64((_u64(_nh(key, _endian_swap(last))) + bitlen) & M64)
156
+ return out
157
+
158
+
159
+ def _poly(wordbits: int, maxwordrange: int, k: int, msg: bytes) -> int:
160
+ wb = wordbits // 8
161
+ p = P64 if wordbits == 64 else P128
162
+ offset = 2**wordbits - p
163
+ marker = p - 1
164
+ n = len(msg) // wb
165
+ y = 1
166
+ for i in range(n):
167
+ m = int.from_bytes(msg[i * wb:(i + 1) * wb], "big")
168
+ if m >= maxwordrange:
169
+ y = (k * y + marker) % p
170
+ y = (k * y + (m - offset)) % p
171
+ else:
172
+ y = (k * y + m) % p
173
+ return y
174
+
175
+
176
+ def _l2(key: bytes, msg: bytes) -> bytes:
177
+ mask64 = 0x01FFFFFF01FFFFFF
178
+ mask128 = 0x01FFFFFF01FFFFFF01FFFFFF01FFFFFF
179
+ k64 = _u64(key[:8]) & mask64
180
+ k128 = int.from_bytes(key[8:24], "big") & mask128
181
+ if len(msg) <= 2**17:
182
+ y = _poly(64, 2**64 - 2**32, k64, msg)
183
+ else:
184
+ m1, m2 = msg[:2**17], msg[2**17:]
185
+ m2 = m2 + b"\x80"
186
+ m2 += b"\x00" * ((-len(m2)) % 16)
187
+ y = _poly(64, 2**64 - 2**32, k64, m1)
188
+ y = _poly(128, 2**128 - 2**96, k128, y.to_bytes(16, "big") + m2)
189
+ return _pack128(y)
190
+
191
+
192
+ def _l3(k1: bytes, k2: bytes, msg: bytes) -> bytes:
193
+ y = 0
194
+ for i in range(8):
195
+ m = int.from_bytes(msg[2 * i:2 * i + 2], "big")
196
+ k = int.from_bytes(k1[8 * i:8 * i + 8], "big") % P36
197
+ y += m * k
198
+ y %= P36
199
+ y %= 2**32
200
+ return bytes(a ^ b for a, b in zip(_pack32(y), k2))
201
+
202
+
203
+ def uhash(key: bytes, msg: bytes, taglen: int) -> bytes:
204
+ if taglen not in (4, 8, 12, 16):
205
+ raise ValueError("taglen must be 4/8/12/16")
206
+ iters = taglen // 4
207
+ l1key = kdf(key, 1, 1024 + (iters - 1) * 16)
208
+ l2key = kdf(key, 2, iters * 24)
209
+ l3k1 = kdf(key, 3, iters * 64)
210
+ l3k2 = kdf(key, 4, iters * 4)
211
+ out = b""
212
+ for i in range(iters):
213
+ k1i = l1key[i * 16:i * 16 + 1024]
214
+ k2i = l2key[i * 24:(i + 1) * 24]
215
+ k3a = l3k1[i * 64:(i + 1) * 64]
216
+ k3b = l3k2[i * 4:(i + 1) * 4]
217
+ a = _l1(k1i, msg)
218
+ b = b"\x00" * 8 + a if len(msg) <= 1024 else _l2(k2i, a)
219
+ out += _l3(k3a, k3b, b)
220
+ return out
221
+
222
+
223
+ def umac(key: bytes, msg: bytes, nonce: bytes, taglen: int) -> bytes:
224
+ # Test/reference path: production tags go through UMacCtx.
225
+ if len(key) != KEYLEN:
226
+ raise ValueError("UMAC key must be 16 bytes")
227
+ hashed = uhash(key, msg, taglen)
228
+ pad = pdf(key, nonce, taglen)
229
+ return bytes(a ^ b for a, b in zip(hashed, pad))
230
+
231
+
232
+ class UMacCtx:
233
+ """
234
+ SSH UMAC (draft-miller-secsh-umac-01): the packet sequence number,
235
+ encoded as uint64, is the nonce. One direction per instance.
236
+
237
+ The L1/L2/L3 subkeys depend only on the connection key, so they are
238
+ derived once here instead of on every packet (dozens of AES ops per
239
+ tag before). The AES key schedules are cached as Cipher objects.
240
+ """
241
+
242
+ def __init__(self, key: bytes, out_len: int):
243
+ if len(key) != KEYLEN or out_len not in (8, 16):
244
+ raise ValueError("UMAC needs a 16-byte key and 8/16-byte tags")
245
+ self._key = key
246
+ self._out_len = out_len
247
+ iters = out_len // 4
248
+ kdf_cipher = Cipher(algorithms.AES(key), modes.ECB())
249
+ self._l1key = _kdf_with(kdf_cipher, 1, 1024 + (iters - 1) * 16)
250
+ self._l2key = _kdf_with(kdf_cipher, 2, iters * 24)
251
+ self._l3k1 = _kdf_with(kdf_cipher, 3, iters * 64)
252
+ self._l3k2 = _kdf_with(kdf_cipher, 4, iters * 4)
253
+ pdf_key = _kdf_with(kdf_cipher, 0, KEYLEN)
254
+ self._pdf_cipher = Cipher(algorithms.AES(pdf_key), modes.ECB())
255
+
256
+ def tag(self, seq: int, data: bytes) -> bytes:
257
+ hashed = _uhash_with_keys(self._l1key, self._l2key, self._l3k1,
258
+ self._l3k2, data, self._out_len)
259
+ pad = _pdf_with(self._pdf_cipher,
260
+ struct.pack(">Q", seq & 0xFFFFFFFFFFFFFFFF),
261
+ self._out_len)
262
+ return bytes(a ^ b for a, b in zip(hashed, pad))
ccssh/wire.py ADDED
@@ -0,0 +1,199 @@
1
+ """
2
+ SSH wire encoding, RFC 4251 section 5: byte/boolean/uint32/uint64/string/mpint/namelist.
3
+
4
+ Dependency-free. Byte-for-byte compatible with OpenSSH on the wire.
5
+ """
6
+ from __future__ import annotations
7
+ import struct
8
+
9
+
10
+ class WireError(ValueError):
11
+ pass
12
+
13
+
14
+ def dec_byte(buf: bytes, off: int = 0) -> tuple[int, int]:
15
+ if off + 1 > len(buf):
16
+ raise WireError("buffer too short for byte")
17
+ return buf[off], off + 1
18
+
19
+
20
+ def enc_bool(v: bool) -> bytes:
21
+ return b"\x01" if v else b"\x00"
22
+
23
+
24
+ def dec_bool(buf: bytes, off: int = 0) -> tuple[bool, int]:
25
+ b, off2 = dec_byte(buf, off)
26
+ if b not in (0, 1):
27
+ raise WireError("boolean must be 0 or 1")
28
+ return bool(b), off2
29
+
30
+
31
+ def enc_u32(v: int) -> bytes:
32
+ if not 0 <= v < 2**32:
33
+ raise WireError("uint32 out of range")
34
+ return struct.pack(">I", v)
35
+
36
+
37
+ def dec_u32(buf: bytes, off: int = 0) -> tuple[int, int]:
38
+ if off + 4 > len(buf):
39
+ raise WireError("buffer too short for uint32")
40
+ return struct.unpack_from(">I", buf, off)[0], off + 4
41
+
42
+
43
+ def enc_u64(v: int) -> bytes:
44
+ if not 0 <= v < 2**64:
45
+ raise WireError("uint64 out of range")
46
+ return struct.pack(">Q", v)
47
+
48
+
49
+ def dec_u64(buf: bytes, off: int = 0) -> tuple[int, int]:
50
+ if off + 8 > len(buf):
51
+ raise WireError("buffer too short for uint64")
52
+ return struct.unpack_from(">Q", buf, off)[0], off + 8
53
+
54
+
55
+ def enc_string(data: bytes) -> bytes:
56
+ return enc_u32(len(data)) + data
57
+
58
+
59
+ def dec_string(buf: bytes, off: int = 0) -> tuple[bytes, int]:
60
+ ln, off2 = dec_u32(buf, off)
61
+ end = off2 + ln
62
+ if end > len(buf):
63
+ raise WireError(f"string declares len={ln}, only {len(buf) - off2} left")
64
+ return buf[off2:end], end
65
+
66
+
67
+ def enc_namelist(names: list[str]) -> bytes:
68
+ for n in names:
69
+ if not n or "," in n:
70
+ raise WireError(f"bad namelist entry {n!r}")
71
+ try:
72
+ n.encode("ascii")
73
+ except UnicodeEncodeError as e:
74
+ raise WireError(f"namelist must be ascii: {n!r}") from e
75
+ try:
76
+ return enc_string(",".join(names).encode("ascii"))
77
+ except UnicodeEncodeError as e:
78
+ raise WireError(f"namelist must be ascii: {e}") from e
79
+
80
+
81
+ def dec_namelist(buf: bytes, off: int = 0) -> tuple[list[str], int]:
82
+ raw, off2 = dec_string(buf, off)
83
+ if not raw:
84
+ return [], off2
85
+ if len(raw) > 32768 or raw.count(b",") > 512:
86
+ raise WireError("namelist too large")
87
+ # Lenient like OpenSSH: empty items (trailing/double commas) are kept
88
+ # and simply never match anything in negotiation.
89
+ try:
90
+ return raw.decode("ascii").split(","), off2
91
+ except UnicodeDecodeError as e:
92
+ raise WireError(f"namelist must be ascii: {e}") from e
93
+
94
+
95
+ def enc_mpint(value: int) -> bytes:
96
+ """mpint per RFC 4251: uint32 length + minimal big-endian two's complement."""
97
+ if value == 0:
98
+ return enc_u32(0)
99
+ if value < 0:
100
+ # Negative values never occur on the wire, but encode them correctly.
101
+ nbytes = (value.bit_length() + 8) // 8
102
+ raw = value.to_bytes(nbytes, "big", signed=True)
103
+ else:
104
+ nbytes = (value.bit_length() + 7) // 8
105
+ raw = value.to_bytes(nbytes, "big", signed=False)
106
+ if raw[0] & 0x80:
107
+ raw = b"\x00" + raw
108
+ return enc_u32(len(raw)) + raw
109
+
110
+
111
+ def dec_mpint(buf: bytes, off: int = 0) -> tuple[int, int]:
112
+ raw, off2 = dec_string(buf, off)
113
+ if not raw:
114
+ return 0, off2
115
+ # RFC 4251 section 5: zero is the empty string; a lone 0x00 byte is
116
+ # non-minimal and must be rejected.
117
+ if len(raw) == 1 and raw[0] == 0:
118
+ raise WireError("non-minimal mpint encoding of zero")
119
+ # Minimal two's complement: no redundant leading 0x00 (when the next
120
+ # byte's high bit is clear) or 0xFF (when it is set) bytes.
121
+ if len(raw) > 1 and raw[0] == 0x00 and not (raw[1] & 0x80):
122
+ raise WireError("non-minimal mpint: redundant leading zero")
123
+ if len(raw) > 1 and raw[0] == 0xFF and (raw[1] & 0x80):
124
+ raise WireError("non-minimal mpint: redundant leading 0xFF")
125
+ return int.from_bytes(raw, "big", signed=True), off2
126
+
127
+
128
+ def mpint_from_bytes(b: bytes) -> bytes:
129
+ """
130
+ Encode a raw big-endian shared secret as an mpint field.
131
+
132
+ Adds a 0x00 prefix when the top bit is set and strips redundant
133
+ leading zeroes (keeping one if needed for the sign bit).
134
+ """
135
+ if not b:
136
+ return enc_u32(0)
137
+ if b[0] & 0x80:
138
+ b = b"\x00" + b
139
+ # Strip redundant leading zeroes in one pass (no quadratic slicing).
140
+ i = 0
141
+ while len(b) - i > 1 and b[i] == 0 and not (b[i + 1] & 0x80):
142
+ i += 1
143
+ if i:
144
+ b = b[i:]
145
+ return enc_u32(len(b)) + b
146
+
147
+
148
+ class Reader:
149
+ """Sequential cursor reader over a payload buffer."""
150
+
151
+ def __init__(self, buf: bytes):
152
+ self._b = buf
153
+ self._o = 0
154
+
155
+ def rest(self) -> int:
156
+ return len(self._b) - self._o
157
+
158
+ def tell(self) -> int:
159
+ return self._o
160
+
161
+ def byte(self) -> int:
162
+ v, self._o = dec_byte(self._b, self._o)
163
+ return v
164
+
165
+ def bool(self) -> bool:
166
+ v, self._o = dec_bool(self._b, self._o)
167
+ return v
168
+
169
+ def u32(self) -> int:
170
+ v, self._o = dec_u32(self._b, self._o)
171
+ return v
172
+
173
+ def u64(self) -> int:
174
+ v, self._o = dec_u64(self._b, self._o)
175
+ return v
176
+
177
+ def string(self) -> bytes:
178
+ v, self._o = dec_string(self._b, self._o)
179
+ return v
180
+
181
+ def namelist(self) -> list[str]:
182
+ v, self._o = dec_namelist(self._b, self._o)
183
+ return v
184
+
185
+ def mpint(self) -> int:
186
+ v, self._o = dec_mpint(self._b, self._o)
187
+ return v
188
+
189
+ def raw(self, n: int) -> bytes:
190
+ if n < 0 or self._o + n > len(self._b):
191
+ raise WireError("raw read out of bounds")
192
+ v = self._b[self._o:self._o + n]
193
+ self._o += n
194
+ return v
195
+
196
+ def assert_end(self) -> None:
197
+ """Reject trailing garbage after a fixed-layout message."""
198
+ if self.rest() != 0:
199
+ raise WireError(f"trailing bytes in message ({self.rest()} left)")