gabriel / musehub public
test_musehub_auth_crypto.py python
452 lines 15.5 KB
Raw
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ breaking 156 days ago
1 """Unit tests for the musehub.crypto.keys abstraction layer.
2
3 Covers every public function, every error path, every algorithm boundary,
4 and every security-critical property documented in keys.py.
5
6 Red-team coverage:
7 - Bit-flip attacks on signature bytes
8 - Bit-flip attacks on public key bytes
9 - Zero-length and over-length inputs
10 - Cross-algorithm key/signature confusion
11 - Constant-time fingerprint comparison side-channel
12 - b64url padding stripping (both directions)
13 """
14 from __future__ import annotations
15
16 import hashlib
17 import os
18 import time
19
20 import pytest
21 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
22
23 from musehub.crypto.keys import (
24 AlgorithmNotImplementedError,
25 DEFAULT_ALGORITHM,
26 IMPLEMENTED_ALGORITHMS,
27 SIGNATURE_SIZES,
28 PUBLIC_KEY_SIZES,
29 InvalidKeyError,
30 KeyAlgorithm,
31 SignatureError,
32 b64url_decode,
33 b64url_encode,
34 fingerprints_equal,
35 key_fingerprint,
36 verify_signature,
37 )
38
39
40 # ---------------------------------------------------------------------------
41 # Helpers
42 # ---------------------------------------------------------------------------
43
44
45 def _ed25519_keypair() -> tuple[Ed25519PrivateKey, bytes]:
46 priv = Ed25519PrivateKey.generate()
47 pub = priv.public_key().public_bytes_raw()
48 return priv, pub
49
50
51 def _sign_ed25519(priv: Ed25519PrivateKey, msg: bytes) -> bytes:
52 return priv.sign(msg)
53
54
55 # ---------------------------------------------------------------------------
56 # KeyAlgorithm enum
57 # ---------------------------------------------------------------------------
58
59
60 class TestKeyAlgorithmEnum:
61 def test_ed25519_value(self) -> None:
62 assert KeyAlgorithm.ED25519.value == "ed25519"
63
64 def test_ml_dsa_65_value(self) -> None:
65 assert KeyAlgorithm.ML_DSA_65.value == "ml-dsa-65"
66
67 def test_round_trip_from_string(self) -> None:
68 assert KeyAlgorithm("ed25519") is KeyAlgorithm.ED25519
69
70 def test_unknown_string_raises(self) -> None:
71 with pytest.raises(ValueError):
72 KeyAlgorithm("rsa-2048")
73
74 def test_default_algorithm_is_ed25519(self) -> None:
75 assert DEFAULT_ALGORITHM is KeyAlgorithm.ED25519
76
77 def test_ed25519_is_implemented(self) -> None:
78 assert KeyAlgorithm.ED25519 in IMPLEMENTED_ALGORITHMS
79
80 def test_ml_dsa_65_is_not_yet_implemented(self) -> None:
81 # When this test fails, it means ML-DSA-65 was added — good!
82 # Update IMPLEMENTED_ALGORITHMS and remove this assert.
83 assert KeyAlgorithm.ML_DSA_65 not in IMPLEMENTED_ALGORITHMS
84
85
86 # ---------------------------------------------------------------------------
87 # Key size registry
88 # ---------------------------------------------------------------------------
89
90
91 class TestKeySizes:
92 def test_ed25519_public_key_is_32_bytes(self) -> None:
93 assert PUBLIC_KEY_SIZES[KeyAlgorithm.ED25519] == 32
94
95 def test_ml_dsa_65_public_key_is_1952_bytes(self) -> None:
96 assert PUBLIC_KEY_SIZES[KeyAlgorithm.ML_DSA_65] == 1952
97
98 def test_ed25519_signature_is_64_bytes(self) -> None:
99 assert SIGNATURE_SIZES[KeyAlgorithm.ED25519] == 64
100
101 def test_ml_dsa_65_signature_is_3309_bytes(self) -> None:
102 assert SIGNATURE_SIZES[KeyAlgorithm.ML_DSA_65] == 3309
103
104 def test_all_algorithms_have_key_and_sig_size(self) -> None:
105 for algo in KeyAlgorithm:
106 assert algo in PUBLIC_KEY_SIZES, f"Missing PUBLIC_KEY_SIZES entry for {algo}"
107 assert algo in SIGNATURE_SIZES, f"Missing SIGNATURE_SIZES entry for {algo}"
108
109
110 # ---------------------------------------------------------------------------
111 # key_fingerprint
112 # ---------------------------------------------------------------------------
113
114
115 class TestKeyFingerprint:
116 def test_is_sha256_prefixed_hex(self) -> None:
117 raw = os.urandom(32)
118 expected = "sha256:" + hashlib.sha256(raw).hexdigest()
119 assert key_fingerprint(raw) == expected
120
121 def test_output_is_71_chars(self) -> None:
122 assert len(key_fingerprint(os.urandom(32))) == 71
123
124 def test_starts_with_sha256_prefix(self) -> None:
125 fp = key_fingerprint(os.urandom(32))
126 assert fp.startswith("sha256:")
127
128 def test_hex_part_is_lowercase(self) -> None:
129 fp = key_fingerprint(os.urandom(32))
130 hex_part = fp[len("sha256:"):]
131 assert hex_part == hex_part.lower()
132
133 def test_different_keys_have_different_fingerprints(self) -> None:
134 a = os.urandom(32)
135 b = os.urandom(32)
136 assert key_fingerprint(a) != key_fingerprint(b)
137
138 def test_same_key_always_same_fingerprint(self) -> None:
139 raw = os.urandom(32)
140 assert key_fingerprint(raw) == key_fingerprint(raw)
141
142 def test_empty_bytes_does_not_crash(self) -> None:
143 fp = key_fingerprint(b"")
144 assert len(fp) == 71
145 assert fp.startswith("sha256:")
146
147 def test_large_key_bytes_work(self) -> None:
148 # ML-DSA-65 key: 1952 bytes
149 fp = key_fingerprint(os.urandom(1952))
150 assert len(fp) == 71
151 assert fp.startswith("sha256:")
152
153
154 # ---------------------------------------------------------------------------
155 # fingerprints_equal — constant-time comparison
156 # ---------------------------------------------------------------------------
157
158
159 class TestFingerprintsEqual:
160 def test_equal_fingerprints(self) -> None:
161 raw = os.urandom(32)
162 fp = key_fingerprint(raw)
163 assert fingerprints_equal(fp, fp) is True
164
165 def test_different_fingerprints(self) -> None:
166 fp_a = key_fingerprint(os.urandom(32))
167 fp_b = key_fingerprint(os.urandom(32))
168 assert fingerprints_equal(fp_a, fp_b) is False
169
170 def test_case_insensitive(self) -> None:
171 fp = key_fingerprint(os.urandom(32))
172 assert fingerprints_equal(fp.upper(), fp.lower()) is True
173
174 def test_timing_is_not_short_circuit(self) -> None:
175 """
176 Both equal and unequal comparisons must take approximately the same
177 time — hmac.compare_digest processes all bytes regardless of mismatch.
178 This test is probabilistic; flakiness indicates a timing leak.
179 """
180 raw = os.urandom(32)
181 fp = key_fingerprint(raw)
182 fp_wrong = key_fingerprint(os.urandom(32))
183
184 samples = 1000
185 times_equal = []
186 times_unequal = []
187
188 for _ in range(samples):
189 t0 = time.perf_counter_ns()
190 fingerprints_equal(fp, fp)
191 times_equal.append(time.perf_counter_ns() - t0)
192
193 t0 = time.perf_counter_ns()
194 fingerprints_equal(fp, fp_wrong)
195 times_unequal.append(time.perf_counter_ns() - t0)
196
197 # Median times should be within 10× of each other (very lenient —
198 # the real guarantee comes from hmac.compare_digest itself).
199 median_eq = sorted(times_equal)[samples // 2]
200 median_ne = sorted(times_unequal)[samples // 2]
201 ratio = max(median_eq, median_ne) / max(min(median_eq, median_ne), 1)
202 assert ratio < 10, (
203 f"Suspicious timing gap: equal={median_eq}ns unequal={median_ne}ns ratio={ratio:.1f}x"
204 )
205
206
207 # ---------------------------------------------------------------------------
208 # b64url_encode / b64url_decode
209 # ---------------------------------------------------------------------------
210
211
212 class TestB64url:
213 def test_round_trip(self) -> None:
214 for _ in range(50):
215 raw = os.urandom(64)
216 assert b64url_decode(b64url_encode(raw)) == raw
217
218 def test_no_padding_in_encoded(self) -> None:
219 for length in range(1, 40):
220 assert "=" not in b64url_encode(os.urandom(length))
221
222 def test_url_safe_chars_only(self) -> None:
223 import string
224 allowed = set(string.ascii_letters + string.digits + "-_")
225 for _ in range(50):
226 encoded = b64url_encode(os.urandom(64))
227 assert set(encoded) <= allowed, f"Non-url-safe chars in: {encoded}"
228
229 def test_decode_with_padding(self) -> None:
230 raw = os.urandom(10)
231 encoded_with_padding = b64url_encode(raw) + "=="
232 assert b64url_decode(encoded_with_padding) == raw
233
234 def test_decode_without_padding(self) -> None:
235 raw = os.urandom(10)
236 encoded = b64url_encode(raw)
237 assert b64url_decode(encoded) == raw
238
239 def test_empty_bytes(self) -> None:
240 assert b64url_encode(b"") == ""
241 assert b64url_decode("") == b""
242
243 def test_known_vector(self) -> None:
244 # RFC 4648 §10: bytes [0xFB, 0xFF, 0xFE] → "+//+" in standard base64
245 # → "-__-" in base64url
246 raw = bytes([0xFB, 0xFF, 0xFE])
247 assert b64url_encode(raw) == "-__-"
248 assert b64url_decode("-__-") == raw
249
250
251 # ---------------------------------------------------------------------------
252 # verify_signature — Ed25519
253 # ---------------------------------------------------------------------------
254
255
256 class TestVerifySignatureEd25519:
257 def test_valid_signature(self) -> None:
258 priv, pub = _ed25519_keypair()
259 msg = os.urandom(32)
260 sig = _sign_ed25519(priv, msg)
261 verify_signature(
262 algorithm=KeyAlgorithm.ED25519,
263 public_key_bytes=pub,
264 message=msg,
265 signature_bytes=sig,
266 ) # must not raise
267
268 def test_wrong_message_rejected(self) -> None:
269 priv, pub = _ed25519_keypair()
270 msg = os.urandom(32)
271 sig = _sign_ed25519(priv, msg)
272 with pytest.raises(SignatureError):
273 verify_signature(
274 algorithm=KeyAlgorithm.ED25519,
275 public_key_bytes=pub,
276 message=msg + b"\x00", # one extra byte
277 signature_bytes=sig,
278 )
279
280 def test_wrong_key_rejected(self) -> None:
281 priv_a, pub_a = _ed25519_keypair()
282 priv_b, pub_b = _ed25519_keypair()
283 msg = os.urandom(32)
284 sig = _sign_ed25519(priv_a, msg)
285 with pytest.raises(SignatureError):
286 verify_signature(
287 algorithm=KeyAlgorithm.ED25519,
288 public_key_bytes=pub_b, # wrong key
289 message=msg,
290 signature_bytes=sig,
291 )
292
293 def test_bit_flip_in_signature_rejected(self) -> None:
294 priv, pub = _ed25519_keypair()
295 msg = os.urandom(32)
296 sig = bytearray(_sign_ed25519(priv, msg))
297 sig[0] ^= 0xFF # flip first byte
298 with pytest.raises(SignatureError):
299 verify_signature(
300 algorithm=KeyAlgorithm.ED25519,
301 public_key_bytes=pub,
302 message=msg,
303 signature_bytes=bytes(sig),
304 )
305
306 def test_bit_flip_last_byte_rejected(self) -> None:
307 priv, pub = _ed25519_keypair()
308 msg = os.urandom(32)
309 sig = bytearray(_sign_ed25519(priv, msg))
310 sig[-1] ^= 0x01 # flip single bit at end
311 with pytest.raises(SignatureError):
312 verify_signature(
313 algorithm=KeyAlgorithm.ED25519,
314 public_key_bytes=pub,
315 message=msg,
316 signature_bytes=bytes(sig),
317 )
318
319 def test_bit_flip_in_public_key_rejected(self) -> None:
320 priv, pub = _ed25519_keypair()
321 msg = os.urandom(32)
322 sig = _sign_ed25519(priv, msg)
323 bad_pub = bytearray(pub)
324 bad_pub[0] ^= 0x01
325 with pytest.raises((SignatureError, InvalidKeyError)):
326 verify_signature(
327 algorithm=KeyAlgorithm.ED25519,
328 public_key_bytes=bytes(bad_pub),
329 message=msg,
330 signature_bytes=sig,
331 )
332
333 def test_zeroed_signature_rejected(self) -> None:
334 priv, pub = _ed25519_keypair()
335 msg = os.urandom(32)
336 with pytest.raises(SignatureError):
337 verify_signature(
338 algorithm=KeyAlgorithm.ED25519,
339 public_key_bytes=pub,
340 message=msg,
341 signature_bytes=bytes(64),
342 )
343
344 def test_zeroed_public_key_rejected(self) -> None:
345 priv, pub = _ed25519_keypair()
346 msg = os.urandom(32)
347 sig = _sign_ed25519(priv, msg)
348 with pytest.raises((SignatureError, InvalidKeyError)):
349 verify_signature(
350 algorithm=KeyAlgorithm.ED25519,
351 public_key_bytes=bytes(32),
352 message=msg,
353 signature_bytes=sig,
354 )
355
356 def test_short_public_key_rejected(self) -> None:
357 priv, pub = _ed25519_keypair()
358 msg = os.urandom(32)
359 sig = _sign_ed25519(priv, msg)
360 with pytest.raises(InvalidKeyError):
361 verify_signature(
362 algorithm=KeyAlgorithm.ED25519,
363 public_key_bytes=pub[:31], # one byte short
364 message=msg,
365 signature_bytes=sig,
366 )
367
368 def test_long_public_key_rejected(self) -> None:
369 priv, pub = _ed25519_keypair()
370 msg = os.urandom(32)
371 sig = _sign_ed25519(priv, msg)
372 with pytest.raises(InvalidKeyError):
373 verify_signature(
374 algorithm=KeyAlgorithm.ED25519,
375 public_key_bytes=pub + b"\x00", # one byte extra
376 message=msg,
377 signature_bytes=sig,
378 )
379
380 def test_short_signature_rejected(self) -> None:
381 priv, pub = _ed25519_keypair()
382 msg = os.urandom(32)
383 sig = _sign_ed25519(priv, msg)
384 with pytest.raises(SignatureError):
385 verify_signature(
386 algorithm=KeyAlgorithm.ED25519,
387 public_key_bytes=pub,
388 message=msg,
389 signature_bytes=sig[:63],
390 )
391
392 def test_long_signature_rejected(self) -> None:
393 priv, pub = _ed25519_keypair()
394 msg = os.urandom(32)
395 sig = _sign_ed25519(priv, msg)
396 with pytest.raises(SignatureError):
397 verify_signature(
398 algorithm=KeyAlgorithm.ED25519,
399 public_key_bytes=pub,
400 message=msg,
401 signature_bytes=sig + b"\x00",
402 )
403
404 def test_empty_message_is_allowed(self) -> None:
405 """Ed25519 is defined for all-length messages including empty."""
406 priv, pub = _ed25519_keypair()
407 sig = _sign_ed25519(priv, b"")
408 verify_signature(
409 algorithm=KeyAlgorithm.ED25519,
410 public_key_bytes=pub,
411 message=b"",
412 signature_bytes=sig,
413 )
414
415 def test_large_message(self) -> None:
416 priv, pub = _ed25519_keypair()
417 msg = os.urandom(1024 * 1024) # 1 MB
418 sig = _sign_ed25519(priv, msg)
419 verify_signature(
420 algorithm=KeyAlgorithm.ED25519,
421 public_key_bytes=pub,
422 message=msg,
423 signature_bytes=sig,
424 )
425
426
427 # ---------------------------------------------------------------------------
428 # verify_signature — ML-DSA-65 (not yet implemented)
429 # ---------------------------------------------------------------------------
430
431
432 class TestVerifySignatureMlDsa65:
433 def test_raises_not_implemented(self) -> None:
434 with pytest.raises(AlgorithmNotImplementedError) as exc_info:
435 verify_signature(
436 algorithm=KeyAlgorithm.ML_DSA_65,
437 public_key_bytes=os.urandom(1952),
438 message=b"hello",
439 signature_bytes=os.urandom(3309),
440 )
441 assert "ml-dsa-65" in str(exc_info.value).lower()
442
443 def test_error_message_mentions_upgrade_path(self) -> None:
444 with pytest.raises(AlgorithmNotImplementedError) as exc_info:
445 verify_signature(
446 algorithm=KeyAlgorithm.ML_DSA_65,
447 public_key_bytes=os.urandom(1952),
448 message=b"hello",
449 signature_bytes=os.urandom(3309),
450 )
451 msg = str(exc_info.value)
452 assert "keys.py" in msg or "defined" in msg
File History 1 commit
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ 156 days ago