diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4ebd633 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,17 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v6 + - name: Validate demo artifact + run: ./scripts/verify.sh + - name: Resolve client dependencies + run: uv sync --locked || uv sync diff --git a/README.md b/README.md index 2987af6..0de3023 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Clone, `docker compose up`, and in 15 minutes you have a running proxy demonstrating end-to-end agentic security: agent identification, signed-bot verification, payment-mandate -verification, per-agent rate-limit enforcement, prompt-linked +verification, agent-budget rate-limit enforcement, prompt-linked audit, and per-request trust tiers. ## Quick start @@ -11,7 +11,7 @@ audit, and per-request trust tiers. ```bash git clone https://github.com/soapbucket/agentic-security-demo cd agentic-security-demo -docker compose up -d +docker compose up -d --build --wait uv sync # installs the scenario clients' Python deps ./scripts/walkthrough.sh ``` @@ -31,17 +31,22 @@ replays the same flow in ~5 minutes. ## What you see -The demo wires six distinct capabilities into one running stack -and exercises each one with a representative client: +The demo wires six capabilities into one running stack and +exercises each one with a representative client. The public +one-clone path uses the OSS sbproxy release for live gateway +enforcement of agent detection, Web Bot Auth, and agent budgets; +the mock origin emits deterministic audit rows for enterprise-only +AP2 and MCP audit surfaces so the walkthrough still runs without +private images or licenses. | # | Scenario | What the demo shows | |---|---|---| | 1 | **Agent detection** | A fake Claude-Code-shape client is identified by UA + headers + JA4; an unsigned scraper is flagged `Suspicious` instead | | 2 | **Web Bot Auth verification** | A signed request from a `Signature-Agent`-shaped signer passes; the same request without the signature is denied | | 3 | **AP2 mandate verification** | An x402 payment request carrying a valid AP2 Cart Mandate succeeds; a replayed mandate is rejected with `409 Conflict` | -| 4 | **Agent budget enforcement** | The fake Claude-Code client fires 50 req/s; the proxy throttles to the configured cap with structured `429`s | -| 5 | **Prompt-linked audit** | An MCP tool call is captured with the originating prompt + the upstream call linked by a single envelope on the audit chain | -| 6 | **Trust tier** | Each request shows its computed tier (`VerifiedSigned`, `BehaviouralTrusted`, `Unknown`, `Suspicious`, or `Hostile`) on the access log | +| 4 | **Agent budget enforcement** | The fake Claude-Code client bursts above the configured public-demo budget; the proxy returns structured `429`s | +| 5 | **Prompt-linked audit** | An MCP tool call is captured with the originating prompt + the upstream call linked by a single envelope on the demo audit log | +| 6 | **Trust tier** | Each request shows the expected tier (`VerifiedSigned`, `BehaviouralTrusted`, `Suspicious`) on the access log | ## Architecture @@ -51,9 +56,9 @@ and exercises each one with a representative client: │ ───────────────── │ │ agent detect │ scenario clients ─▶ │ web bot auth │ ─▶ mock origin - │ AP2 mandate verify │ + │ AP2 demo route │ │ agent budget │ - │ prompt-linked audit │ + │ prompt audit route │ └──────────┬───────────┘ │ ┌──────────┴───────────┐ @@ -65,8 +70,8 @@ Every container is in `docker-compose.yml`. Operators inspect each capability via: * Access log: `docker compose exec sbproxy tail -F /var/log/sbproxy/access.jsonl` -* Audit chain: `docker compose exec sbproxy tail -F /var/log/sbproxy/audit.jsonl` -* Metrics: +* Demo audit log: `docker compose exec sbproxy tail -F /var/log/sbproxy/audit.jsonl` +* Metrics: `docker compose exec -T sbproxy wget -qO- http://127.0.0.1:9090/metrics` ## Build requirements @@ -83,7 +88,7 @@ agentic-security-demo/ ├── pyproject.toml ◀ uv sync installs the client deps ├── docker-compose.yml ◀ the full stack ├── sbproxy-config/ -│ └── sb.yml ◀ proxy config wiring all 6 scenarios +│ └── sb.yml ◀ proxy config wiring the demo hosts ├── mock-origin/ ◀ httpbin-shaped target API │ └── server.py ├── clients/ ◀ one client per scenario, run via `uv run` @@ -112,15 +117,15 @@ agentic-security-demo/ ## Build notes -Some scenarios (AP2 mandate verification, prompt-linked audit, -trust tier) ride on the **SBproxy Enterprise** binary, not the -OSS sbproxy. The demo's `docker-compose.yml` defaults to the -enterprise image (`ghcr.io/soapbucket/sbproxy-enterprise:1.0`) -and reads the license key from `SBPROXY_LICENSE_KEY`. The OSS -build runs scenarios 1, 2, and 4; trial licenses for the rest -are available from `legal@soapbucket.com`. +`docker-compose.yml` builds a local image from the public +`soapbucket/sbproxy` release tarballs and verifies the published +SHA-256 checksum during the build. Set `SBPROXY_VERSION=v1.1.0` +or another release tag to pin the binary. -Each scenario's doc names which build it requires up front. +The public demo does not pull private GHCR images. Enterprise +deployments can replace the sbproxy service with the commercial +image and move the AP2 / MCP audit / trust-tier demo-mode logic +from the mock origin into gateway policy. ## License diff --git a/clients/agent_budget_burst.py b/clients/agent_budget_burst.py index bcec628..215772a 100644 --- a/clients/agent_budget_burst.py +++ b/clients/agent_budget_burst.py @@ -1,15 +1,9 @@ """Scenario 4: agent budget enforcement. Fires 50 requests per second from the Claude-Code-shape client -shown in scenario 1. The proxy's `agent_budget` policy keys on -the resolved agent identity, so every request hits the same -bucket. The configured cap is 5/s with a small burst; the demo -script reads back the 429 count and the per-second admit rate -from the access log. - -Demonstrates that the per-agent budget is identity-aware: a -second client with a different agent_id would not share the -bucket (try `unsigned-scraper.py` in parallel to confirm). +shown in scenario 1. The public v1.1.0 demo routes unresolved +agents through `on_anonymous: shared`, so every request hits the +same small bucket and the script reads back the 429 count. Usage: python agent-budget-burst.py [--duration-secs 5] http://127.0.0.1:8080/anything @@ -17,6 +11,7 @@ import argparse import concurrent.futures +import os import sys import time import urllib.request @@ -24,9 +19,10 @@ def fire_one(url: str) -> int: req = urllib.request.Request(url, method="GET") - req.add_header("Host", "demo.local") + req.add_header("Host", os.environ.get("DEMO_HOST", "demo.local")) req.add_header("User-Agent", "claude-cli/1.2.3 (external, cli)") req.add_header("x-stainless-arch", "arm64") + req.add_header("x-demo-trust-tier", "BehaviouralTrusted") try: with urllib.request.urlopen(req, timeout=2) as resp: return resp.status @@ -48,8 +44,8 @@ def main() -> int: end = time.time() + args.duration_secs statuses: list[int] = [] - # 50 in-flight per round; the proxy throttles to ~5/s, so we - # see a stream of 429 + 200. + # 50 in-flight per round; the proxy's shared demo budget should + # return a mix of 429 + 200. with concurrent.futures.ThreadPoolExecutor(max_workers=50) as pool: while time.time() < end: batch = [pool.submit(fire_one, args.url) for _ in range(50)] diff --git a/clients/ap2_payment.py b/clients/ap2_payment.py index 9aefeb5..c6a2126 100644 --- a/clients/ap2_payment.py +++ b/clients/ap2_payment.py @@ -19,6 +19,7 @@ """ import argparse +import os import sys import time import urllib.request @@ -79,9 +80,10 @@ def main() -> int: sd_jwt = mint_cart_mandate(args.mandate_id) req = urllib.request.Request(args.url, method="POST", data=b'{"intent":"purchase"}') - req.add_header("Host", "demo.local") + req.add_header("Host", os.environ.get("DEMO_HOST", "ap2.demo.local")) req.add_header("User-Agent", "ap2-demo-client/0.1") req.add_header("Content-Type", "application/json") + req.add_header("x-demo-trust-tier", "VerifiedSigned") # The x402 payment header carries the SD-JWT mandate. req.add_header("X-Payment-Mandate", sd_jwt) try: diff --git a/clients/ap2_replay.py b/clients/ap2_replay.py index 6177fa5..2c5613d 100644 --- a/clients/ap2_replay.py +++ b/clients/ap2_replay.py @@ -12,6 +12,7 @@ import sys import time +import os # Reuse the minting helper from the happy-path client so both # scenarios share the SD-JWT shape. `uv run` sets cwd to the @@ -27,9 +28,10 @@ def submit(url: str, sd_jwt: str) -> tuple[int, str]: req = urllib.request.Request(url, method="POST", data=b'{"intent":"purchase"}') - req.add_header("Host", "demo.local") + req.add_header("Host", os.environ.get("DEMO_HOST", "ap2.demo.local")) req.add_header("User-Agent", "ap2-replay-demo/0.1") req.add_header("Content-Type", "application/json") + req.add_header("x-demo-trust-tier", "VerifiedSigned") req.add_header("X-Payment-Mandate", sd_jwt) try: with urllib.request.urlopen(req, timeout=5) as resp: diff --git a/clients/claude_code_like.py b/clients/claude_code_like.py index 58f9527..b7630c0 100644 --- a/clients/claude_code_like.py +++ b/clients/claude_code_like.py @@ -4,9 +4,9 @@ `claude-cli/`, the OpenAI-Stainless SDK header set (`x-stainless-arch`, etc.). The proxy's agent_detect step recognises the prefix + header tell and stamps -`agent.id = claude-code-cli` on the request context. The -trust-tier policy then resolves to `BehaviouralTrusted` because -the agent is named (`unsigned-named`) but there is no signature. +the ADRF verdict on the request context. The public demo stamps +`BehaviouralTrusted` into the access log because the request is +named by wire shape but unsigned. Usage: python claude-code-like.py http://127.0.0.1:8080/anything @@ -15,13 +15,14 @@ agent_budget exercise runs the burst variant below. """ +import os import sys import urllib.request def request_with_claude_code_shape(url: str) -> tuple[int, str]: req = urllib.request.Request(url, method="GET") - req.add_header("Host", "demo.local") + req.add_header("Host", os.environ.get("DEMO_HOST", "demo.local")) req.add_header( "User-Agent", "claude-cli/1.2.3 (external, cli)", @@ -31,6 +32,7 @@ def request_with_claude_code_shape(url: str) -> tuple[int, str]: req.add_header("x-stainless-arch", "arm64") req.add_header("x-stainless-os", "Darwin") req.add_header("x-stainless-runtime", "node") + req.add_header("x-demo-trust-tier", "BehaviouralTrusted") try: with urllib.request.urlopen(req, timeout=5) as resp: return resp.status, resp.read().decode("utf-8") diff --git a/clients/mcp_tool_call.py b/clients/mcp_tool_call.py index bc09015..0495b6d 100644 --- a/clients/mcp_tool_call.py +++ b/clients/mcp_tool_call.py @@ -18,6 +18,7 @@ """ import json +import os import sys import urllib.request import uuid @@ -57,16 +58,17 @@ def main() -> int: method="POST", data=json.dumps(request_body).encode("utf-8"), ) - req.add_header("Host", "demo.local") + req.add_header("Host", os.environ.get("DEMO_HOST", "audit.demo.local")) req.add_header("Content-Type", "application/json") req.add_header("User-Agent", "claude-cli/1.2.3 (external, cli)") req.add_header("x-stainless-arch", "arm64") + req.add_header("x-demo-trust-tier", "BehaviouralTrusted") try: with urllib.request.urlopen(req, timeout=5) as resp: print(f"HTTP {resp.status}") print(resp.read().decode("utf-8")[:500]) print() - print("(check the audit chain for the McpPromptLinkedAudit envelope:") + print("Check the audit chain for the McpPromptLinkedAudit envelope:") print(" docker compose exec sbproxy tail -1 /var/log/sbproxy/audit.jsonl)") return 0 except urllib.error.HTTPError as exc: diff --git a/clients/signed_bot.py b/clients/signed_bot.py index 415b590..e46b41d 100644 --- a/clients/signed_bot.py +++ b/clients/signed_bot.py @@ -18,8 +18,10 @@ """ import base64 +import os import sys import time +import urllib.parse import urllib.request try: @@ -43,9 +45,12 @@ def sign_b64(data: bytes) -> str: def build_signed_request(url: str) -> urllib.request.Request: + host = os.environ.get("DEMO_HOST", "botauth.demo.local") + path = urllib.parse.urlsplit(url).path or "/" req = urllib.request.Request(url, method="GET") - req.add_header("Host", "demo.local") + req.add_header("Host", host) req.add_header("User-Agent", "openai-operator/0.1 (web-bot-auth)") + req.add_header("x-demo-trust-tier", "VerifiedSigned") created = int(time.time()) # RFC 9421 covers signature base + signature input headers. # The demo uses a minimal coverage set: @method @path @authority @@ -61,8 +66,8 @@ def build_signed_request(url: str) -> urllib.request.Request: # matching the sig_input above. base = ( f'"@method": GET\n' - f'"@path": /anything\n' - f'"@authority": demo.local\n' + f'"@path": {path}\n' + f'"@authority": {host}\n' f'"date": {date_value}\n' f'"@signature-params": ("@method" "@path" "@authority" "date");' f"created={created};keyid=\"{_KID}\";alg=\"ed25519\"" diff --git a/clients/unsigned_scraper.py b/clients/unsigned_scraper.py index 438c5e1..833069e 100644 --- a/clients/unsigned_scraper.py +++ b/clients/unsigned_scraper.py @@ -10,6 +10,7 @@ python unsigned-scraper.py http://127.0.0.1:8080/anything """ +import os import sys import urllib.request @@ -17,10 +18,11 @@ def main() -> int: url = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8080/anything" req = urllib.request.Request(url, method="GET") - req.add_header("Host", "demo.local") + req.add_header("Host", os.environ.get("DEMO_HOST", "demo.local")) # Generic UA, no identifying headers, no signature. The # proxy's policy stack sees an unmatched anonymous request. req.add_header("User-Agent", "Mozilla/5.0 (compatible; scraper/0)") + req.add_header("x-demo-trust-tier", "Suspicious") try: with urllib.request.urlopen(req, timeout=5) as resp: print(f"HTTP {resp.status}") diff --git a/docker-compose.yml b/docker-compose.yml index b0c7ea7..5b1a0a3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,23 +1,21 @@ # Agentic Security Demo: full stack for the 6 scenarios in -# scripts/walkthrough.sh. Brings up the proxy, its required -# state, a mock origin, and a metrics endpoint. The scenario -# clients live in clients/ and are run by the walkthrough. +# scripts/walkthrough.sh. Brings up the proxy, its required state, +# a mock origin, optional client services, and a metrics/readiness +# endpoint. The walkthrough runs the clients from the host via uv. # # Tunables: -# SBPROXY_IMAGE_TAG proxy image tag (default 1.0) -# SBPROXY_LICENSE_KEY enterprise license; only needed for -# scenarios 3, 5, 6 +# SBPROXY_VERSION public sbproxy release tag (default v1.1.0) # DEMO_NAMESPACE compose project name (default `agentic-demo`) name: agentic-demo services: sbproxy: - # Default to the enterprise image because scenarios 3 (AP2), - # 5 (prompt-linked audit), and 6 (trust tier) are enterprise - # features. Operators that want OSS-only can override to - # ghcr.io/soapbucket/sbproxy:1.0 and skip those scenarios. - image: ghcr.io/soapbucket/sbproxy-enterprise:${SBPROXY_IMAGE_TAG:-1.0} + build: + context: ./sbproxy-image + args: + SBPROXY_VERSION: ${SBPROXY_VERSION:-v1.1.0} + image: agentic-demo-sbproxy:${SBPROXY_VERSION:-v1.1.0} container_name: agentic-demo-sbproxy depends_on: postgres: @@ -27,23 +25,19 @@ services: mock-origin: condition: service_started environment: - # Read at boot. Missing or invalid license disables - # scenarios 3, 5, 6 (the proxy still runs). - SBPROXY_LICENSE_KEY: ${SBPROXY_LICENSE_KEY:-} # Tell the proxy where its state lives. DATABASE_URL: postgres://sbproxy:demo@postgres/sbproxy REDIS_URL: redis://redis:6379/0 RUST_LOG: info,sbproxy=info,sbproxy_modules=info command: - serve - - --config + - -f - /etc/sbproxy/sb.yml ports: - "127.0.0.1:8080:8080" # proxy data plane - - "127.0.0.1:9090:9090" # metrics + readyz volumes: - ./sbproxy-config:/etc/sbproxy:ro - - sbproxy-logs:/var/log/sbproxy + - demo-logs:/var/log/sbproxy restart: unless-stopped healthcheck: test: ["CMD", "wget", "-qO-", "http://127.0.0.1:9090/readyz"] @@ -84,9 +78,33 @@ services: build: context: ./mock-origin container_name: agentic-demo-mock-origin + volumes: + - demo-logs:/var/log/sbproxy expose: - "3000" + claude-code-client: + image: python:3.12-alpine + profiles: ["clients"] + working_dir: /demo + volumes: + - .:/demo:ro + command: ["python", "clients/claude_code_like.py", "http://sbproxy:8080/anything"] + depends_on: + sbproxy: + condition: service_healthy + + unsigned-scraper: + image: python:3.12-alpine + profiles: ["clients"] + working_dir: /demo + volumes: + - .:/demo:ro + command: ["python", "clients/unsigned_scraper.py", "http://sbproxy:8080/anything"] + depends_on: + sbproxy: + condition: service_healthy + volumes: - sbproxy-logs: + demo-logs: postgres-data: diff --git a/docs/WALKTHROUGH.md b/docs/WALKTHROUGH.md index 99705a0..df73bde 100644 --- a/docs/WALKTHROUGH.md +++ b/docs/WALKTHROUGH.md @@ -7,33 +7,37 @@ in `docs/scenarios/` for the wire-level detail. ## Pre-flight ```bash -docker compose up -d +docker compose up -d --build --wait docker compose ps # all containers healthy? -curl -fsS http://127.0.0.1:9090/readyz | jq . +docker compose exec -T sbproxy wget -qO- http://127.0.0.1:9090/readyz ``` Expected: every service `running (healthy)`; `readyz` returns -HTTP 200 with `{"status":"ready"}`. +HTTP 200 with `{"status":"ok"}`. ## Scenario 1 — Agent detection ```bash uv run clients/claude_code_like.py http://127.0.0.1:8080/anything -docker compose exec sbproxy tail -1 /var/log/sbproxy/access.jsonl | jq . +docker compose exec sbproxy tail -1 /var/log/sbproxy/access.jsonl ``` -Look for `request.agent.id = "claude-code-cli"` and -`request.agent.score = 95` on the access-log row. Then: +Look for the Claude-Code wire shape on the access-log row: +`user_agent = "claude-cli/..."`, `x-stainless-arch`, and +`custom.demo_trust_tier = "BehaviouralTrusted"`. +Then: ```bash uv run clients/unsigned_scraper.py http://127.0.0.1:8080/anything -docker compose exec sbproxy tail -1 /var/log/sbproxy/access.jsonl | jq . +docker compose exec sbproxy tail -1 /var/log/sbproxy/access.jsonl ``` -The scraper's row has `request.agent.provenance = "unsigned-anonymous"` -and `request.trust_tier = "Unknown"`. The proxy did NOT block; -it identified and stamped, letting the policy stack make the -deny call. +The scraper's row has no Stainless tell and the demo stamps +`custom.demo_trust_tier = "Suspicious"` through a request header +so the walkthrough can show the trust-tier story without a private +enterprise build. The ADRF rule-pack scorer is enabled in the +gateway; v1.1.0 does not emit that scorer's named verdict as a +flat access-log field. Detail: `docs/scenarios/01-agent-detection.md`. @@ -41,29 +45,30 @@ Detail: `docs/scenarios/01-agent-detection.md`. ```bash uv run clients/signed_bot.py http://127.0.0.1:8080/anything -docker compose exec sbproxy tail -1 /var/log/sbproxy/access.jsonl | jq . +docker compose exec sbproxy tail -1 /var/log/sbproxy/access.jsonl ``` -`request.bot_auth.verified = true` and `request.trust_tier = -"VerifiedSigned"`. Demonstrate the negative case by stripping -the `Signature` header (or just hand-running curl); the same -request returns 401 with `denial_reason = "bot_auth_signature_missing"`. +The signed request reaches the origin with `principal_kind = +"bot_auth"` in the access log. Demonstrate the negative case by +running the unsigned scraper against the same `botauth.demo.local` +host; the same route returns 401 before reaching the origin. Detail: `docs/scenarios/02-web-bot-auth.md`. -## Scenario 3 — AP2 mandate verification (enterprise) +## Scenario 3 — AP2 mandate verification ```bash uv run clients/ap2_payment.py http://127.0.0.1:8080/anything -docker compose exec sbproxy tail -1 /var/log/sbproxy/audit.jsonl | jq . +docker compose exec sbproxy tail -1 /var/log/sbproxy/audit.jsonl uv run clients/ap2_replay.py http://127.0.0.1:8080/anything ``` -`ap2-payment.py` returns 200 and the audit row's `action` is -`MandateVerified` with `result: success`. `ap2-replay.py` is +`ap2-payment.py` returns 200 and the demo audit row's `action` +is `MandateVerified` with `result: success`. `ap2-replay.py` is deliberately two-shot: the first hits 200, the second hits `409 Conflict` because the mandate's `jti` already lives in the -proxy's nonce store. +mock origin's demo nonce store. In an enterprise deployment, that +same replay check belongs in the gateway policy. Detail: `docs/scenarios/03-ap2-mandate.md`. @@ -74,28 +79,29 @@ uv run clients/agent_budget_burst.py --duration-secs 5 \ http://127.0.0.1:8080/anything ``` -The script prints a status-code histogram. With a 5/s cap and -50/s offered load you should see ~25 HTTP 200 and ~225 HTTP 429 -across a 5-second window. The 429s carry `Retry-After`; check +The script prints a status-code histogram. The public demo uses +`on_anonymous: shared` with a deliberately small bucket +(`requests_per_minute: 10`, `burst: 5`) because v1.1.0 does not +accept inline demo agent-class catalogs. You should see some HTTP +200s and the rest HTTP 429. The 429s carry `Retry-After`; check one of them: ```bash -docker compose exec sbproxy tail -50 /var/log/sbproxy/access.jsonl \ - | jq -s '[.[] | select(.response.status == 429)] | length' +docker compose exec sbproxy tail -50 /var/log/sbproxy/access.jsonl ``` The 429 count matches the burst's overage. Detail: `docs/scenarios/04-agent-budget.md`. -## Scenario 5 — Prompt-linked audit (enterprise) +## Scenario 5 — Prompt-linked audit ```bash uv run clients/mcp_tool_call.py http://127.0.0.1:8080/mcp/v1 -docker compose exec sbproxy tail -1 /var/log/sbproxy/audit.jsonl | jq . +docker compose exec sbproxy tail -1 /var/log/sbproxy/audit.jsonl ``` -The envelope shape pairs the prompt with the tool call: +The demo audit row pairs the prompt with the tool call: ```json { @@ -116,20 +122,20 @@ Detail: `docs/scenarios/05-prompt-linked-audit.md`. ## Scenario 6 — Trust tier -The trust-tier policy stamps every request the proxy serves. To -see the spread, run a mix: +The public demo stamps expected trust tiers into a custom access-log +field. To see the spread, run a mix: ```bash uv run clients/claude_code_like.py http://127.0.0.1:8080/anything uv run clients/signed_bot.py http://127.0.0.1:8080/anything -uv run clients/unsigned_scraper.py http://127.0.0.1:8080/anything +DEMO_HOST=audit.demo.local \ + uv run clients/unsigned_scraper.py http://127.0.0.1:8080/anything -docker compose exec sbproxy tail -10 /var/log/sbproxy/access.jsonl \ - | jq -s 'group_by(.request.trust_tier) | map({tier: .[0].request.trust_tier, count: length})' +docker compose exec sbproxy tail -10 /var/log/sbproxy/access.jsonl ``` -The histogram shows `VerifiedSigned`, `BehaviouralTrusted`, and -`Unknown` (or `Suspicious`) in roughly equal counts. +The recent rows show `custom.demo_trust_tier` values such as +`VerifiedSigned`, `BehaviouralTrusted`, and `Suspicious`. Detail: `docs/scenarios/06-trust-tier.md`. diff --git a/docs/scenarios/01-agent-detection.md b/docs/scenarios/01-agent-detection.md index 072a969..7bbd800 100644 --- a/docs/scenarios/01-agent-detection.md +++ b/docs/scenarios/01-agent-detection.md @@ -1,14 +1,14 @@ # Scenario 1 — Agent detection -**Build tier**: OSS (works against `ghcr.io/soapbucket/sbproxy:1.0`). +**Build tier**: OSS public-release image built by this repo. ## What it shows -The proxy identifies named AI agents (Claude Code, Cursor, -OpenAI SDK shapes, etc.) from the wire shape of their requests -(User-Agent + headers + JA4 TLS fingerprint). The same matcher -flags unknown traffic as `unsigned-anonymous` so downstream -policy can deny-by-default. +The proxy runs an ADRF rule-pack scorer over AI-agent wire shapes +(User-Agent + headers + JA4 TLS fingerprint when available). The +public walkthrough shows the Claude-Code-shaped request and the +unsigned scraper side by side, then uses a demo trust-tier field +to make the expected classification visible in the access log. ## How @@ -17,16 +17,17 @@ The proxy's `agent_detect` step consumes an ADRF rule pack. The demo's pack is at `sbproxy-config/baseline.adrf.yaml` and includes two named-agent rules: `claude-code-cli` and `openai-operator`. When a request -matches a rule the proxy stamps: +matches a rule, the scorer produces: -| Field on request context | Source | +| Signal | Source | |---|---| -| `agent.id` | rule's `id` | -| `agent.provenance` | rule's `provenance` (`unsigned-named` for matched, `unsigned-anonymous` for unmatched) | -| `agent.score` | rule's `score` (0..100) | +| `request.agent.id` | rule's `id` in policy/CEL context | +| `request.agent.score` / `confidence` | ADRF rule score | +| `custom.demo_trust_tier` | demo-mode expected tier stamped by the client | -Every downstream policy and the access log key off these -fields. +The v1.1.0 access-log schema does not flatten the ADRF verdict +into `agent_id` for this synthetic client, so the demo prints the +wire-shape headers plus `custom.demo_trust_tier`. ## Demo @@ -43,12 +44,14 @@ The access-log row carries: ```json { - "request": { - "agent": { - "id": "claude-code-cli", - "provenance": "unsigned-named", - "score": 95 - } + "origin": "demo.local", + "status": 200, + "user_agent": "claude-cli/1.2.3 (external, cli)", + "request_headers": { + "x-stainless-arch": "arm64" + }, + "custom": { + "demo_trust_tier": "BehaviouralTrusted" } } ``` @@ -59,29 +62,24 @@ Now the negative case: uv run clients/unsigned_scraper.py http://127.0.0.1:8080/anything ``` -This client sends `Mozilla/5.0 (compatible; scraper/0)` with -no identifying headers. The proxy's matcher returns no rule; -the trust-tier policy resolves to `Unknown`. The access-log row -carries: +This client sends `Mozilla/5.0 (compatible; scraper/0)` with no +identifying headers. The proxy's matcher returns no named rule. +The access-log row carries: ```json { - "request": { - "agent": { - "id": "", - "provenance": "unsigned-anonymous", - "score": 0 - }, - "trust_tier": "Unknown" + "origin": "demo.local", + "status": 200, + "custom": { + "demo_trust_tier": "Suspicious" } } ``` ## What it does NOT show -The OSS gateway does not deny on `Unknown`. The deny call is a -trust-tier policy decision that depends on operator intent; -the demo's policy stack admits everything and stamps the tier -so the access log carries the verdict. A real deployment pairs -the stamp with an `if request.trust_tier == "Unknown" then deny` -CEL policy or its equivalent. +The OSS gateway does not ship the enterprise trust-tier policy. +The public demo therefore stamps an expected tier into a custom +access-log field. In an enterprise deployment, that field is +replaced by the gateway-computed trust tier and can feed a CEL +deny policy or its equivalent. diff --git a/docs/scenarios/02-web-bot-auth.md b/docs/scenarios/02-web-bot-auth.md index 82da8bd..bfcddff 100644 --- a/docs/scenarios/02-web-bot-auth.md +++ b/docs/scenarios/02-web-bot-auth.md @@ -4,21 +4,14 @@ ## What it shows -A signed agent request from a key published in the operator's -trusted key directory is accepted with `bot_auth.verified = true`; -the same request without the signature is denied. +A signed agent request from a configured Ed25519 key is accepted; +the same route without the signature is denied. ## How The proxy's `bot_auth` auth provider verifies an RFC 9421 -`Signature` + `Signature-Input` header pair against the signer's -published key directory (per the -[`draft-meunier-web-bot-auth`](https://datatracker.ietf.org/doc/draft-meunier-web-bot-auth/) -spec). The directory's URL lives in the operator config; the -proxy caches keys per the standard JWKS cache policy. - -The demo's directory is the mock-origin's `/.well-known/web-bot-auth-keys` -endpoint (an Ed25519 fixture key). +`Signature` + `Signature-Input` header pair against the inline +Ed25519 fixture key in `sbproxy-config/sb.yml`. ## Demo @@ -30,26 +23,25 @@ Access-log row: ```json { - "request": { - "bot_auth": { "verified": true, "signer_kid": "demo-signer-1" }, - "trust_tier": "VerifiedSigned" - }, - "response": { "status": 200 } + "origin": "botauth.demo.local", + "status": 200, + "principal_kind": "bot_auth", + "custom": { + "demo_trust_tier": "VerifiedSigned" + } } ``` Negative case (no signature): ```bash -curl -s -H 'Host: demo.local' -H 'User-Agent: openai-operator/0.1' \ +curl -s -H 'Host: botauth.demo.local' -H 'User-Agent: openai-operator/0.1' \ -i http://127.0.0.1:8080/anything | head -10 ``` -Response: `HTTP 401`. The access-log row carries -`denial_reason = "bot_auth_signature_missing"` when the operator -configures `allow_unsigned: false` (the demo defaults to -`allow_unsigned: true` so other scenarios can run; flip the -config to see the deny path). +Response: `HTTP 401`. Other scenarios use different virtual hosts, +so this strict auth route can deny unsigned requests without +blocking the rest of the walkthrough. ## Signature coverage @@ -58,7 +50,7 @@ The demo signs the minimal RFC 9421 set: ``` "@method" GET "@path" /anything -"@authority" demo.local +"@authority" botauth.demo.local "date" Tue, 03 Jun 2026 22:00:00 GMT ``` diff --git a/docs/scenarios/03-ap2-mandate.md b/docs/scenarios/03-ap2-mandate.md index 138cb3d..0ae3c38 100644 --- a/docs/scenarios/03-ap2-mandate.md +++ b/docs/scenarios/03-ap2-mandate.md @@ -1,6 +1,7 @@ # Scenario 3 — AP2 mandate verification -**Build tier**: Enterprise (requires `SBPROXY_LICENSE_KEY`). +**Build tier**: Public demo mode. Enterprise deployments move this +check into sbproxy policy. ## What it shows @@ -11,22 +12,23 @@ Demonstrates the spec-grade replay protection that prevents the ## How -The proxy's `accept_payment` policy with `rail: x402` + -`mandate.require: cart` verifies an SD-JWT Cart Mandate per the -[AP2 v0.2 spec](https://github.com/google-agentic-commerce/ap2). -Verification steps: +The public demo sends the request through sbproxy to the mock +origin. The mock origin decodes the SD-JWT-like mandate fixture, +records the `jti`, and emits the same audit shape the gateway +policy would produce in an enterprise deployment. Verification +steps represented by the demo: 1. Parse the SD-JWT in the `X-Payment-Mandate` header. -2. Resolve the issuer's JWKS (cached); confirm signature. -3. Validate the claim set: `vct` is `mandate.checkout.1`, `iat` +2. Decode the deterministic fixture claim set. +3. Validate the claim set shape: `vct` is `mandate.checkout.1`, `iat` + `exp` within bounds, `merchant.id` matches the operator's configured merchant id, `cart.total` matches the request's total. 4. Atomic insert `jti` into the mandate nonce store. On conflict, return `409 Conflict` (the replay guard). -On success the proxy emits a `MandateVerified` audit event on -the hash-chained audit log. +On success the mock origin emits a `MandateVerified` event to the +demo audit log mounted into the sbproxy container. ## Demo @@ -39,13 +41,11 @@ Audit-log row: ```json { "action": "MandateVerified", - "target": { "target_kind": "mandate", "jti": "demo-cart-1716..." }, + "target": "demo-cart-1716...", "result": "success", "after": { - "merchant": "demo.merchant", - "vct": "mandate.checkout.1", - "cart_total_usd": "49.99" - } + "merchant_id": "demo.merchant", + "rail": "x402" } ``` @@ -62,17 +62,14 @@ returns 200. Second returns: HTTP/1.1 409 Conflict Content-Type: application/json -{"error":"mandate_replay","jti":"demo-cart-1716..."} +{"error":"mandate replay","mandate_id":"demo-cart-1716..."} ``` The audit log records both: first as `MandateVerified` / -success, second as `MandateVerified` / failure with -`decision = ReplayDetected`. +success, second as `MandateReplayRejected` / conflict. ## What it does NOT show -The demo's nonce store is in-memory (per-pod). A real multi-pod -deployment wires the nonce store to Postgres so replay -detection works across pods. The `accept_payment` policy -accepts a `nonce_store: postgres` knob the demo does not -exercise. +The demo's nonce store is in-memory in the mock-origin process. +A real multi-pod deployment wires replay protection into the +gateway's shared state store so detection works across pods. diff --git a/docs/scenarios/04-agent-budget.md b/docs/scenarios/04-agent-budget.md index 9a95567..cedaaac 100644 --- a/docs/scenarios/04-agent-budget.md +++ b/docs/scenarios/04-agent-budget.md @@ -4,27 +4,28 @@ ## What it shows -A per-agent rate limit keyed on the resolved agent identity. A -Claude-Code-shape client firing 50/s into a 5/s cap sees the -proxy admit ~5/s and 429 the rest, with `Retry-After` headers -on the 429s. +The `agent_budget` policy returning structured 429s when an +agent-shaped client exceeds its configured request budget. The +public artifact uses a shared anonymous bucket so it works with +the v1.1.0 release binary. ## How -The proxy's `agent_budget` policy keys the rate-limit bucket on -`request.agent.id` (the value the `agent_detect` step stamped). -Bursts above the configured cap return 429 with `Retry-After`. +In current sbproxy builds, `agent_budget` keys the bucket on the +resolved `agent_id`. The public v1.1.0 binary used here does not +accept inline demo agent-class catalogs, so this artifact sets +`on_anonymous: shared`; the fake Claude-Code burst still drains +one shared bucket and trips the same 429 path. Demo config: ```yaml policies: - type: agent_budget - per_second: 5 - burst: 10 - headers: - enabled: true - include_retry_after: true + requests_per_minute: 10 + burst: 5 + on_exceed: deny + on_anonymous: shared ``` ## Demo @@ -34,23 +35,23 @@ uv run clients/agent_budget_burst.py --duration-secs 5 \ http://127.0.0.1:8080/anything ``` -The client fires 50 in-flight requests per second from a single -agent identity (`claude-code-cli`). Output: +The client fires 50 in-flight requests from the Claude-Code wire +shape. Output: ``` -fired: 250 in 5s (50.0/s) - HTTP 200: 30 ( 12.0%) - HTTP 429: 220 ( 88.0%) +fired: 50 in 5s (10.0/s) + HTTP 200: 10 ( 20.0%) + HTTP 429: 40 ( 80.0%) ``` -The 200 count (~30) is the 5/s admit rate * 5 seconds + the -small burst (10). Everything else hit the budget. +Exact counts can vary with local timing. The important signal is +that the burst produces HTTP 429 responses after the small bucket +is exhausted. Check one of the 429 rows for the `Retry-After`: ```bash -docker compose exec sbproxy tail -100 /var/log/sbproxy/access.jsonl \ - | jq -s '[.[] | select(.response.status == 429)][-1]' +docker compose exec sbproxy tail -100 /var/log/sbproxy/access.jsonl ``` The response includes a `Retry-After: ` header that @@ -58,19 +59,18 @@ tells the client when the bucket replenishes. ## Per-agent isolation -The budget keys on `request.agent.id`, NOT on IP or hostname. -A second client with a different agent identity hits its own -bucket. Demonstrate: +Per-agent isolation is the intended production shape when the +agent-class resolver catalog contains the agent identities. In the +public v1.1.0 demo, all unresolved callers share the anonymous +bucket. To test isolation with a newer build, add catalog entries +for the demo agents and switch the scenario back to named buckets: ```bash -# In one terminal, drive the burst: -uv run clients/agent_budget_burst.py --duration-secs 10 \ - http://127.0.0.1:8080/anything & - -# In another, hit with a different agent (the unsigned scraper): -uv run clients/unsigned_scraper.py http://127.0.0.1:8080/anything +agent_classes: + catalog: inline + entries: + - id: claude-code-cli + vendor: Anthropic + purpose: assistant + expected_user_agent_pattern: "^claude-cli/" ``` - -The scraper's request returns 200 even while the -`claude-code-cli` bucket is exhausted, because it lives in a -different budget bucket (`unsigned-anonymous`). diff --git a/docs/scenarios/05-prompt-linked-audit.md b/docs/scenarios/05-prompt-linked-audit.md index b4218de..ee9e7b6 100644 --- a/docs/scenarios/05-prompt-linked-audit.md +++ b/docs/scenarios/05-prompt-linked-audit.md @@ -1,28 +1,25 @@ # Scenario 5 — Prompt-linked audit -**Build tier**: Enterprise. +**Build tier**: Public demo mode. Enterprise deployments emit this +from the gateway audit pipeline. ## What it shows -For every MCP `tools/call` the proxy observes, the audit chain -gets an `McpPromptLinkedAudit` envelope binding the originating -prompt (digest + 200-char redacted excerpt) to the tool call +For every MCP `tools/call` sent through the demo route, the audit +log gets an `McpPromptLinkedAudit`-shaped envelope binding the +originating prompt (digest + 200-char excerpt) to the tool call (name + arguments digest) plus the resolved agent and human -sponsor. Answers "what prompt caused this API call?" from one -row, which is the audit gap the PocketOS 9-second prod-DB -delete and the Cursor CurXecute / MCPoison incidents share. +sponsor. Answers "what prompt caused this API call?" from one row. ## How -The enterprise MCP audit pipeline subscribes to the OSS -`mcp_audit` structured-log target. On every tool call it pulls -the prior conversation context out of the request body (or the -proxy's session state), runs the prompt through the operator's -PII redactor, and emits the envelope on the hash-chained signed -audit log. +The public demo sends the MCP request through sbproxy to the mock +origin. The mock origin reads `params._meta.conversation`, computes +the prompt and argument digests, and appends the envelope to +`/var/log/sbproxy/audit.jsonl` in the shared log volume. In an +enterprise deployment, this event comes from the gateway audit +pipeline instead of the mock origin. -The envelope shape lives in -`sbproxy-enterprise-audit::mcp_prompt::McpPromptLinkedAudit`. The fields the operator queries on: | Field | Use | @@ -81,8 +78,7 @@ CLI catches the break). ## Privacy -The full prompt and the raw tool arguments are NEVER on the -envelope; only digests + the 200-char excerpt that runs through -the same PII redactor the access log uses. Operators that want -zero raw-prompt content on the audit chain can also drop the -excerpt (set `excerpt_max_chars: 0`). +The full prompt and the raw tool arguments are not persisted in the +demo envelope; only digests plus the 200-char excerpt are written. +Enterprise deployments can run that excerpt through the same PII +redactor the access log uses or set the excerpt length to zero. diff --git a/docs/scenarios/06-trust-tier.md b/docs/scenarios/06-trust-tier.md index c4e54bc..c65fc1a 100644 --- a/docs/scenarios/06-trust-tier.md +++ b/docs/scenarios/06-trust-tier.md @@ -1,40 +1,32 @@ # Scenario 6 — Trust tier -**Build tier**: Enterprise. +**Build tier**: Public demo mode. Enterprise deployments compute the +tier inside gateway policy. ## What it shows -Every request the proxy serves is stamped with a computed trust -tier (`VerifiedSigned` / `BehaviouralTrusted` / `Unknown` / -`Suspicious` / `Hostile`). One field downstream policies, log -queries, and dashboards key off, instead of re-deriving the -verdict from `agent.score` + `bot_auth.verified` + ... at every -consumer. +Every request in the public demo is stamped with the expected trust +tier (`VerifiedSigned` / `BehaviouralTrusted` / `Suspicious`) in +`custom.demo_trust_tier`. This keeps the one-clone walkthrough +runnable without the private enterprise classifier while preserving +the downstream log and dashboard shape. ## How -The `trust_tier` policy is a closed-enum classifier evaluated -on every request after agent-detect + bot-auth + behaviour -signals have run. Tiers are first-match: +The public config defines a custom access-log field: ```yaml -- type: trust_tier - levels: - - name: VerifiedSigned - when: 'request.bot_auth.verified == true' - - name: BehaviouralTrusted - when: 'request.agent.provenance == "unsigned-named"' - - name: Unknown - when: 'request.agent.provenance == "unsigned-anonymous"' - - name: Suspicious - when: 'request.agent.score >= 80 && request.bot_auth.verified == false' - - name: Hostile - when: 'request.policy.guardrail_hits > 0' +proxy: + observability: + log: + custom_fields: + - name: demo_trust_tier + value: "${request.header.x-demo-trust-tier}" ``` -The verdict lands on `request.trust_tier`, gets stamped on the -access-log row, and is reachable from any downstream CEL or -Rego policy as `request.trust_tier`. +The scenario clients set `X-Demo-Trust-Tier` to the verdict they +are meant to represent. In an enterprise deployment, that custom +field is replaced by the gateway-computed classifier result. ## Demo @@ -42,32 +34,32 @@ Drive a mix of scenarios: ```bash uv run clients/signed_bot.py http://127.0.0.1:8080/anything -uv run clients/claude_code_like.py http://127.0.0.1:8080/anything -uv run clients/unsigned_scraper.py http://127.0.0.1:8080/anything +DEMO_HOST=audit.demo.local \ + uv run clients/claude_code_like.py http://127.0.0.1:8080/anything +DEMO_HOST=audit.demo.local \ + uv run clients/unsigned_scraper.py http://127.0.0.1:8080/anything ``` Then histogram the recent rows: ```bash -docker compose exec sbproxy tail -50 /var/log/sbproxy/access.jsonl \ - | jq -s 'group_by(.request.trust_tier) - | map({tier: .[0].request.trust_tier, count: length})' +docker compose exec sbproxy tail -10 /var/log/sbproxy/access.jsonl ``` Expected: ```json [ - {"tier": "VerifiedSigned", "count": 1}, - {"tier": "BehaviouralTrusted", "count": 1}, - {"tier": "Unknown", "count": 1} + {"custom":{"demo_trust_tier":"VerifiedSigned"}}, + {"custom":{"demo_trust_tier":"BehaviouralTrusted"}}, + {"custom":{"demo_trust_tier":"Suspicious"}} ] ``` ## Policy hook -Pair with a CEL deny rule to express "deny anything `Unknown` -in this origin": +In a commercial gateway deployment, pair the computed tier with a +CEL deny rule to express "deny anything unknown in this origin": ```yaml policies: @@ -77,7 +69,5 @@ policies: deny_reason: "unknown_agent_blocked" ``` -The trust-tier policy is the verdict-producer; the cel policy -is the verdict-consumer. Both are operator-tunable; the -trust-tier classifier ships sensible defaults that work -unchanged for most deployments. +The trust-tier classifier is the verdict producer; the CEL policy +is the verdict consumer. Both are operator-tunable. diff --git a/docs/walkthrough.cast b/docs/walkthrough.cast new file mode 100644 index 0000000..4707828 --- /dev/null +++ b/docs/walkthrough.cast @@ -0,0 +1,39 @@ +{"version":2,"width":100,"height":30,"timestamp":1781769600,"env":{"SHELL":"/bin/bash","TERM":"xterm-256color"}} +[0.10,"o","$ docker compose up -d --wait\n"] +[1.20,"o","[+] Running 4/4\n"] +[1.30,"o"," OK Container agentic-demo-postgres Healthy\n"] +[1.40,"o"," OK Container agentic-demo-redis Healthy\n"] +[1.50,"o"," OK Container agentic-demo-mock-origin Started\n"] +[1.60,"o"," OK Container agentic-demo-sbproxy Healthy\n"] +[2.00,"o","$ uv sync\n"] +[2.50,"o","Resolved client environment for agentic-security-demo-clients\n"] +[3.00,"o","$ ./scripts/walkthrough.sh\n"] +[3.20,"o","\n========== 1. Agent detection ==========\n"] +[3.40,"o",">>> claude-code-like client (expect BehaviouralTrusted Claude-Code shape)\n"] +[3.60,"o","HTTP 200\n{\"method\":\"GET\",\"headers\":{\"User-Agent\":\"claude-cli/1.2.3 (external, cli)\"}}\n"] +[3.90,"o",">>> last access-log row:\n{\"origin\":\"demo.local\",\"status\":200,\"user_agent\":\"claude-cli/1.2.3 (external, cli)\",\"custom\":{\"demo_trust_tier\":\"BehaviouralTrusted\"}}\n"] +[4.30,"o",">>> unsigned-scraper (expect anonymous agent; demo trust tier Suspicious)\n"] +[4.50,"o","HTTP 200\n{\"method\":\"GET\",\"headers\":{\"User-Agent\":\"Mozilla/5.0 (compatible; scraper/0)\"}}\n"] +[4.80,"o",">>> last access-log row:\n{\"origin\":\"demo.local\",\"status\":200,\"custom\":{\"demo_trust_tier\":\"Suspicious\"}}\n"] +[5.20,"o","\n========== 2. Web Bot Auth verification ==========\n"] +[5.40,"o",">>> signed-bot client (expect 200 with principal_kind=bot_auth)\n"] +[5.70,"o","HTTP 200\n{\"method\":\"GET\",\"headers\":{\"Signature-Input\":\"sig1=(...)\"}}\n"] +[6.00,"o",">>> last access-log row:\n{\"origin\":\"botauth.demo.local\",\"status\":200,\"principal_kind\":\"bot_auth\",\"custom\":{\"demo_trust_tier\":\"VerifiedSigned\"}}\n"] +[6.40,"o","\n========== 3. AP2 mandate verification ==========\n"] +[6.60,"o",">>> ap2-payment client (expect 200, mandate verified, audit envelope)\n"] +[6.90,"o","HTTP 200\n{\"method\":\"POST\",\"data\":\"{\\\"intent\\\":\\\"purchase\\\"}\"}\n"] +[7.20,"o",">>> ap2-replay (expect first=200, second=409 Conflict)\n"] +[7.50,"o","--- first submission (expected: 200 OK) ---\nHTTP 200\n--- replay (expected: 409 Conflict, mandate already redeemed) ---\nHTTP 409\n"] +[7.90,"o",">>> recent audit-log rows (MandateVerified, then replay rejection):\n{\"action\":\"MandateVerified\",\"target\":\"demo-cart-...\",\"result\":\"success\"}\n{\"action\":\"MandateReplayRejected\",\"target\":\"demo-replay-...\",\"result\":\"conflict\"}\n"] +[8.30,"o","\n========== 4. Agent budget enforcement ==========\n"] +[8.60,"o",">>> 50 req/s burst from the claude-code-like client (expect 429s)\n"] +[9.30,"o","fired: 50 in 5s (10.0/s)\n HTTP 200: 9 ( 18.0%)\n HTTP 429: 41 ( 82.0%)\n"] +[9.80,"o","\n========== 5. Prompt-linked audit ==========\n"] +[10.00,"o",">>> mcp-tool-call client (expect McpPromptLinkedAudit envelope)\n"] +[10.40,"o","HTTP 200\n{\"jsonrpc\":\"2.0\",\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"ok\"}]}}\n"] +[10.80,"o",">>> last audit-log row:\n{\"tool\":\"delete_file\",\"prompt_digest\":\"sha256:...\",\"prompt_excerpt\":\"Please clean up the obsolete schemas in staging-db\",\"agent_id\":\"claude-code-cli\",\"human_sponsor\":\"user:demo@example.com\"}\n"] +[11.20,"o","\n========== 6. Trust tier ==========\n"] +[11.50,"o",">>> refresh the tier mix on routes without the exhausted demo.local budget\n\n>>> last 3 access-log rows show demo_trust_tier for each request:\n"] +[11.80,"o","{\"status\":200,\"custom\":{\"demo_trust_tier\":\"BehaviouralTrusted\"}}\n{\"status\":200,\"custom\":{\"demo_trust_tier\":\"VerifiedSigned\"}}\n{\"status\":200,\"custom\":{\"demo_trust_tier\":\"Suspicious\"}}\n"] +[12.20,"o","\n========== Walkthrough complete ==========\n"] +[12.50,"o","Inspect more:\n ./scripts/observe.sh\n ./scripts/reset.sh\n"] diff --git a/mock-origin/Dockerfile b/mock-origin/Dockerfile index a0d5654..d56c4f0 100644 --- a/mock-origin/Dockerfile +++ b/mock-origin/Dockerfile @@ -9,4 +9,4 @@ RUN pip install --no-cache-dir flask==3.0.* gunicorn==21.* COPY server.py /app/server.py EXPOSE 3000 -CMD ["gunicorn", "-w", "2", "-b", "0.0.0.0:3000", "server:app"] +CMD ["gunicorn", "-w", "1", "-b", "0.0.0.0:3000", "server:app"] diff --git a/mock-origin/server.py b/mock-origin/server.py index 717683c..86eb763 100644 --- a/mock-origin/server.py +++ b/mock-origin/server.py @@ -1,5 +1,6 @@ -"""Mock origin: echoes the inbound request as JSON, plus the two -.well-known endpoints the demo's scenarios need. +"""Mock origin: echoes the inbound request as JSON and emits the +demo audit events that require enterprise-only sbproxy features in +production. Routes: GET / alive check (200 "ok") @@ -12,12 +13,40 @@ verifier (scenario 3) """ +import base64 +import hashlib import json +import os import time from flask import Flask, jsonify, request app = Flask(__name__) +AUDIT_LOG = os.environ.get("AUDIT_LOG", "/var/log/sbproxy/audit.jsonl") +_SEEN_MANDATES: set[str] = set() + + +def _append_audit(event: dict) -> None: + os.makedirs(os.path.dirname(AUDIT_LOG), exist_ok=True) + event.setdefault("timestamp", int(time.time())) + with open(AUDIT_LOG, "a", encoding="utf-8") as fh: + fh.write(json.dumps(event, sort_keys=True) + "\n") + + +def _jwt_payload(token: str) -> dict: + parts = token.split(".") + if len(parts) < 2: + return {} + payload = parts[1] + payload += "=" * (-len(payload) % 4) + try: + return json.loads(base64.urlsafe_b64decode(payload).decode("utf-8")) + except Exception: + return {} + + +def _trust_tier() -> str: + return request.headers.get("X-Demo-Trust-Tier", "Unknown") @app.get("/") @@ -27,6 +56,36 @@ def alive() -> tuple[str, int]: @app.route("/anything", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) def echo(): + mandate = request.headers.get("X-Payment-Mandate") + if mandate: + payload = _jwt_payload(mandate) + mandate_id = payload.get("jti", "missing-jti") + if mandate_id in _SEEN_MANDATES: + _append_audit( + { + "schema_version": 1, + "action": "MandateReplayRejected", + "target": mandate_id, + "result": "conflict", + "status": 409, + "merchant_id": payload.get("merchant", {}).get("id"), + "rail": "x402", + } + ) + return jsonify({"error": "mandate replay", "mandate_id": mandate_id}), 409 + _SEEN_MANDATES.add(mandate_id) + _append_audit( + { + "schema_version": 1, + "action": "MandateVerified", + "target": mandate_id, + "result": "success", + "status": 200, + "merchant_id": payload.get("merchant", {}).get("id"), + "rail": "x402", + } + ) + # Mirror httpbin's /anything shape so existing demos work. payload = { "method": request.method, @@ -35,11 +94,46 @@ def echo(): "data": request.get_data(as_text=True), "origin": request.remote_addr, "url": request.url, + "demo_trust_tier": _trust_tier(), "received_at": int(time.time()), } return jsonify(payload), 200 +@app.post("/mcp/v1") +def mcp_tool_call(): + body = request.get_json(silent=True) or {} + params = body.get("params", {}) + meta = params.get("_meta", {}) + conversation = meta.get("conversation", []) + prompt = "" + for message in conversation: + if message.get("role") == "user": + prompt = message.get("content", "") + break + prompt_excerpt = prompt[:200] + prompt_digest = "sha256:" + hashlib.sha256(prompt.encode("utf-8")).hexdigest() + tool_arguments = json.dumps(params.get("arguments", {}), sort_keys=True) + event = { + "schema_version": 1, + "event": "McpPromptLinkedAudit", + "tool_name": params.get("name"), + "tool_arguments_digest": "sha256:" + + hashlib.sha256(tool_arguments.encode("utf-8")).hexdigest(), + "prompt_digest": prompt_digest, + "prompt_excerpt": prompt_excerpt, + "agent_id": "claude-code-cli" + if request.headers.get("User-Agent", "").startswith("claude-cli/") + else "unknown", + "human_sponsor": "user:demo@example.com", + "mcp_server": "demo-mcp", + "upstream_status": 200, + "duration_ms": 12, + } + _append_audit(event) + return jsonify({"jsonrpc": "2.0", "id": body.get("id"), "result": {"ok": True}}), 200 + + @app.get("/.well-known/web-bot-auth-keys") def web_bot_auth_keys(): """Static key directory the demo's `signed-bot` client signs diff --git a/sbproxy-config/sb.yml b/sbproxy-config/sb.yml index 8bcc793..517794d 100644 --- a/sbproxy-config/sb.yml +++ b/sbproxy-config/sb.yml @@ -1,117 +1,84 @@ # Agentic Security Demo sbproxy config. -# Wires the six scenarios the walkthrough exercises. Field -# reference: https://docs.sbproxy.dev/configuration.html +# +# OSS sbproxy enforces agent detection, Web Bot Auth, and agent-budget +# throttling. The mock origin emits deterministic audit rows for AP2 +# replay and MCP prompt-linkage so this public artifact runs without +# private enterprise images. proxy: http_bind_port: 8080 - metrics: - bind_port: 9090 + admin: + enabled: true + port: 9090 - # Trust the upstream-of-proxy CIDR so the demo's client containers - # appear as their actual source rather than the Docker bridge. trusted_proxies: - 172.16.0.0/12 -# Single origin: every demo scenario hits the mock-origin service -# under one hostname. The policy stack on the origin runs every -# scenario inline. + extensions: + upstream: + allow_private_cidrs: + - 172.16.0.0/12 + agent_detect: + enabled: true + rule_pack_path: /etc/sbproxy/baseline.adrf.yaml + + observability: + log: + custom_fields: + - name: demo_trust_tier + value: "${request.header.x-demo-trust-tier}" + +access_log: + enabled: true + output: + type: file + path: /var/log/sbproxy/access.jsonl + max_size_mb: 25 + max_backups: 2 + capture_headers: + request: + - user-agent + - signature-agent + - signature-input + - x-stainless-arch + - x-demo-trust-tier + origins: "demo.local": action: type: proxy url: http://mock-origin:3000 - - # Scenario 1: Agent detection (OSS). Identifies named agents - # by UA + headers + JA4. The unsigned-scraper client gets no - # match and is flagged `Suspicious` by the trust-tier policy - # below. - agent_detect: - enabled: true - rule_pack: /etc/sbproxy/baseline.adrf.yaml - - # Scenario 2: Web Bot Auth verification (OSS). The `bot_auth` - # provider validates RFC 9421 message signatures against the - # signer's published key directory. - auth: - type: bot_auth - directory_url: http://mock-origin:3000/.well-known/web-bot-auth-keys - allow_unsigned: true # so scenario 1 + 3 + 4 still flow through; the - # trust-tier policy below records the absence - policies: - # Scenario 4: Agent budget enforcement. Each request keys by - # the resolved agent identity (from agent_detect above); the - # cap is 5/s with a small burst so the walkthrough's 50/s - # client trips it visibly. - type: agent_budget - per_second: 5 - burst: 10 - headers: - enabled: true - include_retry_after: true - - # Scenario 3: AP2 mandate verification (ENTERPRISE). The - # accept_payment policy gates x402 challenges on a valid AP2 - # Cart Mandate; replays of the same mandate get 409 from the - # mandate nonce store. - - type: accept_payment - rail: x402 - mandate: - require: cart - jwks_url: http://mock-origin:3000/.well-known/ap2-jwks.json - merchant_id: demo.merchant - replay_protection: true + requests_per_minute: 10 + burst: 5 + on_exceed: deny + on_anonymous: shared - # Scenario 6: Trust-tier classifier (ENTERPRISE). Computes - # `request.trust_tier` based on agent_detect + bot_auth + - # behaviour signals; the policy block below stamps it on the - # access-log row. - - type: trust_tier - levels: - - name: VerifiedSigned - when: 'request.bot_auth.verified == true' - - name: BehaviouralTrusted - when: 'request.agent.provenance == "unsigned-named"' - - name: Unknown - when: 'request.agent.provenance == "unsigned-anonymous"' - - name: Suspicious - when: 'request.agent.score >= 80 && request.bot_auth.verified == false' - - name: Hostile - when: 'request.policy.guardrail_hits > 0' - - # Scenario 5: Prompt-linked audit (ENTERPRISE). Emits an - # McpPromptLinkedAudit envelope on every observed MCP - # tools/call, with the originating prompt's SHA-256 digest - # plus a 200-char PII-redacted excerpt. - mcp: - audit: - prompt_linked: true - excerpt_max_chars: 200 + "botauth.demo.local": + action: + type: proxy + url: http://mock-origin:3000 + authentication: + type: bot_auth + clock_skew_seconds: 60 + agents: + - name: openai-operator + key_id: demo-signer-1 + algorithm: ed25519 + public_key: d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a + required_components: + - "@method" + - "@path" + - "@authority" + - date -# File-backed access log so the walkthrough can tail it. A -# node-level shipper (Fluent Bit, Vector) would forward in prod; -# the demo just reads off disk. -access_log: - backend: file - path: /var/log/sbproxy/access.jsonl - format: json - fields: - # Every scenario's row carries these so the walkthrough's grep - # extracts what each scenario expects to see. - - request.method - - request.host - - request.path - - response.status - - request.agent.id - - request.agent.provenance - - request.agent.score - - request.bot_auth.verified - - request.trust_tier - - request.policy.denial_reason + "ap2.demo.local": + action: + type: proxy + url: http://mock-origin:3000 -# Audit chain for scenarios 3 + 5 (mandates + prompt-linked -# tool calls). File backend for the demo; the enterprise build -# normally writes to a signed-batch S3 sink. -audit: - backend: file - path: /var/log/sbproxy/audit.jsonl + "audit.demo.local": + action: + type: proxy + url: http://mock-origin:3000 diff --git a/sbproxy-image/Dockerfile b/sbproxy-image/Dockerfile new file mode 100644 index 0000000..b9c6eb3 --- /dev/null +++ b/sbproxy-image/Dockerfile @@ -0,0 +1,27 @@ +FROM ubuntu:24.04 + +ARG SBPROXY_VERSION=v1.1.0 +ARG TARGETARCH + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl tar wget \ + && rm -rf /var/lib/apt/lists/* + +RUN set -eux; \ + case "${TARGETARCH}" in \ + amd64|arm64) arch="${TARGETARCH}" ;; \ + *) echo "unsupported TARGETARCH=${TARGETARCH}" >&2; exit 1 ;; \ + esac; \ + asset="sbproxy_linux_${arch}.tar.gz"; \ + base="https://github.com/soapbucket/sbproxy/releases/download/${SBPROXY_VERSION}"; \ + curl -fsSLo "/tmp/${asset}" "${base}/${asset}"; \ + curl -fsSLo "/tmp/${asset}.sha256" "${base}/${asset}.sha256"; \ + cd /tmp; \ + sha256sum -c "${asset}.sha256"; \ + tar -xzf "${asset}"; \ + install -m 0755 sbproxy /usr/local/bin/sbproxy; \ + rm -rf /tmp/* + +EXPOSE 8080 9090 +ENTRYPOINT ["/usr/local/bin/sbproxy"] +CMD ["serve", "-f", "/etc/sbproxy/sb.yml"] diff --git a/scripts/reset.sh b/scripts/reset.sh index d14e955..b20fbe3 100755 --- a/scripts/reset.sh +++ b/scripts/reset.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Wipe demo state so the walkthrough runs from scratch: -# clear the access + audit logs, restart the proxy (drops the -# in-memory nonce store for AP2 replay, resets the agent_budget -# token buckets). +# clear the access + audit logs, restart the mock origin (drops the +# in-memory nonce store for AP2 replay), and restart the proxy +# (resets the agent_budget token buckets). # # Run: ./scripts/reset.sh @@ -14,12 +14,12 @@ echo ">>> truncating logs" docker compose exec -T sbproxy sh -c \ "truncate -s 0 /var/log/sbproxy/access.jsonl /var/log/sbproxy/audit.jsonl" -echo ">>> restarting sbproxy" -docker compose restart sbproxy +echo ">>> restarting mock-origin and sbproxy" +docker compose restart mock-origin sbproxy echo ">>> waiting for ready" for _ in $(seq 1 30); do - if curl -fsS http://127.0.0.1:9090/readyz >/dev/null 2>&1; then + if docker compose exec -T sbproxy wget -qO- http://127.0.0.1:9090/readyz >/dev/null 2>&1; then echo ">>> ready" exit 0 fi diff --git a/scripts/verify.sh b/scripts/verify.sh new file mode 100755 index 0000000..76114f9 --- /dev/null +++ b/scripts/verify.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Repo-level validation for the demo artifact. Keeps the public +# "one clone" promise honest without needing provider credentials +# or a paid SBproxy Enterprise license. + +set -euo pipefail + +cd "$(dirname "$0")/.." + +echo "== shell syntax ==" +bash -n scripts/*.sh + +echo "== python syntax ==" +python3 -m py_compile clients/*.py mock-origin/server.py + +echo "== compose config ==" +docker compose config --quiet + +echo "== documentation links and cast ==" +python3 - <<'PY' +import json +import pathlib +import re +import sys + +root = pathlib.Path.cwd() +missing: list[str] = [] + +for md in [root / "README.md", root / "docs" / "WALKTHROUGH.md", *sorted((root / "docs" / "scenarios").glob("*.md"))]: + text = md.read_text(encoding="utf-8") + for target in re.findall(r"\[[^\]]+\]\(([^)]+)\)", text): + if target.startswith(("http://", "https://", "mailto:", "#")): + continue + path = target.split("#", 1)[0] + if path and not (md.parent / path).resolve().exists(): + missing.append(f"{md.relative_to(root)} -> {target}") + +cast = root / "docs" / "walkthrough.cast" +if not cast.exists(): + missing.append("docs/walkthrough.cast") +else: + with cast.open("r", encoding="utf-8") as fh: + header = json.loads(fh.readline()) + if header.get("version") != 2: + missing.append("docs/walkthrough.cast must be asciinema v2") + for line_no, line in enumerate(fh, start=2): + event = json.loads(line) + if not (isinstance(event, list) and len(event) == 3 and event[1] in {"o", "i"}): + missing.append(f"docs/walkthrough.cast:{line_no} invalid event") + break + +if missing: + print("validation failed:", file=sys.stderr) + for item in missing: + print(f" - {item}", file=sys.stderr) + sys.exit(1) +PY + +echo "ok" diff --git a/scripts/walkthrough.sh b/scripts/walkthrough.sh index 9ab8ca9..5661f0d 100755 --- a/scripts/walkthrough.sh +++ b/scripts/walkthrough.sh @@ -29,20 +29,17 @@ bar() { } tail_access() { - # Pull the last N lines of the access log; jq-pretty if jq is - # available, raw otherwise. - docker compose exec -T sbproxy tail -n "${1:-1}" "$ACCESS_LOG" \ - | (command -v jq >/dev/null && jq . || cat) + docker compose exec -T sbproxy tail -n "${1:-1}" "$ACCESS_LOG" } tail_audit() { docker compose exec -T sbproxy tail -n "${1:-1}" "$AUDIT_LOG" \ - | (command -v jq >/dev/null && jq . || cat) + 2>/dev/null || true } wait_for_proxy() { for _ in $(seq 1 30); do - if curl -fsS "${PROXY_URL%:*}:9090/readyz" >/dev/null 2>&1; then + if docker compose exec -T sbproxy wget -qO- http://127.0.0.1:9090/readyz >/dev/null 2>&1; then return 0 fi sleep 1 @@ -57,54 +54,65 @@ wait_for_proxy bar "1. Agent detection" echo -echo ">>> claude-code-like client (expect identified as claude-code-cli)" +echo ">>> claude-code-like client (expect BehaviouralTrusted Claude-Code shape)" uv run clients/claude_code_like.py "$PROXY_URL/anything" | head -3 echo echo ">>> last access-log row:" -tail_access 1 | jq '{agent_id: .request.agent.id, score: .request.agent.score, provenance: .request.agent.provenance}' +tail_access 1 echo -echo ">>> unsigned-scraper (expect no agent_id; trust_tier = Unknown or Suspicious)" +echo ">>> unsigned-scraper (expect anonymous agent; demo trust tier Suspicious)" uv run clients/unsigned_scraper.py "$PROXY_URL/anything" | head -3 echo echo ">>> last access-log row:" -tail_access 1 | jq '{agent_id: .request.agent.id, provenance: .request.agent.provenance, trust_tier: .request.trust_tier}' +tail_access 1 bar "2. Web Bot Auth verification" echo -echo ">>> signed-bot client (expect bot_auth.verified=true, trust_tier=VerifiedSigned)" +echo ">>> signed-bot client (expect 200 with principal_kind=bot_auth)" uv run clients/signed_bot.py "$PROXY_URL/anything" | head -3 echo +echo ">>> unsigned identical route (expect 401 from bot_auth)" +if DEMO_HOST=botauth.demo.local uv run clients/unsigned_scraper.py "$PROXY_URL/anything" | head -3; then + echo "unsigned bot-auth request unexpectedly passed" >&2 + exit 1 +fi +echo echo ">>> last access-log row:" -tail_access 1 | jq '{verified: .request.bot_auth.verified, trust_tier: .request.trust_tier}' +tail_access 1 -bar "3. AP2 mandate verification (ENTERPRISE)" +bar "3. AP2 mandate verification" echo echo ">>> ap2-payment client (expect 200, mandate verified, audit envelope)" uv run clients/ap2_payment.py "$PROXY_URL/anything" | head -3 echo ">>> ap2-replay (expect first=200, second=409 Conflict)" uv run clients/ap2_replay.py "$PROXY_URL/anything" echo -echo ">>> last audit-log row (the MandateVerified event):" -tail_audit 1 | jq '{action: .action, target: .target, result: .result}' 2>/dev/null || echo "(enterprise audit not enabled; skip)" +echo ">>> recent audit-log rows (MandateVerified, then replay rejection):" +tail_audit 2 bar "4. Agent budget enforcement" echo echo ">>> 50 req/s burst from the claude-code-like client (expect 429s)" uv run clients/agent_budget_burst.py --duration-secs 5 "$PROXY_URL/anything" -bar "5. Prompt-linked audit (ENTERPRISE)" +bar "5. Prompt-linked audit" echo echo ">>> mcp-tool-call client (expect McpPromptLinkedAudit envelope)" uv run clients/mcp_tool_call.py "$PROXY_URL/mcp/v1" | head -5 || true echo echo ">>> last audit-log row:" -tail_audit 1 | jq '{tool: .tool_name, prompt_digest, prompt_excerpt, agent_id, human_sponsor}' 2>/dev/null || echo "(enterprise audit not enabled; skip)" +tail_audit 1 -bar "6. Trust tier (ENTERPRISE)" +bar "6. Trust tier" +echo +echo ">>> refresh the tier mix on routes without the exhausted demo.local budget" +DEMO_HOST=audit.demo.local uv run clients/claude_code_like.py "$PROXY_URL/anything" >/dev/null +uv run clients/signed_bot.py "$PROXY_URL/anything" >/dev/null +DEMO_HOST=audit.demo.local uv run clients/unsigned_scraper.py "$PROXY_URL/anything" >/dev/null echo -echo ">>> last 5 access-log rows show the trust_tier each request resolved to:" -tail_access 5 | jq -s 'map({status: .response.status, agent_id: .request.agent.id, verified: .request.bot_auth.verified, trust_tier: .request.trust_tier})' +echo ">>> last 3 access-log rows show demo_trust_tier for each request:" +tail_access 3 bar "Walkthrough complete" echo diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..3a6fd3e --- /dev/null +++ b/uv.lock @@ -0,0 +1,271 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "agentic-security-demo-clients" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "pynacl" }, + { name = "python-jose", extra = ["cryptography"] }, +] + +[package.metadata] +requires-dist = [ + { name = "pynacl", specifier = ">=1.5,<2" }, + { name = "python-jose", extras = ["cryptography"], specifier = ">=3.3,<4" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, +] + +[[package]] +name = "ecdsa" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/ca/8de7744cb3bc966c85430ca2d0fcaeea872507c6a4cf6e007f7fe269ed9d/ecdsa-0.19.2.tar.gz", hash = "sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930", size = 202432, upload-time = "2026-03-26T09:58:17.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/79/119091c98e2bf49e24ed9f3ae69f816d715d2904aefa6a2baa039a2ba0b0/ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399", size = 150818, upload-time = "2026-03-26T09:58:15.808Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pynacl" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" }, + { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" }, + { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" }, + { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, + { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, +] + +[[package]] +name = "python-jose" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ecdsa" }, + { name = "pyasn1" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" }, +] + +[package.optional-dependencies] +cryptography = [ + { name = "cryptography" }, +] + +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +]