ab069db3a4
---ci---
project: acdl
phase: 2
milestone: v1.28
status: execute
persona: security-engineer
---
C-5.2 grill fix: symmetric JWS (HS256) where the PAT is the shared secret.
derive_signing_key(pat) -> HKDF-SHA256(pat.encode(), salt=b'nova-local-
attestation', info=b'jws-signing-key', length=32) via cryptography (fallback
to hashlib HKDF). sign_attestation(payload, pat) -> compact JWS
b64url(header).b64url(payload).b64url(sig) with header {alg:HS256,typ:JWT}.
verify_attestation(jws, pat) -> payload (raises JWSValidationError on tamper
or wrong PAT; hmac.compare_digest constant-time). INV-14..17 enforced
(key derived from PAT, not cached, fixed salt/info, constant-time compare).
tests/test_jws_attestation.py: 20 tests (round-trip, tamper, wrong-PAT,
invariants, hashlib/crypto parity).
193 lines
7.6 KiB
Python
193 lines
7.6 KiB
Python
"""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 |