"""REQ-332 / C-5.2 tests: JWS-from-PAT key derivation (symmetric HS256). Verifies: - HKDF-SHA256 key derivation (32 bytes, deterministic, salt/info constants) - sign → verify round-trip (payload matches) - tamper detection (modify the JWS → verify raises) - wrong-PAT detection (verify with a different PAT → raises) - INV-14..17: key derived from PAT, not cached, fixed salt/info, HMAC constant-time comparison """ from __future__ import annotations import sys from pathlib import Path import pytest sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from core.jws_attestation import ( JWSValidationError, derive_signing_key, sign_attestation, verify_attestation, ) class TestDeriveSigningKey: def test_returns_32_bytes(self): key = derive_signing_key("test-pat") assert isinstance(key, bytes) assert len(key) == 32, f"expected 32 bytes, got {len(key)}" def test_deterministic(self): """The same PAT always yields the same key (HKDF is deterministic).""" k1 = derive_signing_key("my-pat") k2 = derive_signing_key("my-pat") assert k1 == k2 def test_different_pats_yield_different_keys(self): k1 = derive_signing_key("pat-a") k2 = derive_signing_key("pat-b") assert k1 != k2 def test_empty_pat_raises(self): with pytest.raises(ValueError, match="non-empty"): derive_signing_key("") def test_non_string_pat_raises(self): with pytest.raises(ValueError): derive_signing_key(12345) # type: ignore[arg-type] def test_key_is_not_the_pat_raw_bytes(self): """INV-14: the key is DERIVED from the PAT, not the PAT bytes.""" key = derive_signing_key("test-pat") assert key != b"test-pat" assert key != "test-pat".encode() def test_hashlib_fallback_matches_cryptography(self): """The hashlib HKDF fallback produces the same key as cryptography.""" from core.jws_attestation import _hkdf_sha256, _hkdf_sha256_hashlib ikm = b"test-pat" salt = b"nova-local-attestation" info = b"jws-signing-key" via_crypto = _hkdf_sha256(ikm, salt, info, 32) via_hashlib = _hkdf_sha256_hashlib(ikm, salt, info, 32) assert via_crypto == via_hashlib class TestRoundTrip: def test_sign_verify_roundtrip(self): """sign → verify → payload matches the original.""" payload = {"x": 1, "contractId": "c-001", "reviewer": "alice"} jws = sign_attestation(payload, "test-pat") assert isinstance(jws, str) # Compact JWS: 3 dot-separated segments. assert jws.count(".") == 2 verified = verify_attestation(jws, "test-pat") assert verified == payload def test_roundtrip_complex_payload(self): payload = { "contractId": "msvc-001", "environment": "dev", "reviewers": ["alice", "bob"], "score": 0.92, "nested": {"a": 1, "b": [2, 3]}, } jws = sign_attestation(payload, "secret-pat-123") verified = verify_attestation(jws, "secret-pat-123") assert verified == payload def test_header_is_hs256_jwt(self): """The JWS header is {"alg":"HS256","typ":"JWT"}.""" import base64 import json jws = sign_attestation({"x": 1}, "pat") header_segment = jws.split(".")[0] pad = "=" * (-len(header_segment) % 4) header = json.loads(base64.urlsafe_b64decode(header_segment + pad)) assert header["alg"] == "HS256" assert header["typ"] == "JWT" class TestTamperDetection: def test_tampered_payload_raises(self): """Modifying the payload segment → verify raises (INV-17).""" payload = {"x": 1} jws = sign_attestation(payload, "test-pat") parts = jws.split(".") # Flip a char in the payload segment. tampered_payload = parts[1][:-1] + ("A" if parts[1][-1] != "A" else "B") tampered = f"{parts[0]}.{tampered_payload}.{parts[2]}" with pytest.raises(JWSValidationError, match="signature verification failed"): verify_attestation(tampered, "test-pat") def test_tampered_signature_raises(self): """Modifying the signature segment → verify raises.""" payload = {"x": 1} jws = sign_attestation(payload, "test-pat") parts = jws.split(".") tampered_sig = parts[2][:-1] + ("A" if parts[2][-1] != "A" else "B") tampered = f"{parts[0]}.{parts[1]}.{tampered_sig}" with pytest.raises(JWSValidationError, match="signature verification failed"): verify_attestation(tampered, "test-pat") def test_tampered_header_raises(self): """Modifying the header segment → verify raises (header is part of the signing input).""" payload = {"x": 1} jws = sign_attestation(payload, "test-pat") parts = jws.split(".") tampered_header = parts[0][:-1] + ("A" if parts[0][-1] != "A" else "B") tampered = f"{tampered_header}.{parts[1]}.{parts[2]}" with pytest.raises(JWSValidationError): verify_attestation(tampered, "test-pat") def test_malformed_jws_raises(self): with pytest.raises(JWSValidationError, match="3 segments"): verify_attestation("not.a.jws.token", "pat") with pytest.raises(JWSValidationError, match="3 segments"): verify_attestation("onlyonesegment", "pat") class TestWrongPatDetection: def test_wrong_pat_raises(self): """Verify with a different PAT → raises (the key derivation differs).""" payload = {"x": 1} jws = sign_attestation(payload, "correct-pat") with pytest.raises(JWSValidationError, match="signature verification failed"): verify_attestation(jws, "wrong-pat") def test_empty_pat_raises(self): jws = sign_attestation({"x": 1}, "real-pat") with pytest.raises(ValueError): verify_attestation(jws, "") class TestInvInvariants: def test_inv14_key_derived_from_pat(self): """INV-14: the signing key is derived from the PAT via HKDF.""" # The key is a function of the PAT (different PAT → different key, # same PAT → same key). Already covered above; this is the explicit # invariant assertion. assert derive_signing_key("pat") == derive_signing_key("pat") assert derive_signing_key("pat") != derive_signing_key("other") def test_inv15_key_not_cached(self): """INV-15: derive_signing_key recomputes the key on each call (no module-level cache of the key). Inspect the module source.""" import inspect from core import jws_attestation src = inspect.getsource(jws_attestation.derive_signing_key) assert "_hkdf_sha256(" in src # No module-level key cache variable. assert not hasattr(jws_attestation, "_cached_key") assert not hasattr(jws_attestation, "_signing_key") def test_inv16_salt_and_info_are_fixed_constants(self): """INV-16: the salt + info are fixed constants binding the key to the nova-local-attestation / jws-signing-key purpose.""" from core import jws_attestation assert jws_attestation._KDF_SALT == b"nova-local-attestation" assert jws_attestation._KDF_INFO == b"jws-signing-key" assert jws_attestation._KDF_LENGTH == 32 def test_inv17_constant_time_comparison(self): """INV-17: signature comparison uses hmac.compare_digest (constant-time).""" import inspect from core import jws_attestation src = inspect.getsource(jws_attestation.verify_attestation) assert "compare_digest" in src