gabriel / musehub public
test_musehub_auth_crypto.py python
446 lines 15.2 KB
Raw
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 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_hex(self) -> None:
117 raw = os.urandom(32)
118 expected = hashlib.sha256(raw).hexdigest()
119 assert key_fingerprint(raw) == expected
120
121 def test_output_is_64_hex_chars(self) -> None:
122 assert len(key_fingerprint(os.urandom(32))) == 64
123
124 def test_output_is_lowercase(self) -> None:
125 fp = key_fingerprint(os.urandom(32))
126 assert fp == fp.lower()
127
128 def test_different_keys_have_different_fingerprints(self) -> None:
129 a = os.urandom(32)
130 b = os.urandom(32)
131 assert key_fingerprint(a) != key_fingerprint(b)
132
133 def test_same_key_always_same_fingerprint(self) -> None:
134 raw = os.urandom(32)
135 assert key_fingerprint(raw) == key_fingerprint(raw)
136
137 def test_empty_bytes_does_not_crash(self) -> None:
138 # SHA-256 of empty bytes is well-defined
139 fp = key_fingerprint(b"")
140 assert len(fp) == 64
141
142 def test_large_key_bytes_work(self) -> None:
143 # ML-DSA-65 key: 1952 bytes
144 fp = key_fingerprint(os.urandom(1952))
145 assert len(fp) == 64
146
147
148 # ---------------------------------------------------------------------------
149 # fingerprints_equal — constant-time comparison
150 # ---------------------------------------------------------------------------
151
152
153 class TestFingerprintsEqual:
154 def test_equal_fingerprints(self) -> None:
155 raw = os.urandom(32)
156 fp = key_fingerprint(raw)
157 assert fingerprints_equal(fp, fp) is True
158
159 def test_different_fingerprints(self) -> None:
160 fp_a = key_fingerprint(os.urandom(32))
161 fp_b = key_fingerprint(os.urandom(32))
162 assert fingerprints_equal(fp_a, fp_b) is False
163
164 def test_case_insensitive(self) -> None:
165 fp = key_fingerprint(os.urandom(32))
166 assert fingerprints_equal(fp.upper(), fp.lower()) is True
167
168 def test_timing_is_not_short_circuit(self) -> None:
169 """
170 Both equal and unequal comparisons must take approximately the same
171 time — hmac.compare_digest processes all bytes regardless of mismatch.
172 This test is probabilistic; flakiness indicates a timing leak.
173 """
174 raw = os.urandom(32)
175 fp = key_fingerprint(raw)
176 fp_wrong = key_fingerprint(os.urandom(32))
177
178 samples = 1000
179 times_equal = []
180 times_unequal = []
181
182 for _ in range(samples):
183 t0 = time.perf_counter_ns()
184 fingerprints_equal(fp, fp)
185 times_equal.append(time.perf_counter_ns() - t0)
186
187 t0 = time.perf_counter_ns()
188 fingerprints_equal(fp, fp_wrong)
189 times_unequal.append(time.perf_counter_ns() - t0)
190
191 # Median times should be within 10× of each other (very lenient —
192 # the real guarantee comes from hmac.compare_digest itself).
193 median_eq = sorted(times_equal)[samples // 2]
194 median_ne = sorted(times_unequal)[samples // 2]
195 ratio = max(median_eq, median_ne) / max(min(median_eq, median_ne), 1)
196 assert ratio < 10, (
197 f"Suspicious timing gap: equal={median_eq}ns unequal={median_ne}ns ratio={ratio:.1f}x"
198 )
199
200
201 # ---------------------------------------------------------------------------
202 # b64url_encode / b64url_decode
203 # ---------------------------------------------------------------------------
204
205
206 class TestB64url:
207 def test_round_trip(self) -> None:
208 for _ in range(50):
209 raw = os.urandom(64)
210 assert b64url_decode(b64url_encode(raw)) == raw
211
212 def test_no_padding_in_encoded(self) -> None:
213 for length in range(1, 40):
214 assert "=" not in b64url_encode(os.urandom(length))
215
216 def test_url_safe_chars_only(self) -> None:
217 import string
218 allowed = set(string.ascii_letters + string.digits + "-_")
219 for _ in range(50):
220 encoded = b64url_encode(os.urandom(64))
221 assert set(encoded) <= allowed, f"Non-url-safe chars in: {encoded}"
222
223 def test_decode_with_padding(self) -> None:
224 raw = os.urandom(10)
225 encoded_with_padding = b64url_encode(raw) + "=="
226 assert b64url_decode(encoded_with_padding) == raw
227
228 def test_decode_without_padding(self) -> None:
229 raw = os.urandom(10)
230 encoded = b64url_encode(raw)
231 assert b64url_decode(encoded) == raw
232
233 def test_empty_bytes(self) -> None:
234 assert b64url_encode(b"") == ""
235 assert b64url_decode("") == b""
236
237 def test_known_vector(self) -> None:
238 # RFC 4648 §10: bytes [0xFB, 0xFF, 0xFE] → "+//+" in standard base64
239 # → "-__-" in base64url
240 raw = bytes([0xFB, 0xFF, 0xFE])
241 assert b64url_encode(raw) == "-__-"
242 assert b64url_decode("-__-") == raw
243
244
245 # ---------------------------------------------------------------------------
246 # verify_signature — Ed25519
247 # ---------------------------------------------------------------------------
248
249
250 class TestVerifySignatureEd25519:
251 def test_valid_signature(self) -> None:
252 priv, pub = _ed25519_keypair()
253 msg = os.urandom(32)
254 sig = _sign_ed25519(priv, msg)
255 verify_signature(
256 algorithm=KeyAlgorithm.ED25519,
257 public_key_bytes=pub,
258 message=msg,
259 signature_bytes=sig,
260 ) # must not raise
261
262 def test_wrong_message_rejected(self) -> None:
263 priv, pub = _ed25519_keypair()
264 msg = os.urandom(32)
265 sig = _sign_ed25519(priv, msg)
266 with pytest.raises(SignatureError):
267 verify_signature(
268 algorithm=KeyAlgorithm.ED25519,
269 public_key_bytes=pub,
270 message=msg + b"\x00", # one extra byte
271 signature_bytes=sig,
272 )
273
274 def test_wrong_key_rejected(self) -> None:
275 priv_a, pub_a = _ed25519_keypair()
276 priv_b, pub_b = _ed25519_keypair()
277 msg = os.urandom(32)
278 sig = _sign_ed25519(priv_a, msg)
279 with pytest.raises(SignatureError):
280 verify_signature(
281 algorithm=KeyAlgorithm.ED25519,
282 public_key_bytes=pub_b, # wrong key
283 message=msg,
284 signature_bytes=sig,
285 )
286
287 def test_bit_flip_in_signature_rejected(self) -> None:
288 priv, pub = _ed25519_keypair()
289 msg = os.urandom(32)
290 sig = bytearray(_sign_ed25519(priv, msg))
291 sig[0] ^= 0xFF # flip first byte
292 with pytest.raises(SignatureError):
293 verify_signature(
294 algorithm=KeyAlgorithm.ED25519,
295 public_key_bytes=pub,
296 message=msg,
297 signature_bytes=bytes(sig),
298 )
299
300 def test_bit_flip_last_byte_rejected(self) -> None:
301 priv, pub = _ed25519_keypair()
302 msg = os.urandom(32)
303 sig = bytearray(_sign_ed25519(priv, msg))
304 sig[-1] ^= 0x01 # flip single bit at end
305 with pytest.raises(SignatureError):
306 verify_signature(
307 algorithm=KeyAlgorithm.ED25519,
308 public_key_bytes=pub,
309 message=msg,
310 signature_bytes=bytes(sig),
311 )
312
313 def test_bit_flip_in_public_key_rejected(self) -> None:
314 priv, pub = _ed25519_keypair()
315 msg = os.urandom(32)
316 sig = _sign_ed25519(priv, msg)
317 bad_pub = bytearray(pub)
318 bad_pub[0] ^= 0x01
319 with pytest.raises((SignatureError, InvalidKeyError)):
320 verify_signature(
321 algorithm=KeyAlgorithm.ED25519,
322 public_key_bytes=bytes(bad_pub),
323 message=msg,
324 signature_bytes=sig,
325 )
326
327 def test_zeroed_signature_rejected(self) -> None:
328 priv, pub = _ed25519_keypair()
329 msg = os.urandom(32)
330 with pytest.raises(SignatureError):
331 verify_signature(
332 algorithm=KeyAlgorithm.ED25519,
333 public_key_bytes=pub,
334 message=msg,
335 signature_bytes=bytes(64),
336 )
337
338 def test_zeroed_public_key_rejected(self) -> None:
339 priv, pub = _ed25519_keypair()
340 msg = os.urandom(32)
341 sig = _sign_ed25519(priv, msg)
342 with pytest.raises((SignatureError, InvalidKeyError)):
343 verify_signature(
344 algorithm=KeyAlgorithm.ED25519,
345 public_key_bytes=bytes(32),
346 message=msg,
347 signature_bytes=sig,
348 )
349
350 def test_short_public_key_rejected(self) -> None:
351 priv, pub = _ed25519_keypair()
352 msg = os.urandom(32)
353 sig = _sign_ed25519(priv, msg)
354 with pytest.raises(InvalidKeyError):
355 verify_signature(
356 algorithm=KeyAlgorithm.ED25519,
357 public_key_bytes=pub[:31], # one byte short
358 message=msg,
359 signature_bytes=sig,
360 )
361
362 def test_long_public_key_rejected(self) -> None:
363 priv, pub = _ed25519_keypair()
364 msg = os.urandom(32)
365 sig = _sign_ed25519(priv, msg)
366 with pytest.raises(InvalidKeyError):
367 verify_signature(
368 algorithm=KeyAlgorithm.ED25519,
369 public_key_bytes=pub + b"\x00", # one byte extra
370 message=msg,
371 signature_bytes=sig,
372 )
373
374 def test_short_signature_rejected(self) -> None:
375 priv, pub = _ed25519_keypair()
376 msg = os.urandom(32)
377 sig = _sign_ed25519(priv, msg)
378 with pytest.raises(SignatureError):
379 verify_signature(
380 algorithm=KeyAlgorithm.ED25519,
381 public_key_bytes=pub,
382 message=msg,
383 signature_bytes=sig[:63],
384 )
385
386 def test_long_signature_rejected(self) -> None:
387 priv, pub = _ed25519_keypair()
388 msg = os.urandom(32)
389 sig = _sign_ed25519(priv, msg)
390 with pytest.raises(SignatureError):
391 verify_signature(
392 algorithm=KeyAlgorithm.ED25519,
393 public_key_bytes=pub,
394 message=msg,
395 signature_bytes=sig + b"\x00",
396 )
397
398 def test_empty_message_is_allowed(self) -> None:
399 """Ed25519 is defined for all-length messages including empty."""
400 priv, pub = _ed25519_keypair()
401 sig = _sign_ed25519(priv, b"")
402 verify_signature(
403 algorithm=KeyAlgorithm.ED25519,
404 public_key_bytes=pub,
405 message=b"",
406 signature_bytes=sig,
407 )
408
409 def test_large_message(self) -> None:
410 priv, pub = _ed25519_keypair()
411 msg = os.urandom(1024 * 1024) # 1 MB
412 sig = _sign_ed25519(priv, msg)
413 verify_signature(
414 algorithm=KeyAlgorithm.ED25519,
415 public_key_bytes=pub,
416 message=msg,
417 signature_bytes=sig,
418 )
419
420
421 # ---------------------------------------------------------------------------
422 # verify_signature — ML-DSA-65 (not yet implemented)
423 # ---------------------------------------------------------------------------
424
425
426 class TestVerifySignatureMlDsa65:
427 def test_raises_not_implemented(self) -> None:
428 with pytest.raises(AlgorithmNotImplementedError) as exc_info:
429 verify_signature(
430 algorithm=KeyAlgorithm.ML_DSA_65,
431 public_key_bytes=os.urandom(1952),
432 message=b"hello",
433 signature_bytes=os.urandom(3309),
434 )
435 assert "ml-dsa-65" in str(exc_info.value).lower()
436
437 def test_error_message_mentions_upgrade_path(self) -> None:
438 with pytest.raises(AlgorithmNotImplementedError) as exc_info:
439 verify_signature(
440 algorithm=KeyAlgorithm.ML_DSA_65,
441 public_key_bytes=os.urandom(1952),
442 message=b"hello",
443 signature_bytes=os.urandom(3309),
444 )
445 msg = str(exc_info.value)
446 assert "keys.py" in msg or "defined" in msg
File History 1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago