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.
- ramen_ai_core-0.2.0/.gitignore +76 -0
- ramen_ai_core-0.2.0/.gitkeep +0 -0
- ramen_ai_core-0.2.0/PKG-INFO +11 -0
- ramen_ai_core-0.2.0/README.md +106 -0
- ramen_ai_core-0.2.0/pyproject.toml +26 -0
- ramen_ai_core-0.2.0/ramen_ai/__init__.py +7 -0
- ramen_ai_core-0.2.0/ramen_ai/client.py +244 -0
- ramen_ai_core-0.2.0/ramen_ai/verifier.py +206 -0
- ramen_ai_core-0.2.0/tests/__init__.py +0 -0
- ramen_ai_core-0.2.0/tests/test_verifier.py +188 -0
|
@@ -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,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()
|