gabriel / musehub public

test_musehub_auth_crypto.py file-level

at sha256:3 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:0 fix: fall back to any indexed mpack in read_object_bytes when push mpac… · gabriel · Jun 17, 2026
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 os
17 import time
18
19 from muse.core.types import public_key_fingerprint
20
21 import pytest
22 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
23
24 from musehub.crypto.keys import (
25 AlgorithmNotImplementedError,
26 DEFAULT_ALGORITHM,
27 IMPLEMENTED_ALGORITHMS,
28 SIGNATURE_SIZES,
29 PUBLIC_KEY_SIZES,
30 InvalidKeyError,
31 KeyAlgorithm,
32 SignatureError,
33 b64url_decode,
34 b64url_encode,
35 fingerprints_equal,
36 key_fingerprint,
37 verify_signature,
38 )
39
40
41 # ---------------------------------------------------------------------------
42 # Helpers
43 # ---------------------------------------------------------------------------
44
45
46 def _ed25519_keypair() -> tuple[Ed25519PrivateKey, bytes]:
47 priv = Ed25519PrivateKey.generate()
48 pub = priv.public_key().public_bytes_raw()
49 return priv, pub
50
51
52 def _sign_ed25519(priv: Ed25519PrivateKey, msg: bytes) -> bytes:
53 return priv.sign(msg)
54
55
56 # ---------------------------------------------------------------------------
57 # KeyAlgorithm enum
58 # ---------------------------------------------------------------------------
59
60
61 class TestKeyAlgorithmEnum:
62 def test_ed25519_value(self) -> None:
63 assert KeyAlgorithm.ED25519.value == "ed25519"
64
65 def test_ml_dsa_65_value(self) -> None:
66 assert KeyAlgorithm.ML_DSA_65.value == "ml-dsa-65"
67
68 def test_round_trip_from_string(self) -> None:
69 assert KeyAlgorithm("ed25519") is KeyAlgorithm.ED25519
70
71 def test_unknown_string_raises(self) -> None:
72 with pytest.raises(ValueError):
73 KeyAlgorithm("rsa-2048")
74
75 def test_default_algorithm_is_ed25519(self) -> None:
76 assert DEFAULT_ALGORITHM is KeyAlgorithm.ED25519
77
78 def test_ed25519_is_implemented(self) -> None:
79 assert KeyAlgorithm.ED25519 in IMPLEMENTED_ALGORITHMS
80
81 def test_ml_dsa_65_is_not_yet_implemented(self) -> None:
82 # When this test fails, it means ML-DSA-65 was added — good!
83 # Update IMPLEMENTED_ALGORITHMS and remove this assert.
84 assert KeyAlgorithm.ML_DSA_65 not in IMPLEMENTED_ALGORITHMS
85
86
87 # ---------------------------------------------------------------------------
88 # Key size registry
89 # ---------------------------------------------------------------------------
90
91
92 class TestKeySizes:
93 def test_ed25519_public_key_is_32_bytes(self) -> None:
94 assert PUBLIC_KEY_SIZES[KeyAlgorithm.ED25519] == 32
95
96 def test_ml_dsa_65_public_key_is_1952_bytes(self) -> None:
97 assert PUBLIC_KEY_SIZES[KeyAlgorithm.ML_DSA_65] == 1952
98
99 def test_ed25519_signature_is_64_bytes(self) -> None:
100 assert SIGNATURE_SIZES[KeyAlgorithm.ED25519] == 64
101
102 def test_ml_dsa_65_signature_is_3309_bytes(self) -> None:
103 assert SIGNATURE_SIZES[KeyAlgorithm.ML_DSA_65] == 3309
104
105 def test_all_algorithms_have_key_and_sig_size(self) -> None:
106 for algo in KeyAlgorithm:
107 assert algo in PUBLIC_KEY_SIZES, f"Missing PUBLIC_KEY_SIZES entry for {algo}"
108 assert algo in SIGNATURE_SIZES, f"Missing SIGNATURE_SIZES entry for {algo}"
109
110
111 # ---------------------------------------------------------------------------
112 # key_fingerprint
113 # ---------------------------------------------------------------------------
114
115
116 class TestKeyFingerprint:
117 def test_is_sha256_prefixed_hex(self) -> None:
118 raw = os.urandom(32)
119 expected = public_key_fingerprint(raw)
120 assert key_fingerprint(raw) == expected
121
122 def test_output_is_71_chars(self) -> None:
123 assert len(key_fingerprint(os.urandom(32))) == 71
124
125 def test_starts_with_sha256_prefix(self) -> None:
126 fp = key_fingerprint(os.urandom(32))
127 assert fp.startswith("sha256:")
128
129 def test_hex_part_is_lowercase(self) -> None:
130 fp = key_fingerprint(os.urandom(32))
131 hex_part = fp[len("sha256:"):]
132 assert hex_part == hex_part.lower()
133
134 def test_different_keys_have_different_fingerprints(self) -> None:
135 a = os.urandom(32)
136 b = os.urandom(32)
137 assert key_fingerprint(a) != key_fingerprint(b)
138
139 def test_same_key_always_same_fingerprint(self) -> None:
140 raw = os.urandom(32)
141 assert key_fingerprint(raw) == key_fingerprint(raw)
142
143 def test_empty_bytes_does_not_crash(self) -> None:
144 fp = key_fingerprint(b"")
145 assert len(fp) == 71
146 assert fp.startswith("sha256:")
147
148 def test_large_key_bytes_work(self) -> None:
149 # ML-DSA-65 key: 1952 bytes
150 fp = key_fingerprint(os.urandom(1952))
151 assert len(fp) == 71
152 assert fp.startswith("sha256:")
153
154
155 # ---------------------------------------------------------------------------
156 # fingerprints_equal — constant-time comparison
157 # ---------------------------------------------------------------------------
158
159
160 class TestFingerprintsEqual:
161 def test_equal_fingerprints(self) -> None:
162 raw = os.urandom(32)
163 fp = key_fingerprint(raw)
164 assert fingerprints_equal(fp, fp) is True
165
166 def test_different_fingerprints(self) -> None:
167 fp_a = key_fingerprint(os.urandom(32))
168 fp_b = key_fingerprint(os.urandom(32))
169 assert fingerprints_equal(fp_a, fp_b) is False
170
171 def test_case_insensitive(self) -> None:
172 fp = key_fingerprint(os.urandom(32))
173 assert fingerprints_equal(fp.upper(), fp.lower()) is True
174
175 def test_timing_is_not_short_circuit(self) -> None:
176 """
177 Both equal and unequal comparisons must take approximately the same
178 time — hmac.compare_digest processes all bytes regardless of mismatch.
179 This test is probabilistic; flakiness indicates a timing leak.
180 """
181 raw = os.urandom(32)
182 fp = key_fingerprint(raw)
183 fp_wrong = key_fingerprint(os.urandom(32))
184
185 samples = 1000
186 times_equal = []
187 times_unequal = []
188
189 for _ in range(samples):
190 t0 = time.perf_counter_ns()
191 fingerprints_equal(fp, fp)
192 times_equal.append(time.perf_counter_ns() - t0)
193
194 t0 = time.perf_counter_ns()
195 fingerprints_equal(fp, fp_wrong)
196 times_unequal.append(time.perf_counter_ns() - t0)
197
198 # Median times should be within 10× of each other (very lenient —
199 # the real guarantee comes from hmac.compare_digest itself).
200 median_eq = sorted(times_equal)[samples // 2]
201 median_ne = sorted(times_unequal)[samples // 2]
202 ratio = max(median_eq, median_ne) / max(min(median_eq, median_ne), 1)
203 assert ratio < 10, (
204 f"Suspicious timing gap: equal={median_eq}ns unequal={median_ne}ns ratio={ratio:.1f}x"
205 )
206
207
208 # ---------------------------------------------------------------------------
209 # b64url_encode / b64url_decode
210 # ---------------------------------------------------------------------------
211
212
213 class TestB64url:
214 def test_round_trip(self) -> None:
215 for _ in range(50):
216 raw = os.urandom(64)
217 assert b64url_decode(b64url_encode(raw)) == raw
218
219 def test_no_padding_in_encoded(self) -> None:
220 for length in range(1, 40):
221 assert "=" not in b64url_encode(os.urandom(length))
222
223 def test_url_safe_chars_only(self) -> None:
224 import string
225 allowed = set(string.ascii_letters + string.digits + "-_")
226 for _ in range(50):
227 encoded = b64url_encode(os.urandom(64))
228 assert set(encoded) <= allowed, f"Non-url-safe chars in: {encoded}"
229
230 def test_decode_with_padding(self) -> None:
231 raw = os.urandom(10)
232 encoded_with_padding = f"{b64url_encode(raw)}=="
233 assert b64url_decode(encoded_with_padding) == raw
234
235 def test_decode_without_padding(self) -> None:
236 raw = os.urandom(10)
237 encoded = b64url_encode(raw)
238 assert b64url_decode(encoded) == raw
239
240 def test_empty_bytes(self) -> None:
241 assert b64url_encode(b"") == ""
242 assert b64url_decode("") == b""
243
244 def test_known_vector(self) -> None:
245 # RFC 4648 §10: bytes [0xFB, 0xFF, 0xFE] → "+//+" in standard base64
246 # → "-__-" in base64url
247 raw = bytes([0xFB, 0xFF, 0xFE])
248 assert b64url_encode(raw) == "-__-"
249 assert b64url_decode("-__-") == raw
250
251
252 # ---------------------------------------------------------------------------
253 # verify_signature — Ed25519
254 # ---------------------------------------------------------------------------
255
256
257 class TestVerifySignatureEd25519:
258 def test_valid_signature(self) -> None:
259 priv, pub = _ed25519_keypair()
260 msg = os.urandom(32)
261 sig = _sign_ed25519(priv, msg)
262 verify_signature(
263 algorithm=KeyAlgorithm.ED25519,
264 public_key_bytes=pub,
265 message=msg,
266 signature_bytes=sig,
267 ) # must not raise
268
269 def test_wrong_message_rejected(self) -> None:
270 priv, pub = _ed25519_keypair()
271 msg = os.urandom(32)
272 sig = _sign_ed25519(priv, msg)
273 with pytest.raises(SignatureError):
274 verify_signature(
275 algorithm=KeyAlgorithm.ED25519,
276 public_key_bytes=pub,
277 message=msg + b"\x00", # one extra byte
278 signature_bytes=sig,
279 )
280
281 def test_wrong_key_rejected(self) -> None:
282 priv_a, pub_a = _ed25519_keypair()
283 priv_b, pub_b = _ed25519_keypair()
284 msg = os.urandom(32)
285 sig = _sign_ed25519(priv_a, msg)
286 with pytest.raises(SignatureError):
287 verify_signature(
288 algorithm=KeyAlgorithm.ED25519,
289 public_key_bytes=pub_b, # wrong key
290 message=msg,
291 signature_bytes=sig,
292 )
293
294 def test_bit_flip_in_signature_rejected(self) -> None:
295 priv, pub = _ed25519_keypair()
296 msg = os.urandom(32)
297 sig = bytearray(_sign_ed25519(priv, msg))
298 sig[0] ^= 0xFF # flip first byte
299 with pytest.raises(SignatureError):
300 verify_signature(
301 algorithm=KeyAlgorithm.ED25519,
302 public_key_bytes=pub,
303 message=msg,
304 signature_bytes=bytes(sig),
305 )
306
307 def test_bit_flip_last_byte_rejected(self) -> None:
308 priv, pub = _ed25519_keypair()
309 msg = os.urandom(32)
310 sig = bytearray(_sign_ed25519(priv, msg))
311 sig[-1] ^= 0x01 # flip single bit at end
312 with pytest.raises(SignatureError):
313 verify_signature(
314 algorithm=KeyAlgorithm.ED25519,
315 public_key_bytes=pub,
316 message=msg,
317 signature_bytes=bytes(sig),
318 )
319
320 def test_bit_flip_in_public_key_rejected(self) -> None:
321 priv, pub = _ed25519_keypair()
322 msg = os.urandom(32)
323 sig = _sign_ed25519(priv, msg)
324 bad_pub = bytearray(pub)
325 bad_pub[0] ^= 0x01
326 with pytest.raises((SignatureError, InvalidKeyError)):
327 verify_signature(
328 algorithm=KeyAlgorithm.ED25519,
329 public_key_bytes=bytes(bad_pub),
330 message=msg,
331 signature_bytes=sig,
332 )
333
334 def test_zeroed_signature_rejected(self) -> None:
335 priv, pub = _ed25519_keypair()
336 msg = os.urandom(32)
337 with pytest.raises(SignatureError):
338 verify_signature(
339 algorithm=KeyAlgorithm.ED25519,
340 public_key_bytes=pub,
341 message=msg,
342 signature_bytes=bytes(64),
343 )
344
345 def test_zeroed_public_key_rejected(self) -> None:
346 priv, pub = _ed25519_keypair()
347 msg = os.urandom(32)
348 sig = _sign_ed25519(priv, msg)
349 with pytest.raises((SignatureError, InvalidKeyError)):
350 verify_signature(
351 algorithm=KeyAlgorithm.ED25519,
352 public_key_bytes=bytes(32),
353 message=msg,
354 signature_bytes=sig,
355 )
356
357 def test_short_public_key_rejected(self) -> None:
358 priv, pub = _ed25519_keypair()
359 msg = os.urandom(32)
360 sig = _sign_ed25519(priv, msg)
361 with pytest.raises(InvalidKeyError):
362 verify_signature(
363 algorithm=KeyAlgorithm.ED25519,
364 public_key_bytes=pub[:31], # one byte short
365 message=msg,
366 signature_bytes=sig,
367 )
368
369 def test_long_public_key_rejected(self) -> None:
370 priv, pub = _ed25519_keypair()
371 msg = os.urandom(32)
372 sig = _sign_ed25519(priv, msg)
373 with pytest.raises(InvalidKeyError):
374 verify_signature(
375 algorithm=KeyAlgorithm.ED25519,
376 public_key_bytes=pub + b"\x00", # one byte extra
377 message=msg,
378 signature_bytes=sig,
379 )
380
381 def test_short_signature_rejected(self) -> None:
382 priv, pub = _ed25519_keypair()
383 msg = os.urandom(32)
384 sig = _sign_ed25519(priv, msg)
385 with pytest.raises(SignatureError):
386 verify_signature(
387 algorithm=KeyAlgorithm.ED25519,
388 public_key_bytes=pub,
389 message=msg,
390 signature_bytes=sig[:63],
391 )
392
393 def test_long_signature_rejected(self) -> None:
394 priv, pub = _ed25519_keypair()
395 msg = os.urandom(32)
396 sig = _sign_ed25519(priv, msg)
397 with pytest.raises(SignatureError):
398 verify_signature(
399 algorithm=KeyAlgorithm.ED25519,
400 public_key_bytes=pub,
401 message=msg,
402 signature_bytes=sig + b"\x00",
403 )
404
405 def test_empty_message_is_allowed(self) -> None:
406 """Ed25519 is defined for all-length messages including empty."""
407 priv, pub = _ed25519_keypair()
408 sig = _sign_ed25519(priv, b"")
409 verify_signature(
410 algorithm=KeyAlgorithm.ED25519,
411 public_key_bytes=pub,
412 message=b"",
413 signature_bytes=sig,
414 )
415
416 def test_large_message(self) -> None:
417 priv, pub = _ed25519_keypair()
418 msg = os.urandom(1024 * 1024) # 1 MB
419 sig = _sign_ed25519(priv, msg)
420 verify_signature(
421 algorithm=KeyAlgorithm.ED25519,
422 public_key_bytes=pub,
423 message=msg,
424 signature_bytes=sig,
425 )
426
427
428 # ---------------------------------------------------------------------------
429 # verify_signature — ML-DSA-65 (not yet implemented)
430 # ---------------------------------------------------------------------------
431
432
433 class TestVerifySignatureMlDsa65:
434 def test_raises_not_implemented(self) -> None:
435 with pytest.raises(AlgorithmNotImplementedError) as exc_info:
436 verify_signature(
437 algorithm=KeyAlgorithm.ML_DSA_65,
438 public_key_bytes=os.urandom(1952),
439 message=b"hello",
440 signature_bytes=os.urandom(3309),
441 )
442 assert "ml-dsa-65" in str(exc_info.value).lower()
443
444 def test_error_message_mentions_upgrade_path(self) -> None:
445 with pytest.raises(AlgorithmNotImplementedError) as exc_info:
446 verify_signature(
447 algorithm=KeyAlgorithm.ML_DSA_65,
448 public_key_bytes=os.urandom(1952),
449 message=b"hello",
450 signature_bytes=os.urandom(3309),
451 )
452 msg = str(exc_info.value)
453 assert "keys.py" in msg or "defined" in msg
454
455
456 # ---------------------------------------------------------------------------
457 # b64url_decode — canonical algo-prefixed values
458 # ---------------------------------------------------------------------------
459
460
461 class TestBase64UrlCodecContracts:
462 """Strict contracts for base64url codec functions.
463
464 ``b64url_decode`` — bare-only, for the MSign header ``sig=`` field.
465 ``decode_pubkey`` / ``decode_sig`` — canonical prefixed, for all stored values.
466
467 There is no backward compatibility: every cryptographic value is either
468 explicitly bare (MSign sig= by protocol design) or canonically prefixed.
469 Mixing these up is a programming error, not a supported usage.
470 """
471
472 def test_b64url_decode_bare_value(self) -> None:
473 """b64url_decode correctly decodes bare base64url (its only valid input)."""
474 raw = os.urandom(32)
475 encoded = b64url_encode(raw)
476 assert b64url_decode(encoded) == raw
477
478 def test_b64url_decode_bare_64_byte_signature(self) -> None:
479 """b64url_decode decodes a bare 64-byte signature (MSign sig= use case)."""
480 raw = os.urandom(64)
481 encoded = b64url_encode(raw)
482 assert b64url_decode(encoded) == raw
483
484 def test_decode_pubkey_extracts_raw_bytes(self) -> None:
485 """decode_pubkey correctly decodes a canonical ``ed25519:<b64url>`` public key."""
486 from muse.core.types import decode_pubkey, encode_pubkey
487 raw = os.urandom(32)
488 prefixed = encode_pubkey("ed25519", raw)
489 algo, decoded = decode_pubkey(prefixed)
490 assert algo == "ed25519"
491 assert decoded == raw
492
493 def test_decode_sig_extracts_raw_bytes(self) -> None:
494 """decode_sig correctly decodes a canonical ``ed25519:<b64url>`` signature."""
495 from muse.core.types import decode_sig, encode_sig
496 raw = os.urandom(64)
497 prefixed = encode_sig("ed25519", raw)
498 algo, decoded = decode_sig(prefixed)
499 assert algo == "ed25519"
500 assert decoded == raw
501
502 def test_decode_pubkey_round_trip_with_real_key(self) -> None:
503 """encode_pubkey → decode_pubkey round-trips a real Ed25519 public key."""
504 from muse.core.types import encode_pubkey, decode_pubkey
505 from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
506 priv = Ed25519PrivateKey.generate()
507 raw = priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
508 prefixed = encode_pubkey("ed25519", raw)
509 algo, decoded = decode_pubkey(prefixed)
510 assert algo == "ed25519"
511 assert decoded == raw