diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index b5e14cd..cc3ccf3 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -19,7 +19,7 @@ "source": "git-subdir", "url": "https://github.com/vaaraio/vaara.git", "path": "plugins/claude-code-vaara-governance", - "ref": "v0.41.0" + "ref": "v0.43.0" }, "homepage": "https://vaara.io" } diff --git a/src/vaara/integrations/_mcp_attest.py b/src/vaara/integrations/_mcp_attest.py index 4899edd..6dbab9c 100644 --- a/src/vaara/integrations/_mcp_attest.py +++ b/src/vaara/integrations/_mcp_attest.py @@ -296,6 +296,14 @@ def build_attest_emitter( f"--attest-signing-key is not a usable PEM private key: {exc}" ) from exc if isinstance(key, EllipticCurvePrivateKey): + # ES256 emits a fixed 32-byte r||s; a non-P-256 curve would be + # mislabeled and then silently fail to sign (emit_attestation + # swallows errors), so reject it up front with a clear message. + if key.curve.name != "secp256r1": + raise AttestConfigError( + "--attest-signing-key EC key must use the P-256 (secp256r1) " + f"curve for ES256; got {key.curve.name!r}." + ) alg: str = "ES256" elif isinstance(key, RSAPrivateKey): alg = "RS256" diff --git a/src/vaara/integrations/mcp_proxy.py b/src/vaara/integrations/mcp_proxy.py index c182175..47329a1 100644 --- a/src/vaara/integrations/mcp_proxy.py +++ b/src/vaara/integrations/mcp_proxy.py @@ -1393,7 +1393,9 @@ def main(argv: Optional[list[str]] = None) -> None: "github=github-mcp-server` or `--upstream npx`).", ) - attest_emitter = _build_attest_emitter_from_args(args, upstreams=upstreams) + attest_emitter = _build_attest_emitter_from_args( + args, upstreams=_attest_upstreams_for_slots(upstreams), + ) legacy_single = ( list(next(iter(upstreams.values()))) if len(upstreams) == 1 else None @@ -1467,6 +1469,23 @@ def _parse_upstream_specs( return upstreams +def _attest_upstreams_for_slots( + upstreams: dict[str, list[str]], +) -> dict[str, list[str]]: + """Key the attestation fingerprint table the way the proxy slots upstreams. + + A single upstream (named ``NAME=CMD`` or bare ``CMD``) collapses into the + ``"default"`` slot inside ``VaaraMCPProxy``, and ``_REQUEST_UPSTREAM`` + resolves to ``"default"`` at runtime. The emitter must be keyed the same + way, or ``fingerprint_for("default")`` misses the precomputed cmd-hash and + emits a ``cmd:sha256:unknown-default`` placeholder. Multi-upstream fan-out + keeps the operator-supplied slot names. + """ + if len(upstreams) == 1: + return {"default": list(next(iter(upstreams.values())))} + return dict(upstreams) + + def _build_attest_emitter_from_args( args: argparse.Namespace, *, diff --git a/tests/test_integrations_mcp_proxy.py b/tests/test_integrations_mcp_proxy.py index 1a2399c..156abbd 100644 --- a/tests/test_integrations_mcp_proxy.py +++ b/tests/test_integrations_mcp_proxy.py @@ -723,3 +723,24 @@ def test_inflight_requests_cleared_when_tools_call_raises(monkeypatch): finally: _REQUEST_UPSTREAM.reset(token) assert p._inflight_requests == {} + + +# --------------------------------------------------------------------------- +# Attestation emitter slot-keying (pure; runs without the attestation extra) +# --------------------------------------------------------------------------- + +def test_attest_upstreams_named_single_collapses_to_default(): + # A named single upstream (--upstream github=CMD) collapses to the + # "default" slot inside the proxy, so the attestation fingerprint table + # must be keyed "default" too. Regression for the placeholder-fingerprint + # bug on the documented NAME=CMD single-upstream form. + from vaara.integrations.mcp_proxy import _attest_upstreams_for_slots + assert _attest_upstreams_for_slots( + {"github": ["github-mcp-server", "stdio"]} + ) == {"default": ["github-mcp-server", "stdio"]} + + +def test_attest_upstreams_multi_preserves_names(): + from vaara.integrations.mcp_proxy import _attest_upstreams_for_slots + upstreams = {"github": ["gh-srv"], "fs": ["fs-srv"]} + assert _attest_upstreams_for_slots(upstreams) == upstreams diff --git a/tests/test_integrations_mcp_proxy_attest.py b/tests/test_integrations_mcp_proxy_attest.py index 0169a9b..f804242 100644 --- a/tests/test_integrations_mcp_proxy_attest.py +++ b/tests/test_integrations_mcp_proxy_attest.py @@ -319,6 +319,66 @@ def test_manifest_fingerprint_idempotent(monkeypatch, emitter): assert emitter.fingerprint_for("default") == first_fp +# --------------------------------------------------------------------------- +# Key validation +# --------------------------------------------------------------------------- + +def test_build_attest_emitter_rejects_non_p256_ec_key(tmp_path): + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import ec + from vaara.integrations._mcp_attest import AttestConfigError, build_attest_emitter + key = ec.generate_private_key(ec.SECP384R1()) + pem = key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + key_path = tmp_path / "p384.pem" + key_path.write_bytes(pem) + with pytest.raises(AttestConfigError, match="secp256r1"): + build_attest_emitter( + signing_key_path=key_path, + receipts_dir=tmp_path / "r", + upstream_commands={"default": ["echo"]}, + ) + + +def test_build_attest_emitter_accepts_p256_ec_key(tmp_path): + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import ec + from vaara.integrations._mcp_attest import build_attest_emitter + key = ec.generate_private_key(ec.SECP256R1()) + pem = key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + key_path = tmp_path / "p256.pem" + key_path.write_bytes(pem) + emitter = build_attest_emitter( + signing_key_path=key_path, + receipts_dir=tmp_path / "r", + upstream_commands={"default": ["echo"]}, + ) + assert emitter.fingerprint_for("default").startswith("cmd:sha256:") + + +def test_named_single_upstream_fingerprints_under_default(attest_key, attest_receipts_dir): + # End-to-end of the slot-keying fix: an emitter built the way main() now + # builds it for a named single upstream returns a real cmd-hash under + # "default", never the unknown-* placeholder. + from vaara.integrations._mcp_attest import build_attest_emitter + from vaara.integrations.mcp_proxy import _attest_upstreams_for_slots + emitter = build_attest_emitter( + signing_key_path=attest_key, + receipts_dir=attest_receipts_dir, + upstream_commands=_attest_upstreams_for_slots({"github": ["github-mcp-server"]}), + ) + fp = emitter.fingerprint_for("default") + assert fp.startswith("cmd:sha256:") + assert "unknown" not in fp + + # --------------------------------------------------------------------------- # Config error handling # ---------------------------------------------------------------------------