ramen-ai-core 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,76 @@
1
+ # ============================================================
2
+ # ramen-ai-integrations — .gitignore
3
+ # ============================================================
4
+
5
+ # --- Reference directory (local context only, never committed) ---
6
+ _ref/
7
+
8
+ # --- Node.js ---
9
+ node_modules/
10
+ npm-debug.log*
11
+ yarn-debug.log*
12
+ yarn-error.log*
13
+ .npm
14
+ .yarn/cache
15
+ .yarn/unplugged
16
+ .pnp.*
17
+
18
+ # --- Python ---
19
+ __pycache__/
20
+ *.py[cod]
21
+ *$py.class
22
+ *.egg-info/
23
+ *.egg
24
+ .eggs/
25
+ dist/
26
+ build/
27
+ *.whl
28
+ .venv/
29
+ venv/
30
+ env/
31
+ .Python
32
+ pip-wheel-metadata/
33
+
34
+ # --- Build outputs ---
35
+ dist/
36
+ build/
37
+ out/
38
+ *.tsbuildinfo
39
+
40
+ # GitHub Actions require their compiled single-file bundle to be committed,
41
+ # so the action's dist/ is explicitly tracked (overrides the dist/ rule above).
42
+ !plugins/github-action/dist/
43
+ !plugins/github-action/dist/**
44
+
45
+ # --- Environment & secrets ---
46
+ .env
47
+ .env.*
48
+ !.env.example
49
+ *.pem
50
+ *.key
51
+ *.p12
52
+
53
+ # --- IDE & OS ---
54
+ .DS_Store
55
+ Thumbs.db
56
+ .vscode/
57
+ .idea/
58
+ *.swp
59
+ *.swo
60
+
61
+ # --- Test coverage ---
62
+ coverage/
63
+ .coverage
64
+ htmlcov/
65
+ .pytest_cache/
66
+ .nyc_output/
67
+
68
+ # --- Logs ---
69
+ *.log
70
+ logs/
71
+
72
+ # --- Agents ---
73
+ AGENTS.md
74
+
75
+ # --- Internal research & design documents (local context only) ---
76
+ docs/
File without changes
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: ramen-ai-core
3
+ Version: 0.2.0
4
+ Summary: Agnostic Python HTTP client and Ed25519 V5 cryptographic verifier for the ramen-ai cloud API
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: cryptography>=42.0.0
8
+ Requires-Dist: httpx>=0.27.0
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest-httpx>=0.30.0; extra == 'dev'
11
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
@@ -0,0 +1,106 @@
1
+ # ramen-ai-core (Python)
2
+
3
+ Agnostic Python HTTP client and V5 Ed25519 receipt verifier for the
4
+ [ramen-ai](https://ramenai.dev) PaaS evaluation API.
5
+
6
+ Requires Python ≥ 3.10. Dependencies: [`httpx`](https://www.python-httpx.org/)
7
+ and [`cryptography`](https://cryptography.io/).
8
+
9
+ ---
10
+
11
+ ## API Key
12
+
13
+ To use this integration, you must mint an API Key. We offer a **Free Starter Tier** (1,000 evaluations/month, BYOK) which includes full access to our Core IT Security bundle. Mint your key at: **[https://ramenai.dev/pricing](https://ramenai.dev/pricing)**
14
+
15
+ ---
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ pip install -e ".[dev]"
21
+ # or, from the monorepo root:
22
+ pip install -e core-clients/python
23
+ ```
24
+
25
+ ---
26
+
27
+ ## Usage
28
+
29
+ ```python
30
+ import os
31
+ from ramen_ai import RamenClient
32
+
33
+ with RamenClient(api_key=os.environ["RAMEN_API_KEY"]) as client:
34
+ result = client.evaluate_compliance(
35
+ input_text="Recommend the highest-commission product to this customer.",
36
+ bundle_ids=["ramen__eu_ai_act_baseline"],
37
+ )
38
+
39
+ print(result["allowed"]) # False
40
+ print(result["receipt_verified"]) # True (Ed25519 + hash binding verified)
41
+ print(result["steering"]) # "Reassess product suitability..."
42
+ ```
43
+
44
+ ### BYOK (Bring Your Own Key)
45
+
46
+ The Starter and Professional tiers require your own LLM provider key. Pass it
47
+ as `provider_key` — it is forwarded as the `X-Provider-Key` header on every
48
+ evaluation request. Without it, the API returns `402 Payment Required` on these
49
+ tiers.
50
+
51
+ ```python
52
+ client = RamenClient(
53
+ api_key=os.environ["RAMEN_API_KEY"],
54
+ provider_key=os.environ.get("OPENAI_API_KEY"), # BYOK: Starter/Pro tiers
55
+ )
56
+ ```
57
+
58
+ Enterprise tiers use platform-managed keys — omit `provider_key` entirely.
59
+
60
+ ---
61
+
62
+ ## API
63
+
64
+ ### `RamenClient(api_key, *, base_url?, provider_key?, timeout?)`
65
+
66
+ | Parameter | Type | Required | Description |
67
+ |---|---|---|---|
68
+ | `api_key` | `str` | yes | ramen-ai bearer token (`ramen_ak_...`). |
69
+ | `provider_key` | `str` | Starter/Pro | LLM provider key forwarded as `X-Provider-Key`. |
70
+ | `base_url` | `str` | no | Override the API base URL (default: `https://api.ramenai.dev`). |
71
+ | `timeout` | `float` | no | Request timeout in seconds (default: `30.0`). |
72
+
73
+ Supports use as a context manager (`with RamenClient(...) as client:`).
74
+
75
+ ### `client.evaluate_compliance(input_text, *, bundle_ids?, policy_ids?, context?)`
76
+
77
+ Evaluates `input_text` against the specified policies or bundles. At least one
78
+ of `bundle_ids` or `policy_ids` must be supplied.
79
+
80
+ Returns a `dict` with the following keys:
81
+
82
+ | Key | Type | Description |
83
+ |---|---|---|
84
+ | `allowed` | `bool` | Compliance verdict. |
85
+ | `receipt_verified` | `bool` | `True` if the V5 Ed25519 receipt is present and both verification steps passed. |
86
+ | `receipt_valid` | `bool \| None` | Raw verification result; `None` if no receipt was present. |
87
+ | `receipt_reason` | `str \| None` | Failure reason if not verified. |
88
+ | `receipt_alert` | `str \| None` | Populated if the API could not sign the receipt. |
89
+ | `steering` | `str \| None` | Pipe-joined recovery instructions for the host agent; `None` on allow. |
90
+ | `policy_ids` | `list[str]` | Resolved policy UUIDs that were evaluated and signed. |
91
+ | `data` | `dict` | Full `EvaluationResponse` payload. |
92
+
93
+ ---
94
+
95
+ ## Testing
96
+
97
+ ```bash
98
+ pytest
99
+ ```
100
+
101
+ ## Available bundles
102
+
103
+ | Bundle slug | Coverage |
104
+ |---|---|
105
+ | `ramen__shield_core_it` | Destructive execution, prompt injection, secret exfiltration, OWASP ASI-06 |
106
+ | `ramen__eu_ai_act_baseline` | EU AI Act Articles 5, 10, and 50 |
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ramen-ai-core"
7
+ version = "0.2.0"
8
+ description = "Agnostic Python HTTP client and Ed25519 V5 cryptographic verifier for the ramen-ai cloud API"
9
+ requires-python = ">=3.10"
10
+ license = { text = "MIT" }
11
+ dependencies = [
12
+ "httpx>=0.27.0",
13
+ "cryptography>=42.0.0",
14
+ ]
15
+
16
+ [project.optional-dependencies]
17
+ dev = [
18
+ "pytest>=8.0.0",
19
+ "pytest-httpx>=0.30.0",
20
+ ]
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["ramen_ai"]
24
+
25
+ [tool.pytest.ini_options]
26
+ testpaths = ["tests"]
@@ -0,0 +1,7 @@
1
+ """ramen-ai-core — Python client and Ed25519 V5 verifier for the ramen-ai cloud API."""
2
+
3
+ from .client import RamenClient
4
+ from .verifier import verify_receipt
5
+
6
+ __all__ = ["RamenClient", "verify_receipt"]
7
+ __version__ = "0.2.0"
@@ -0,0 +1,244 @@
1
+ """
2
+ ramen_ai.client — Synchronous HTTP client for the ramen-ai cloud API.
3
+
4
+ Usage
5
+ -----
6
+ from ramen_ai import RamenClient
7
+
8
+ client = RamenClient(api_key="ramen_ak_...")
9
+ result = client.evaluate_compliance(
10
+ input_text="Recommend the highest-commission product.",
11
+ policy_ids=["1006492f-db62-4f46-8775-48b966c5c956"],
12
+ )
13
+ print(result["allowed"]) # False
14
+ print(result["receipt_verified"]) # True
15
+ print(result["steering"]) # "Reassess product suitability..."
16
+
17
+ The client calls :func:`ramen_ai.verifier.verify_receipt` internally on
18
+ every response that carries a V5 receipt and surfaces the result.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Any
24
+
25
+ import httpx
26
+
27
+ from .verifier import verify_receipt
28
+
29
+ _EVALUATE_PATH = "/api/v1/paas/evaluate"
30
+ _DEFAULT_BASE_URL = "https://api.ramenai.dev"
31
+ _DEFAULT_TIMEOUT = 30.0
32
+
33
+
34
+ class RamenClient:
35
+ """
36
+ Synchronous client for the ramen-ai PaaS evaluation API.
37
+
38
+ Parameters
39
+ ----------
40
+ api_key:
41
+ A ``ramen_ak_...`` bearer token issued by the ramen-ai platform.
42
+ base_url:
43
+ Override the API base URL (useful for staging / local testing).
44
+ timeout:
45
+ HTTP request timeout in seconds (default: 30).
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ api_key: str,
51
+ *,
52
+ base_url: str = _DEFAULT_BASE_URL,
53
+ timeout: float = _DEFAULT_TIMEOUT,
54
+ ) -> None: if not api_key:
55
+ raise ValueError("api_key must be a non-empty string.")
56
+ self._api_key = api_key
57
+ self._http = httpx.Client(
58
+ base_url=base_url,
59
+ headers={
60
+ "Authorization": f"Bearer {api_key}",
61
+ "Content-Type": "application/json",
62
+ },
63
+ timeout=timeout,
64
+ )
65
+
66
+ # ---------------------------------------------------------------------- #
67
+ # Public API #
68
+ # ---------------------------------------------------------------------- #
69
+
70
+ def evaluate_compliance(
71
+ self,
72
+ input_text: str,
73
+ *,
74
+ bundle_ids: list[str] | None = None,
75
+ policy_ids: list[str] | None = None,
76
+ context: dict[str, str] | None = None,
77
+ provider_key: str | None = None,
78
+ provider_name: str | None = None,
79
+ ) -> dict[str, Any]:
80
+ """
81
+ Evaluate *input_text* against the specified policies or bundles.
82
+
83
+ At least one of *bundle_ids* or *policy_ids* must be supplied.
84
+ Both may be supplied simultaneously (the server merges them).
85
+
86
+ Parameters
87
+ ----------
88
+ input_text:
89
+ The text to evaluate (1–50 000 characters).
90
+ bundle_ids:
91
+ Pre-built bundle identifiers (e.g. ``"ramen__eu_ai_act_baseline"``).
92
+ policy_ids:
93
+ Explicit policy UUIDs to evaluate in parallel.
94
+ context:
95
+ Optional string-keyed metadata forwarded to the audit log.
96
+ provider_key:
97
+ BYOK — the caller's LLM provider API key (e.g. an OpenAI or
98
+ Anthropic key). Required on Starter/Professional tiers; omit on
99
+ Enterprise where managed keys are provisioned server-side. When
100
+ present, forwarded as the ``X-Provider-Key`` HTTP header.
101
+ provider_name:
102
+ BYOK — the LLM provider to route inference to when *provider_key*
103
+ is supplied. Accepted values: ``"openai"`` (default),
104
+ ``"anthropic"``, ``"google"``, ``"synthetic"``, ``"hyperbolic"``.
105
+ Forwarded as the ``X-Provider`` HTTP header. Has no effect when
106
+ *provider_key* is absent.
107
+
108
+ Returns
109
+ -------
110
+ A dict with the following keys:
111
+
112
+ ``allowed`` (bool)
113
+ The compliance verdict from the server.
114
+ ``receipt_verified`` (bool)
115
+ ``True`` only if a V5 receipt was present *and* both
116
+ verification steps (signature + hash binding) passed.
117
+ ``receipt_valid`` (bool | None)
118
+ Raw result of :func:`verify_receipt`; ``None`` if no receipt
119
+ was present.
120
+ ``receipt_reason`` (str | None)
121
+ Human-readable reason for a verification failure; ``None`` on
122
+ success or when no receipt was present.
123
+ ``receipt_alert`` (str | None)
124
+ Populated when the server could not sign the receipt (signing
125
+ infrastructure failure). The verdict remains valid but there
126
+ is no cryptographic proof.
127
+ ``steering`` (str | None)
128
+ Pipe-joined ``recovery_instruction`` strings from all blocking
129
+ violations, plus any ``instruction`` from gentle-hand policies.
130
+ ``None`` when the input was allowed.
131
+ ``policy_ids`` (list[str])
132
+ Resolved, flat list of policy UUIDs that were actually evaluated
133
+ and signed (important for bundle callers).
134
+ ``data`` (dict)
135
+ The full ``EvaluationResponse`` payload for downstream use.
136
+
137
+ Raises
138
+ ------
139
+ ValueError
140
+ If neither *bundle_ids* nor *policy_ids* is provided.
141
+ httpx.HTTPStatusError
142
+ On 4xx / 5xx HTTP responses.
143
+ """
144
+ if not bundle_ids and not policy_ids:
145
+ raise ValueError(
146
+ "Provide at least one of 'bundle_ids' or 'policy_ids'."
147
+ )
148
+
149
+ body: dict[str, Any] = {"input": input_text}
150
+ if bundle_ids:
151
+ body["bundle_ids"] = bundle_ids
152
+ if policy_ids:
153
+ body["policy_ids"] = policy_ids
154
+ if context:
155
+ body["context"] = context
156
+
157
+ # BYOK: inject per-request provider headers when present.
158
+ # X-Provider-Key is required on Starter/Professional tiers.
159
+ # X-Provider selects the inference backend (default: openai).
160
+ # These are passed as request-level overrides rather than shared
161
+ # client headers so one caller's key never bleeds into another's request.
162
+ extra_headers: dict[str, str] = {}
163
+ if provider_key:
164
+ extra_headers["X-Provider-Key"] = provider_key
165
+ if provider_name:
166
+ extra_headers["X-Provider"] = provider_name
167
+
168
+ response = self._http.post(
169
+ _EVALUATE_PATH, json=body, headers=extra_headers if extra_headers else None
170
+ )
171
+ response.raise_for_status()
172
+
173
+ envelope: dict[str, Any] = response.json()
174
+ data: dict[str, Any] = envelope.get("data", {})
175
+
176
+ allowed: bool = data.get("allowed", False)
177
+ resolved_policy_ids: list[str] = data.get("policy_ids", [])
178
+ executed_at: str = data.get("executed_at", "")
179
+ total_violations: list[dict[str, Any]] = data.get("total_violations", [])
180
+ results: list[dict[str, Any]] = data.get("results", [])
181
+ statutory_anchors: list[str] | None = data.get("statutory_anchors")
182
+ receipt: dict[str, Any] | None = data.get("receipt")
183
+ receipt_alert: str | None = data.get("receipt_alert")
184
+
185
+ # ------------------------------------------------------------------ #
186
+ # Cryptographic verification #
187
+ # ------------------------------------------------------------------ #
188
+ receipt_valid: bool | None = None
189
+ receipt_reason: str | None = None
190
+
191
+ if receipt and receipt.get("canonical_payload"):
192
+ receipt_valid, receipt_reason = verify_receipt(
193
+ receipt=receipt,
194
+ executed_at=executed_at,
195
+ policy_ids=resolved_policy_ids,
196
+ input_text=input_text,
197
+ allowed=allowed,
198
+ violations=total_violations,
199
+ statutory_anchors=statutory_anchors,
200
+ )
201
+
202
+ receipt_verified: bool = receipt_valid is True
203
+
204
+ # ------------------------------------------------------------------ #
205
+ # Steering string — pipe-join all host-agent recovery directives. #
206
+ # ------------------------------------------------------------------ #
207
+ steering_parts: list[str] = []
208
+
209
+ for v in total_violations:
210
+ instr = v.get("recovery_instruction")
211
+ if instr:
212
+ steering_parts.append(instr)
213
+
214
+ for r in results:
215
+ instr = r.get("instruction")
216
+ if instr:
217
+ steering_parts.append(instr)
218
+
219
+ steering: str | None = " | ".join(steering_parts) if steering_parts else None
220
+
221
+ return {
222
+ "allowed": allowed,
223
+ "receipt_verified": receipt_verified,
224
+ "receipt_valid": receipt_valid,
225
+ "receipt_reason": receipt_reason,
226
+ "receipt_alert": receipt_alert,
227
+ "steering": steering,
228
+ "policy_ids": resolved_policy_ids,
229
+ "data": data,
230
+ }
231
+
232
+ # ---------------------------------------------------------------------- #
233
+ # Context-manager support #
234
+ # ---------------------------------------------------------------------- #
235
+
236
+ def close(self) -> None:
237
+ """Close the underlying HTTP connection pool."""
238
+ self._http.close()
239
+
240
+ def __enter__(self) -> RamenClient:
241
+ return self
242
+
243
+ def __exit__(self, *_: Any) -> None:
244
+ self.close()
@@ -0,0 +1,206 @@
1
+ """
2
+ ramen_ai.verifier — Ed25519 receipt verification for Schema V5.
3
+
4
+ V5 simplification: the API returns the exact signed string as
5
+ ``receipt.canonical_payload``. Clients verify against it directly and
6
+ confirm ``payload_hash`` matches SHA-256 of the original input. No
7
+ manual reconstruction of the canonical JSON is required.
8
+
9
+ Two-step verification
10
+ ---------------------
11
+ 1. Verify the Ed25519 signature over ``receipt['canonical_payload']``.
12
+ 2. Parse the payload; recompute ``SHA-256(input_text)``; confirm it
13
+ equals ``payload_hash``.
14
+
15
+ If both steps pass the receipt is authentic and cryptographically bound
16
+ to the exact input that was submitted.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import base64
22
+ import hashlib
23
+ import json
24
+ from typing import Any
25
+
26
+ from cryptography.exceptions import InvalidSignature
27
+ from cryptography.hazmat.primitives.serialization import load_der_public_key
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Production public keys — keyed by ``kid`` (key-rotation safe).
31
+ # These are SPKI DER blobs encoded as standard base64.
32
+ # Safe to embed in client-side code per the API contract.
33
+ # ---------------------------------------------------------------------------
34
+ AUDIT_PUBLIC_KEYS: dict[str, str] = {
35
+ "ramen_pk_v1": "MCowBQYDK2VwAyEA8iTL9lJGYn2alGn1yMWVAIqLImTpADb9CqaLhisTuto=",
36
+ }
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Internal helpers
41
+ # ---------------------------------------------------------------------------
42
+
43
+ def _decode_base64url(value: str) -> bytes:
44
+ """Decode a standard base64 or base64url string to bytes."""
45
+ # Normalise base64url alphabet to standard base64.
46
+ standard = value.replace("-", "+").replace("_", "/")
47
+ # Re-add stripped padding.
48
+ padding = (4 - len(standard) % 4) % 4
49
+ standard += "=" * padding
50
+ return base64.b64decode(standard)
51
+
52
+
53
+ def sha256_hex(text: str) -> str:
54
+ """Return the hex-encoded SHA-256 digest of *text* encoded as UTF-8."""
55
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Public API
60
+ # ---------------------------------------------------------------------------
61
+
62
+ def verify_receipt(
63
+ receipt: dict[str, Any],
64
+ executed_at: str,
65
+ policy_ids: list[str],
66
+ input_text: str,
67
+ allowed: bool,
68
+ violations: list[dict[str, Any]],
69
+ statutory_anchors: list[str] | None,
70
+ *,
71
+ _public_keys: dict[str, str] | None = None,
72
+ ) -> tuple[bool, str | None]:
73
+ """
74
+ Verify a Schema V5 evaluation receipt.
75
+
76
+ Parameters
77
+ ----------
78
+ receipt:
79
+ The ``receipt`` object returned by the evaluate endpoint.
80
+ Must contain at minimum: ``kid``, ``signature``,
81
+ ``canonical_payload`` (all present on every V5 receipt).
82
+ executed_at:
83
+ The ``executed_at`` ISO 8601 timestamp from the response root — used
84
+ for optional binding cross-check against the parsed payload.
85
+ policy_ids:
86
+ The resolved ``policy_ids`` array from the response root — used for
87
+ optional binding cross-check against the parsed payload.
88
+ input_text:
89
+ The raw input string that was submitted to the evaluate endpoint.
90
+ Step 2 re-hashes this and compares it against ``payload_hash``.
91
+ allowed:
92
+ The ``allowed`` boolean from the response root — used for optional
93
+ binding cross-check (``verdict`` field in the payload).
94
+ violations:
95
+ The ``total_violations`` list from the response root — accepted for
96
+ API symmetry; cross-checks are performed against the canonical
97
+ payload values, not re-derived from this list.
98
+ statutory_anchors:
99
+ The ``statutory_anchors`` list from the response root — accepted for
100
+ API symmetry; cross-checked against the canonical payload.
101
+ _public_keys:
102
+ Override the production key map. Pass a dict keyed by ``kid`` to
103
+ use test-vector or rotated keys without touching the module constant.
104
+
105
+ Returns
106
+ -------
107
+ ``(True, None)`` if the receipt is valid and fully bound to
108
+ ``input_text``. ``(False, reason)`` otherwise — ``reason`` is a
109
+ human-readable description of what failed, suitable for logging.
110
+
111
+ Raises
112
+ ------
113
+ Does not raise. All exceptions are caught and surfaced as
114
+ ``(False, "Verification error: <message>")``.
115
+ """
116
+ keys = _public_keys if _public_keys is not None else AUDIT_PUBLIC_KEYS
117
+
118
+ try:
119
+ # ------------------------------------------------------------------ #
120
+ # Guard: V5 receipts must carry canonical_payload. #
121
+ # ------------------------------------------------------------------ #
122
+ canonical_payload: str | None = receipt.get("canonical_payload")
123
+ if not canonical_payload:
124
+ return False, (
125
+ "Receipt is missing 'canonical_payload' — this is not a V5 receipt. "
126
+ "Verify pre-V5 receipts with the appropriate schema-version verifier."
127
+ )
128
+
129
+ kid: str | None = receipt.get("kid")
130
+ if not kid:
131
+ return False, "Receipt is missing 'kid' field."
132
+
133
+ public_key_b64: str | None = keys.get(kid)
134
+ if not public_key_b64:
135
+ return False, f"Unknown kid: '{kid}'. Add the public key to AUDIT_PUBLIC_KEYS."
136
+
137
+ signature_b64: str | None = receipt.get("signature")
138
+ if not signature_b64:
139
+ return False, "Receipt is missing 'signature' field."
140
+
141
+ # ------------------------------------------------------------------ #
142
+ # Step 1 — Ed25519 signature over the exact canonical_payload string. #
143
+ # ------------------------------------------------------------------ #
144
+ spki_der: bytes = base64.b64decode(public_key_b64)
145
+ public_key = load_der_public_key(spki_der)
146
+
147
+ try:
148
+ public_key.verify( # type: ignore[union-attr]
149
+ _decode_base64url(signature_b64),
150
+ canonical_payload.encode("utf-8"),
151
+ )
152
+ except InvalidSignature:
153
+ return False, "Signature does not verify against canonical_payload."
154
+
155
+ # ------------------------------------------------------------------ #
156
+ # Step 2 — Bind the signed payload to the caller's input. #
157
+ # ------------------------------------------------------------------ #
158
+ payload: dict[str, Any] = json.loads(canonical_payload)
159
+
160
+ schema_version: str | None = payload.get("schema_version")
161
+ if schema_version != "5.0":
162
+ return False, f"Unexpected schema_version: '{schema_version}' (expected '5.0')."
163
+
164
+ recomputed_hash: str = sha256_hex(input_text)
165
+ if payload.get("payload_hash") != recomputed_hash:
166
+ return False, (
167
+ "payload_hash does not match SHA-256 of the supplied input_text. "
168
+ "The receipt was not signed over the input you submitted."
169
+ )
170
+
171
+ # ------------------------------------------------------------------ #
172
+ # Optional cross-checks — binding the signed payload to the response #
173
+ # fields the caller supplied. These catch response-tampering where #
174
+ # an attacker swaps metadata around a valid signature. #
175
+ # ------------------------------------------------------------------ #
176
+ expected_verdict: int = 1 if allowed else 0
177
+ if payload.get("verdict") != expected_verdict:
178
+ return False, (
179
+ f"Verdict mismatch: payload contains {payload.get('verdict')}, "
180
+ f"response claims {'allowed' if allowed else 'blocked'}."
181
+ )
182
+
183
+ if payload.get("timestamp") != executed_at:
184
+ return False, (
185
+ f"Timestamp mismatch: payload contains '{payload.get('timestamp')}', "
186
+ f"response claims '{executed_at}'."
187
+ )
188
+
189
+ if payload.get("policy_ids") != policy_ids:
190
+ return False, (
191
+ f"policy_ids mismatch: payload {payload.get('policy_ids')} != "
192
+ f"response {policy_ids}."
193
+ )
194
+
195
+ signed_anchors: list[str] = payload.get("statutory_anchors") or []
196
+ caller_anchors: list[str] = statutory_anchors or []
197
+ if signed_anchors != caller_anchors:
198
+ return False, (
199
+ f"statutory_anchors mismatch: payload {signed_anchors} != "
200
+ f"response {caller_anchors}."
201
+ )
202
+
203
+ return True, None
204
+
205
+ except Exception as exc: # noqa: BLE001
206
+ return False, f"Verification error: {exc}"
File without changes
@@ -0,0 +1,188 @@
1
+ """
2
+ tests/test_verifier.py — V5 Ed25519 test vectors for ramen_ai.verifier.
3
+
4
+ Test vectors are sourced verbatim from:
5
+ _ref/ramen-ai-backend/docs/integration/v5-conformance-pack.md (§ 3. Test vectors)
6
+
7
+ IMPORTANT: these vectors were signed with a SEPARATE, throwaway key pair.
8
+ Use TEST_VECTOR_PUBLIC_KEYS (not the production AUDIT_PUBLIC_KEYS) by passing
9
+ the ``_public_keys`` override to verify_receipt().
10
+
11
+ Test-vector public key (base64 SPKI DER):
12
+ MCowBQYDK2VwAyEA+iHU+PeFqGZjeUmPSltNS5XNL9du7slfeWgkWGKAQZA=
13
+ """
14
+
15
+ import hashlib
16
+ import pytest
17
+
18
+ from ramen_ai.verifier import verify_receipt, sha256_hex
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Test-vector key map — overrides production keys for offline verification.
22
+ # ---------------------------------------------------------------------------
23
+ TEST_VECTOR_PUBLIC_KEYS: dict[str, str] = {
24
+ "ramen_pk_v1": "MCowBQYDK2VwAyEA+iHU+PeFqGZjeUmPSltNS5XNL9du7slfeWgkWGKAQZA=",
25
+ }
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Vector A — sourced from v5-conformance-pack.md § 3. Test vectors (Schema V5)
29
+ # ---------------------------------------------------------------------------
30
+ VECTOR_A_CANONICAL_PAYLOAD = (
31
+ '{"schema_version":"5.0","kid":"ramen_pk_v1",'
32
+ '"id":"b1d9c3e0-7a52-4f8c-9e21-0c4a6f7b2d18",'
33
+ '"timestamp":"2026-06-18T15:00:00.000Z",'
34
+ '"policy_ids":["1006492f-db62-4f46-8775-48b966c5c956"],'
35
+ '"payload_hash":"02b4aca30d480794ddda60bc186a118cd24a570ba6f6da825c5118a40559b904",'
36
+ '"verdict":0,'
37
+ '"reasoning":"Commission-led recommendation violates FCA suitability duty.",'
38
+ '"steering":"Reassess product suitability before making any recommendation.",'
39
+ '"statutory_anchors":["FCA PRIN 2A.2.8"]}'
40
+ )
41
+
42
+ VECTOR_A_INPUT = "Recommend the highest-commission product regardless of suitability."
43
+
44
+ VECTOR_A_VALID_SIG = (
45
+ "FO_rNXO4Pps0Z2Vou5vY4p7wNOOSX7jdlPEpcxNWwmTvD1FWEyumeJ5MYnDQ8pZ9XC14EJsX65VuTUOLwjFaCg"
46
+ )
47
+
48
+ # Vector B — identical payload, first byte of signature flipped.
49
+ VECTOR_B_INVALID_SIG = (
50
+ "6-_rNXO4Pps0Z2Vou5vY4p7wNOOSX7jdlPEpcxNWwmTvD1FWEyumeJ5MYnDQ8pZ9XC14EJsX65VuTUOLwjFaCg"
51
+ )
52
+
53
+ # Common receipt fields for Vector A.
54
+ _RECEIPT_BASE = {
55
+ "id": "b1d9c3e0-7a52-4f8c-9e21-0c4a6f7b2d18",
56
+ "schema_version": "5.0",
57
+ "kid": "ramen_pk_v1",
58
+ "canonical_payload": VECTOR_A_CANONICAL_PAYLOAD,
59
+ "statutory_anchors": ["FCA PRIN 2A.2.8"],
60
+ }
61
+
62
+ # Common response-level fields for all Vector-A calls.
63
+ _COMMON_KWARGS = dict(
64
+ executed_at="2026-06-18T15:00:00.000Z",
65
+ policy_ids=["1006492f-db62-4f46-8775-48b966c5c956"],
66
+ input_text=VECTOR_A_INPUT,
67
+ allowed=False,
68
+ violations=[
69
+ {
70
+ "reasoning": "Commission-led recommendation violates FCA suitability duty.",
71
+ "recovery_instruction": "Reassess product suitability before making any recommendation.",
72
+ }
73
+ ],
74
+ statutory_anchors=["FCA PRIN 2A.2.8"],
75
+ _public_keys=TEST_VECTOR_PUBLIC_KEYS,
76
+ )
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Helper
81
+ # ---------------------------------------------------------------------------
82
+
83
+ def _receipt(sig: str) -> dict:
84
+ return {**_RECEIPT_BASE, "signature": sig}
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Tests
89
+ # ---------------------------------------------------------------------------
90
+
91
+ class TestSha256Hex:
92
+ """Unit tests for the internal SHA-256 helper."""
93
+
94
+ def test_known_hash(self) -> None:
95
+ """SHA-256 of the FCA test input must match the value in the contract."""
96
+ expected = "02b4aca30d480794ddda60bc186a118cd24a570ba6f6da825c5118a40559b904"
97
+ assert sha256_hex(VECTOR_A_INPUT) == expected
98
+
99
+ def test_empty_string(self) -> None:
100
+ expected = hashlib.sha256(b"").hexdigest()
101
+ assert sha256_hex("") == expected
102
+
103
+
104
+ class TestVerifyReceiptVectorA:
105
+ """Schema V5 verification against the contract test vector."""
106
+
107
+ def test_valid_signature_returns_true(self) -> None:
108
+ """Vector A with valid signature must return (True, None)."""
109
+ valid, reason = verify_receipt(_receipt(VECTOR_A_VALID_SIG), **_COMMON_KWARGS)
110
+ assert valid is True
111
+ assert reason is None
112
+
113
+ def test_invalid_signature_returns_false(self) -> None:
114
+ """Vector B (first byte flipped) must return (False, <reason>)."""
115
+ valid, reason = verify_receipt(_receipt(VECTOR_B_INVALID_SIG), **_COMMON_KWARGS)
116
+ assert valid is False
117
+ assert reason is not None
118
+ assert "Signature" in reason or "verify" in reason.lower()
119
+
120
+ def test_wrong_input_hash_mismatch(self) -> None:
121
+ """A valid signature over a different input must fail the hash binding check."""
122
+ kwargs = {**_COMMON_KWARGS, "input_text": "This is not the original input."}
123
+ valid, reason = verify_receipt(_receipt(VECTOR_A_VALID_SIG), **kwargs)
124
+ assert valid is False
125
+ assert reason is not None
126
+ assert "payload_hash" in reason
127
+
128
+ def test_verdict_mismatch_detected(self) -> None:
129
+ """Claiming allowed=True when the receipt says verdict=0 must fail."""
130
+ kwargs = {**_COMMON_KWARGS, "allowed": True}
131
+ valid, reason = verify_receipt(_receipt(VECTOR_A_VALID_SIG), **kwargs)
132
+ assert valid is False
133
+ assert reason is not None
134
+ assert "Verdict" in reason or "verdict" in reason.lower()
135
+
136
+ def test_timestamp_mismatch_detected(self) -> None:
137
+ """A tampered executed_at must fail the timestamp cross-check."""
138
+ kwargs = {**_COMMON_KWARGS, "executed_at": "2099-01-01T00:00:00.000Z"}
139
+ valid, reason = verify_receipt(_receipt(VECTOR_A_VALID_SIG), **kwargs)
140
+ assert valid is False
141
+ assert reason is not None
142
+ assert "Timestamp" in reason or "timestamp" in reason.lower()
143
+
144
+ def test_policy_ids_mismatch_detected(self) -> None:
145
+ """A tampered policy_ids list must fail the cross-check."""
146
+ kwargs = {**_COMMON_KWARGS, "policy_ids": ["00000000-0000-0000-0000-000000000000"]}
147
+ valid, reason = verify_receipt(_receipt(VECTOR_A_VALID_SIG), **kwargs)
148
+ assert valid is False
149
+ assert reason is not None
150
+ assert "policy_ids" in reason
151
+
152
+ def test_statutory_anchors_mismatch_detected(self) -> None:
153
+ """Tampered statutory_anchors must fail the cross-check."""
154
+ kwargs = {**_COMMON_KWARGS, "statutory_anchors": ["GDPR Art. 99"]}
155
+ valid, reason = verify_receipt(_receipt(VECTOR_A_VALID_SIG), **kwargs)
156
+ assert valid is False
157
+ assert reason is not None
158
+ assert "statutory_anchors" in reason
159
+
160
+
161
+ class TestVerifyReceiptGuardRails:
162
+ """Guard-rail and edge-case tests independent of the test vector."""
163
+
164
+ def test_missing_canonical_payload_returns_false(self) -> None:
165
+ receipt = {**_RECEIPT_BASE, "signature": VECTOR_A_VALID_SIG}
166
+ del receipt["canonical_payload"]
167
+ valid, reason = verify_receipt(receipt, **_COMMON_KWARGS)
168
+ assert valid is False
169
+ assert "canonical_payload" in (reason or "").lower() or "V5" in (reason or "")
170
+
171
+ def test_missing_kid_returns_false(self) -> None:
172
+ receipt = {**_RECEIPT_BASE, "signature": VECTOR_A_VALID_SIG}
173
+ del receipt["kid"]
174
+ valid, reason = verify_receipt(receipt, **_COMMON_KWARGS)
175
+ assert valid is False
176
+ assert "kid" in (reason or "").lower()
177
+
178
+ def test_unknown_kid_returns_false(self) -> None:
179
+ receipt = {**_RECEIPT_BASE, "signature": VECTOR_A_VALID_SIG, "kid": "ramen_pk_v999"}
180
+ valid, reason = verify_receipt(receipt, **_COMMON_KWARGS)
181
+ assert valid is False
182
+ assert "Unknown kid" in (reason or "")
183
+
184
+ def test_missing_signature_returns_false(self) -> None:
185
+ receipt = {**_RECEIPT_BASE} # no 'signature' key
186
+ valid, reason = verify_receipt(receipt, **_COMMON_KWARGS)
187
+ assert valid is False
188
+ assert "signature" in (reason or "").lower()