diff --git a/services/api/NOTES.md b/services/api/NOTES.md index 98e1b9f..c6b58d6 100644 --- a/services/api/NOTES.md +++ b/services/api/NOTES.md @@ -1,4 +1,4 @@ -# AxiomCode production build — W1 + W2 +# AxiomCode production build — W1 + W2, then W3 + W4 (code) + W5 Local working copy built from `api-prototype/` (state/secret files — keys.txt, meter.jsonl, revoked.txt — deliberately excluded). Nothing @@ -55,7 +55,6 @@ pushed anywhere; this tree is for review/checkpoint. system temp) and removed in a `finally` block in both servers. ## Tests — 14 passed, 0 failed (`~/ax-venv/bin/python -m pytest tests/ -q`) - - test_ca.py (6): key persistence + 0600 perms; issue→verify round-trip (v2 fields, 90-day window); tampered verdict and mangled signature fail; wrong key fails (bad_signature); published payload @@ -76,19 +75,179 @@ pushed anywhere; this tree is for review/checkpoint. CA/store wiring, not Lean. Real-Lean behaviour was proven by the prototype demo (2026-09-26) and the engine suite. -## Left stubbed / out of scope (later workstreams) +## Left stubbed / out of scope after W1/W2 (status updated by W3–W5 below) - Revocation is operator-side only (store.revoke) — no public revoke endpoint, same as the prototype; an authenticated admin path is still to be designed. -- API-key enforcement on REST (prototype had none; MCP bearer auth - unchanged, including its known mutable-identity issue — W3 scope). -- Trial limits (10 cases), certificate withholding until payment, - Stripe entitlement/webhooks, quotas, rate limits, idempotency — - not in W1/W2 scope. +- ~~API-key enforcement on REST~~ — done in W3 (below). +- ~~Trial limits (10 cases), certificate withholding until payment, + Stripe entitlement/webhooks, quotas, rate limits~~ — done in + W3/W4/W5 (below). Idempotency: webhook grants are idempotent on + Stripe ids; request-level idempotency keys are still open. - restart_rest.sh / restart_mcp.sh were not copied from the prototype (they point at prototype paths/ports). - Ruff: clean except deliberate fail-closed broad `except` catches (BLE001 in ca/certs/billing legacy path, S112 in the legacy JSONL reader) and import-order notes where sys.path setup must precede imports — all inherited patterns, left as-is intentionally. + +## W3 — Access control + trial enforcement + +- **API keys** (`store.py`): `Store.create_api_key(label)` returns a + plaintext `axk_` exactly once; the store keeps + ONLY its SHA-256 hash (`api_keys`: key_hash PK, label, created_at, + revoked) plus an audit event carrying hash + label. Operator CLI: + `python -m store create-key --label "Acme"` (prints the key once). + `revoke_api_key(key_hash)` revokes by hash. Nothing anywhere logs + or persists a plaintext key. +- **Identity = key hash.** Everywhere a caller identity is recorded + (meter_events.key_id, certificate key_id, limits, grants) it is the + key's SHA-256 hash — stable and joinable, not a secret. Request + bodies carry no identity at all. +- **REST**: `/verify` and `/check` require a key (`X-Api-Key` header + or `Authorization: Bearer`); missing / unknown / revoked → 401. + The old `rest-anonymous` metering identity is gone. `/check` is a + read: it consumes no trial case and no credits. Open endpoints: + `/health`, `/.well-known/axiomcode-keys.json`, and `/revoked` + (the public CRL — publishing it is the point, same as X.509). +- **MCP**: bearer auth now validates against the store, replacing + keys.txt / `billing.load_keys` (the mutable-identity hole is + closed: `_KEY` binds the authenticated key's hash per request, and + no tool takes an identity parameter, so callers cannot meter or + spend as another key). The server process's own key comes from + `AXIOMCODE_API_KEY` and is provisioned at startup if unknown + (`ensure_api_key`, label `AXIOMCODE_API_KEY_LABEL`, default + "mcp-server"); a store revocation is never undone by a restart. +- **Trial** (shared flow, `verify_service.execute_verification` — + both servers run the same code so the interfaces cannot drift): + balance = sum(credit_grants) − metered credits used + (`store.balance_for`). Balance > 0 → paid case (proof, meter, + issue + record cert). Else, while trial cases used < 10 → free + trial case: proof runs, a zero-credit meter event flagged + `trial = 1` is recorded (that flag is how cases are counted), the + response is `{verified, attempts, certificate: None, usage, + trial: {cases_used, cases_left, certificates: "unlock with a + credit pack"}}` and NO certificate is recorded. Trial exhausted + and balance ≤ 0 → refused with 402 (REST) / a payment_required + error object (MCP) BEFORE the prover is invoked — no free compute. + Edge: a positive-but-tiny balance takes the paid path and may go + slightly negative on that call (metering is post-hoc by design). +- **Limits** (`limits.py`, per-process state): sliding-window rate + limit per key, default 60 req/min (`AXIOMCODE_RATE_LIMIT_PER_MIN`) + → 429; per-key proof concurrency cap, default 2 + (`AXIOMCODE_MAX_CONCURRENT_PROOFS`), non-blocking → 429 / + too_many_concurrent_proofs. +- **Store additions**: tables `api_keys`, `credit_grants` + (external_id UNIQUE → grant idempotency), `meter_events.trial` + column (added by an idempotent ALTER migration for W2 stores). + `grant_credits` returns False on a duplicate external_id instead + of raising, so webhook replays are naturally no-ops. +- **PR #21 hardening fixes**: (i) `ca.issue()` applies CA-fixed + fields (version, alg, kid, serial, issuer, validity window) AFTER + the subject spread — subjects can no longer override them, and a + subject-supplied "signature" is always replaced; (ii) + `verify_certificate` rejects a cert whose kid ≠ the verifying + key's kid (reason `unknown_kid`) after the signature check, so a + wrong key still reports `bad_signature` as before; (iii) + `record_cert` / `revoke` write the state row AND their audit event + in ONE transaction (rollback on error); (iv) **audit chain format + v2**: hash = SHA-256(prev_hash | event_type | ts | canonical + payload) — re-typing or re-dating a stored event now breaks the + chain too. The store is pre-production, so no migration of W2 + chains is owed; a W2-created DB must not be mixed with v2 writers. + +## W4 — Stripe billing (code layer only; no live calls anywhere) + +- `stripe_billing.py`: pack catalogue starter 1,000cr/$10, growth + 5,000cr/$50, scale 20,000cr/$200 (doctrine unchanged); price IDs + from `AXIOMCODE_STRIPE_PRICE_STARTER/_GROWTH/_SCALE`. + `build_checkout_payload(pack, key_hash)` is pure; the session + carries the key HASH as client_reference_id + metadata (never the + plaintext key). The API call sits behind `StripeClient` + (lazy SDK import) — tests inject a fake client. +- Webhook core `handle_webhook(store, payload, signature, + verify_signature)`: the route wires verify_signature to + `stripe.Webhook.construct_event`; tests pass a trivial callable. + `checkout.session.completed` → grant the pack's credits with + external_id = session id (replays grant nothing), plus a + zero-credit link row keyed by the session's payment_intent id + (source `stripe_pi_for:`) so refunds can find the + grant. `charge.refunded` → negative grant reversing the original + (external_id `:refund:`, replay-safe); + refunds for unknown payments return handled=False for deploy-time + monitoring to alert on. +- Route `POST /billing/webhook` in verify_api.py: without the + `stripe` package or `STRIPE_WEBHOOK_SECRET` it answers 501 with a + clear log line rather than accepting unverifiable events. +- **Still stubbed (deploy-time)**: creating the products/prices in + the live Stripe account, setting the price-id env vars, + `STRIPE_SECRET_KEY` / `STRIPE_WEBHOOK_SECRET`, and endpoint + registration in the Stripe dashboard. No network call exists in + any tested path. + +## W5 — Job isolation + +- Submission size cap: `AXIOMCODE_MAX_CODE_BYTES` (default 262144) + → 413 / code_too_large, checked before proving on both interfaces. +- Proof timeout: `AXIOMCODE_PROOF_TIMEOUT_S` (default 600) enforced + in `verify_service.run_proof` around the prover call → 504 / + proof_timeout. Implemented with a worker thread (threads cannot be + killed): on timeout the response goes out, the temp project is + removed by the finally block, and the orphaned prover thread dies + against the removed directory. Hard-kill process isolation is a + possible future step if abuse shows up. +- Per-job temp projects stay unique (`mkdtemp` under + `AXIOMCODE_WORK_ROOT`) and are always removed; shared mutable + state between concurrent jobs is only the store (single + connection + lock, WAL) and the limiter/gate (internally locked). + +## Env vars (new in W3–W5) + +- `AXIOMCODE_TRIAL_CASES` (10), `AXIOMCODE_RATE_LIMIT_PER_MIN` (60), + `AXIOMCODE_MAX_CONCURRENT_PROOFS` (2), `AXIOMCODE_MAX_CODE_BYTES` + (262144), `AXIOMCODE_PROOF_TIMEOUT_S` (600) +- `AXIOMCODE_API_KEY`, `AXIOMCODE_API_KEY_LABEL` (MCP server key) +- `AXIOMCODE_STRIPE_PRICE_STARTER/_GROWTH/_SCALE`, + `AXIOMCODE_CHECKOUT_SUCCESS_URL`, `AXIOMCODE_CHECKOUT_CANCEL_URL`, + `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` (deploy-time) + +## Tests — 40 passed, 0 failed (also clean under +## `-W error::DeprecationWarning`) + +- test_access.py (9, new): 401 for missing/unknown key and for a + caller-supplied body identity; X-Api-Key and Bearer both work; + revoked key → 401; /health, keys payload, /revoked stay open; + /check consumes no trial case and no credits; full trial flow — + 10 free cases with certificate=None and zero credits, no cert + recorded, 11th refused 402 with the prover never invoked, grant → + cert issued; rate limit 429; size cap 413; proof timeout 504 with + the temp dir cleaned up. +- test_billing_flow.py (10, new): balance = grants − metered + credits; trial meter events don't spend balance; grant idempotency + on external_id; only key hashes are stored; checkout payload shape + + error cases; fake-client session creation; webhook grants pack + credits exactly once across replays; refund reverses exactly once; + unknown refund / unhandled event types; bad signature propagates; + webhook route answers 501 until Stripe is configured. +- test_hardening.py (7, new): subject cannot override issuer / + serial / kid / alg / version / validity in issue(); cert signed by + one key but naming another's kid is rejected (`unknown_kid`); + chain detects event_type tamper (broken_seq 1) and ts tamper + (broken_seq 2); concurrency gate unit behaviour; a second + concurrent proof for the same key is rejected while one runs; + MCP trial flow + tools expose no identity parameter. +- test_ca.py / test_store.py / test_wiring.py (14, updated): + wiring tests now authenticate (provisioned key + fixture grant) + and assert hash-keyed identity; all W1/W2 behaviour unchanged. +- Smoke (manual, not in suite): `python -m store create-key` issues + a key whose plaintext appears nowhere in the DB file; MCP server + with `AXIOMCODE_API_KEY` set provisions and authenticates it. +- Ruff: `ruff check` is clean for all service modules under the repo + config (verified in a repo-shaped tree — sibling-module import + order is first-party-sensitive, so it must be checked in-repo, not + from this directory). New files are `ruff format`-clean; store.py + and the older test files keep their W1/W2 formatting (ruff format + is not the repo gate for this subtree, and restyling them would + bury the functional diff). Pre-existing exceptions unchanged: + E741 in legacy certs.py, test-file import grouping as merged. diff --git a/services/api/billing.py b/services/api/billing.py index e305a7c..9d94d42 100644 --- a/services/api/billing.py +++ b/services/api/billing.py @@ -81,9 +81,22 @@ def _is_store(target) -> bool: return hasattr(target, "record_meter") and hasattr(target, "usage_for") -def meter(target, key_id: str, code_sha: str, verified: bool, compute_s: float, pricing: dict | None = None) -> dict: +def meter( + target, + key_id: str, + code_sha: str, + verified: bool, + compute_s: float, + pricing: dict | None = None, + trial: bool = False, +) -> dict: """Record one metered call. `target` is the durable Store - (production) or a legacy JSONL path. Returns the entry.""" + (production) or a legacy JSONL path. Returns the entry. + + Trial calls (W3) are FREE: the charge is computed as usual (the + rate fields stay on the entry for transparency) but credits/usd + are recorded as 0 and the entry is flagged trial — the store + counts those rows as the key's consumed trial cases.""" bill = charge_for(compute_s, pricing) entry = { "ts": int(time.time()), @@ -92,6 +105,10 @@ def meter(target, key_id: str, code_sha: str, verified: bool, compute_s: float, "verified": verified, **bill, } + if trial: + entry["credits"] = 0.0 + entry["usd"] = 0.0 + entry["trial"] = True if _is_store(target): entry_out: dict = target.record_meter(entry) return entry_out diff --git a/services/api/ca.py b/services/api/ca.py index b56d6fd..ed8ea05 100644 --- a/services/api/ca.py +++ b/services/api/ca.py @@ -117,9 +117,13 @@ def sign_body(self, body: dict) -> str: def issue(self, subject: dict) -> dict: """Issue a v2 certificate. `subject` holds the verdict fields - (code_sha256, verified, attempts, toolchain, key_id, ...).""" + (code_sha256, verified, attempts, toolchain, key_id, ...). + The CA-fixed fields (version, alg, kid, serial, issuer, + validity window) are applied AFTER the subject spread, so a + subject can never override them (PR #21 review hardening).""" now = int(time.time()) cert = { + **subject, "version": 2, "alg": ALG, "kid": self.kid, @@ -127,7 +131,6 @@ def issue(self, subject: dict) -> dict: "issuer": ISSUER, "valid_from": now, "valid_until": now + VALIDITY_DAYS * 86400, - **subject, } cert["signature"] = self.sign_body(cert) return cert @@ -170,6 +173,10 @@ def verify_certificate(cert: dict, public_key=None, ca: CA | None = None) -> dic pub.verify(base64.b64decode(sig_b64), canonical(body)) except Exception: # InvalidSignature, malformed base64, wrong key type return {"valid": False, "reason": "bad_signature"} + if cert.get("kid") != kid_for(pub): + # The signature verifies under this key but the cert names a + # different one — not a cert this key stands behind. + return {"valid": False, "reason": "unknown_kid"} now = int(time.time()) if now < cert.get("valid_from", 0): return {"valid": False, "reason": "not_yet_valid"} diff --git a/services/api/limits.py b/services/api/limits.py new file mode 100644 index 0000000..d368e9f --- /dev/null +++ b/services/api/limits.py @@ -0,0 +1,74 @@ +"""AxiomCode request limits (W3) — per-key rate limiting + proof +concurrency, in-process and dependency-free. + +RateLimiter: sliding window per key (default 60 requests / 60 s, +AXIOMCODE_RATE_LIMIT_PER_MIN). ConcurrencyGate: at most N proofs in +flight per key (default 2, AXIOMCODE_MAX_CONCURRENT_PROOFS) — a Lean +build is the expensive unit, so concurrency is gated separately from +request rate. + +State is per-process by design: each server process enforces its own +limits. (A multi-process deployment would move these to the store; +the single-host launch topology does not need it.) +""" + +import os +import threading +import time +from collections import deque + +RATE_LIMIT_PER_MIN = int(os.environ.get("AXIOMCODE_RATE_LIMIT_PER_MIN", "60")) +MAX_CONCURRENT_PROOFS = int(os.environ.get("AXIOMCODE_MAX_CONCURRENT_PROOFS", "2")) + + +class RateLimiter: + """Sliding-window limiter: allow(key) is True while fewer than + `limit` requests from `key` landed in the last `window_s` seconds.""" + + def __init__(self, limit: int = RATE_LIMIT_PER_MIN, window_s: float = 60.0): + self.limit = limit + self.window_s = window_s + self._hits: dict[str, deque] = {} + self._lock = threading.Lock() + + def allow(self, key: str) -> bool: + now = time.monotonic() + cutoff = now - self.window_s + with self._lock: + hits = self._hits.setdefault(key, deque()) + while hits and hits[0] <= cutoff: + hits.popleft() + if len(hits) >= self.limit: + return False + hits.append(now) + return True + + +class ConcurrencyGate: + """Per-key cap on simultaneous proofs. acquire() is non-blocking: + False means the key is already at its cap (caller answers 429 / + a rate_limited error rather than queueing unboundedly).""" + + def __init__(self, limit: int = MAX_CONCURRENT_PROOFS): + self.limit = limit + self._in_flight: dict[str, int] = {} + self._lock = threading.Lock() + + def acquire(self, key: str) -> bool: + with self._lock: + if self._in_flight.get(key, 0) >= self.limit: + return False + self._in_flight[key] = self._in_flight.get(key, 0) + 1 + return True + + def release(self, key: str) -> None: + with self._lock: + n = self._in_flight.get(key, 0) + if n <= 1: + self._in_flight.pop(key, None) + else: + self._in_flight[key] = n - 1 + + def in_flight(self, key: str) -> int: + with self._lock: + return self._in_flight.get(key, 0) diff --git a/services/api/mcp_server.py b/services/api/mcp_server.py index 39a5b94..80ef618 100644 --- a/services/api/mcp_server.py +++ b/services/api/mcp_server.py @@ -2,32 +2,39 @@ Tools: verify(code, max_attempts), check_certificate(certificate), pricing(), usage_report(). -Auth: Authorization: Bearer (keys in API_KEYS_FILE, one per -line: :). +Auth: Authorization: Bearer . Keys are provisioned in the +durable store (store.py — only SHA-256 hashes are kept); the server +process's own key is supplied via AXIOMCODE_API_KEY and provisioned +at startup if unknown (label AXIOMCODE_API_KEY_LABEL, default +"mcp-server"). The caller's identity is the hash of the presented +key — tools take no identity parameter, so no caller can meter, +spend or impersonate under another key. Production build: certificates are Ed25519-signed (ca.py, format v2); -metering, revocation and the hash-chained audit log live in the -durable store (store.py) — no more meter.jsonl / revoked.txt. -Temp Lean projects are created under AXIOMCODE_WORK_ROOT and always -removed after the verification finishes. +metering, revocation, grants and the hash-chained audit log live in +the durable store (store.py). Trial policy (W3) applies exactly as +on REST: a new key's first 10 cases return verdicts without a +certificate; certificates unlock with paid credits. Per-key rate +limit + proof concurrency cap (limits.py) and submission size / +proof timeout caps (W5) apply too — all enforced by the shared +verify_service flow so the interfaces cannot drift. Run: uvicorn mcp_server:mcp_app --port 8092 (Uses Streamable HTTP transport so remote agents can connect.) """ -import hashlib import os -import shutil import sys import tempfile -import time from pathlib import Path sys.path.insert(0, "/home/hatch/workspace/axiomcode/repo") sys.path.insert(0, os.path.dirname(__file__)) import ca -from billing import load_keys, meter, price_per_compute_s, usage_for +import limits +import verify_service +from billing import price_per_compute_s, usage_for from mcp.server.fastmcp import FastMCP from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import JSONResponse @@ -37,18 +44,29 @@ # ─── config ────────────────────────────────────────────────────────────── _HERE = Path(__file__).parent -API_KEYS_FILE = os.environ.get("AXIOMCODE_API_KEYS", str(_HERE / "keys.txt")) WORK_ROOT = os.environ.get("AXIOMCODE_WORK_ROOT", tempfile.gettempdir()) TOOLCHAIN = "leanprover/lean4:v4.35.0-rc3" +MAX_CODE_BYTES = int(os.environ.get("AXIOMCODE_MAX_CODE_BYTES", "262144")) +TRIAL_CASES = verify_service.TRIAL_CASES +PROOF_TIMEOUT_S = verify_service.PROOF_TIMEOUT_S CA = ca.CA.load_or_create() STORE = Store() +LIMITER = limits.RateLimiter() +GATE = limits.ConcurrencyGate() + +# The server process's own API key (clients present it as Bearer). +# Provisioned into the store at startup if unknown; a later operator +# revocation in the store wins over this env var (ensure, not force). +_SERVER_KEY = os.environ.get("AXIOMCODE_API_KEY", "") +if _SERVER_KEY: + STORE.ensure_api_key(_SERVER_KEY, label=os.environ.get("AXIOMCODE_API_KEY_LABEL", "mcp-server")) mcp = FastMCP("axiomcode-verify") class _KeyCtx: - id = "anonymous" + key_hash = "" _KEY = _KeyCtx() @@ -58,10 +76,10 @@ class AuthMiddleware(BaseHTTPMiddleware): async def dispatch(self, request, call_next): auth = request.headers.get("authorization", "") key = auth[7:] if auth.lower().startswith("bearer ") else "" - kid = load_keys(API_KEYS_FILE).get(key) - if not kid: + row = STORE.authenticate_api_key(key) if key else None + if row is None: return JSONResponse({"error": "invalid or missing API key"}, status_code=401) - _KEY.id = kid + _KEY.key_hash = str(row["key_hash"]) return await call_next(request) @@ -70,64 +88,54 @@ def verify(code: str, max_attempts: int = 1) -> dict: """Machine-verify a Lean 4 proof and get a signed certificate of the verdict. The proof is really compiled — sorry/admit never counts as verified. Anyone can re-check the certificate without trusting us: - verification-as-evidence, not verification-as-opinion.""" - key_id = _KEY.id - proj = Path(tempfile.mkdtemp(prefix="ax-mcp-", dir=WORK_ROOT)) + verification-as-evidence, not verification-as-opinion. + Trial keys (first 10 cases, no credit balance) get the verdict + with certificate=None; certificates unlock with a credit pack.""" + key_hash = _KEY.key_hash + if len(code.encode()) > MAX_CODE_BYTES: + return {"error": "code_too_large", "max_bytes": MAX_CODE_BYTES} + if not LIMITER.allow(key_hash): + return {"error": "rate_limited", "detail": f"over {LIMITER.limit} requests per minute for this key"} try: - (proj / "lean-toolchain").write_text(TOOLCHAIN) - (proj / "lakefile.lean").write_text( - "import Lake\nopen Lake DSL\npackage v where\nlean_lib V where\n roots := #[`V]\n" - ) - src = proj / "V.lean" - - def writer(c: str) -> Path: - src.write_text(c) - return src - - t0 = time.time() - verified, _, attempts, _ = iterative_proof_search( - writer, - proj, - code, - None, - "none", + return verify_service.execute_verification( + store=STORE, + authority=CA, + key_hash=key_hash, + code=code, max_attempts=max_attempts, - use_pantograph=False, + work_root=WORK_ROOT, + prefix="ax-mcp-", + toolchain=TOOLCHAIN, + search=iterative_proof_search, + gate=GATE, + timeout_s=PROOF_TIMEOUT_S, + trial_cases=TRIAL_CASES, ) - compute_s = time.time() - t0 - finally: - shutil.rmtree(proj, ignore_errors=True) - - code_sha = hashlib.sha256(code.encode()).hexdigest() - usage = meter(STORE, key_id, code_sha, verified, compute_s) - cert = CA.issue( - { - "code_sha256": code_sha, - "verified": verified, - "attempts": attempts, - "toolchain": TOOLCHAIN, - "key_id": key_id, + except verify_service.PaymentRequiredError: + return { + "error": "payment_required", + "detail": "trial cases exhausted and no credit balance; certificates unlock with a credit pack", + "trial": {"cases_used": TRIAL_CASES, "cases_left": 0, "certificates": "unlock with a credit pack"}, } - ) - STORE.record_cert(cert) - return { - "verified": verified, - "attempts": attempts, - "certificate": cert, - "usage": usage, - } + except verify_service.TooManyConcurrentError: + return {"error": "too_many_concurrent_proofs", "detail": f"at most {GATE.limit} proofs in flight per key"} + except verify_service.ProofTimeoutError: + return {"error": "proof_timeout", "timeout_s": PROOF_TIMEOUT_S} @mcp.tool() def usage_report() -> dict: """Total metered spend for the calling API key, in credits and USD.""" - return usage_for(STORE, _KEY.id) + return usage_for(STORE, _KEY.key_hash) @mcp.tool() def check_certificate(certificate: dict) -> dict: """Re-verify a verification certificate: signature, validity window, - revocation status. Anyone can check — never take our word for it.""" + revocation status. Anyone can check — never take our word for it. + (Authenticated like every tool; consumes no trial case or credits.)""" + if not LIMITER.allow(_KEY.key_hash): + return {"error": "rate_limited", "detail": f"over {LIMITER.limit} requests per minute for this key"} result = ca.verify_certificate(certificate, ca=CA) serial = certificate.get("serial") if isinstance(certificate, dict) else None if result.get("valid") and serial and STORE.is_revoked(serial): diff --git a/services/api/store.py b/services/api/store.py index 714ba16..0360e36 100644 --- a/services/api/store.py +++ b/services/api/store.py @@ -1,4 +1,4 @@ -"""AxiomCode durable store (production, W2) — sqlite3, stdlib only. +"""AxiomCode durable store (production, W2 + W3) — sqlite3, stdlib only. Replaces the prototype's revoked.txt + meter.jsonl flat files with one durable database: @@ -6,23 +6,40 @@ certificates (serial PK, cert_json, status, issued_at) revocations (serial PK, reason, ts) meter_events (id, ts, key_id, code_sha256, verified, credits, usd, - entry_json) — the billing record + trial, entry_json) — the billing record; trial rows + are free verification cases (credits = 0, trial = 1) audit_log (seq, ts, event_type, payload_json, prev_hash, hash) + api_keys (key_hash PK, label, created_at, revoked) — only the + SHA-256 hash of a key is ever stored, never plaintext + credit_grants (id, key_hash, credits, source, external_id UNIQUE, ts) + — purchased / comped credits; external_id (e.g. a + Stripe checkout session id) makes grants idempotent The audit log is hash-chained: every row's hash is -SHA-256(prev_hash + canonical payload_json), where the payload is -stored as its canonical JSON string (json.dumps, sort_keys=True), so -any later edit to a stored payload — even one byte — breaks the chain -at that seq, and verify_chain() reports the first broken seq. +SHA-256(prev_hash | event_type | ts | canonical payload_json), where +the payload is stored as its canonical JSON string (json.dumps, +sort_keys=True), so any later edit to a stored payload, event type or +timestamp — even one byte — breaks the chain at that seq, and +verify_chain() reports the first broken seq. (Chain format v2, W3: +the W2 format bound only the payload. The store is pre-production, +so no migration of W2 chains is owed.) + +A key's balance is sum(credit_grants) - sum(meter_events.credits); +trial cases are counted from meter_events rows with trial = 1. +Everywhere a caller identity is recorded (meter_events.key_id, +certificate key_id) it is the key's SHA-256 hash — stable, joinable, +and not a secret. Issuance and revocation append their audit events by construction -(record_cert / revoke do it themselves); verification events are -appended by the API layer via append_audit(). +(record_cert / revoke do it themselves, in the same transaction as +the state change); verification events are appended by the API layer +via append_audit(). """ import hashlib import json import os +import secrets import sqlite3 import threading import time @@ -62,15 +79,40 @@ prev_hash TEXT NOT NULL, hash TEXT NOT NULL ); +CREATE TABLE IF NOT EXISTS api_keys ( + key_hash TEXT PRIMARY KEY, + label TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + revoked INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS credit_grants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key_hash TEXT NOT NULL, + credits REAL NOT NULL, + source TEXT NOT NULL DEFAULT '', + external_id TEXT UNIQUE, + ts INTEGER NOT NULL +); """ +KEY_PREFIX = "axk_" + + +def hash_api_key(plaintext: str) -> str: + """The only representation of an API key that is ever stored.""" + return hashlib.sha256(plaintext.encode()).hexdigest() + def _canonical(payload: dict) -> str: return json.dumps(payload, sort_keys=True) -def _chain_hash(prev_hash: str, payload_json: str) -> str: - return hashlib.sha256((prev_hash + payload_json).encode()).hexdigest() +def _chain_hash(prev_hash: str, event_type: str, ts: int, payload_json: str) -> str: + """Chain format v2 (W3): binds event type and timestamp as well as + the payload, so re-typing or re-dating a stored event also breaks + the chain.""" + material = "|".join((prev_hash, event_type, str(ts), payload_json)) + return hashlib.sha256(material.encode()).hexdigest() class Store: @@ -85,6 +127,11 @@ def __init__(self, db_path: str | Path | None = None): self._db.row_factory = sqlite3.Row self._db.execute("PRAGMA journal_mode=WAL") self._db.executescript(_SCHEMA) + # Migration for stores created by W2: meter_events.trial marks + # free trial cases (zero-credit) — added in W3. + cols = {r["name"] for r in self._db.execute("PRAGMA table_info(meter_events)")} + if "trial" not in cols: + self._db.execute("ALTER TABLE meter_events ADD COLUMN trial INTEGER NOT NULL DEFAULT 0") self._db.commit() def close(self) -> None: @@ -92,38 +139,47 @@ def close(self) -> None: self._db.close() # ─── audit log ─────────────────────────────────────────────────── + def _insert_audit_locked(self, event_type: str, payload: dict, ts: int) -> dict: + """Insert one hash-chained audit row. Caller must hold the lock + and owns the transaction (commit / rollback).""" + payload_json = _canonical(payload) + row = self._db.execute("SELECT hash FROM audit_log ORDER BY seq DESC LIMIT 1").fetchone() + prev_hash = row["hash"] if row else GENESIS_HASH + h = _chain_hash(prev_hash, event_type, ts, payload_json) + cur = self._db.execute( + "INSERT INTO audit_log (ts, event_type, payload_json, prev_hash, hash) VALUES (?, ?, ?, ?, ?)", + (ts, event_type, payload_json, prev_hash, h), + ) + return { + "seq": cur.lastrowid, + "ts": ts, + "event_type": event_type, + "payload": payload, + "prev_hash": prev_hash, + "hash": h, + } + def append_audit(self, event_type: str, payload: dict) -> dict: """Append one hash-chained audit event. Returns the stored row.""" - payload_json = _canonical(payload) ts = int(time.time()) with self._lock: - row = self._db.execute("SELECT hash FROM audit_log ORDER BY seq DESC LIMIT 1").fetchone() - prev_hash = row["hash"] if row else GENESIS_HASH - h = _chain_hash(prev_hash, payload_json) - cur = self._db.execute( - "INSERT INTO audit_log (ts, event_type, payload_json, prev_hash, hash) VALUES (?, ?, ?, ?, ?)", - (ts, event_type, payload_json, prev_hash, h), - ) + out = self._insert_audit_locked(event_type, payload, ts) self._db.commit() - return { - "seq": cur.lastrowid, - "ts": ts, - "event_type": event_type, - "payload": payload, - "prev_hash": prev_hash, - "hash": h, - } + return out def verify_chain(self) -> dict: """Recompute the whole chain. {"ok": True, "events": n} or {"ok": False, "broken_seq": }.""" with self._lock: - rows = self._db.execute("SELECT seq, payload_json, prev_hash, hash FROM audit_log ORDER BY seq").fetchall() + rows = self._db.execute( + "SELECT seq, ts, event_type, payload_json, prev_hash, hash FROM audit_log ORDER BY seq" + ).fetchall() prev = GENESIS_HASH for r in rows: if r["prev_hash"] != prev: return {"ok": False, "broken_seq": r["seq"]} - if _chain_hash(r["prev_hash"], r["payload_json"]) != r["hash"]: + expect = _chain_hash(r["prev_hash"], r["event_type"], r["ts"], r["payload_json"]) + if expect != r["hash"]: return {"ok": False, "broken_seq": r["seq"]} prev = r["hash"] return {"ok": True, "events": len(rows)} @@ -140,22 +196,29 @@ def audit_events(self, event_type: str | None = None) -> list[dict]: # ─── certificates & revocation ─────────────────────────────────── def record_cert(self, cert: dict) -> dict: - """Persist an issued certificate + its issuance audit event.""" + """Persist an issued certificate + its issuance audit event, + in ONE transaction (W3 hardening: the two used to commit + separately, so a crash could leave a cert with no audit row).""" with self._lock: - self._db.execute( - "INSERT OR REPLACE INTO certificates (serial, cert_json, status, issued_at) VALUES (?, ?, ?, ?)", - (cert["serial"], _canonical(cert), "issued", int(cert.get("valid_from", time.time()))), - ) - self._db.commit() - self.append_audit( - "issuance", - { - "serial": cert["serial"], - "kid": cert.get("kid"), - "code_sha256": cert.get("code_sha256"), - "verified": cert.get("verified"), - }, - ) + try: + self._db.execute( + "INSERT OR REPLACE INTO certificates (serial, cert_json, status, issued_at) VALUES (?, ?, ?, ?)", + (cert["serial"], _canonical(cert), "issued", int(cert.get("valid_from", time.time()))), + ) + self._insert_audit_locked( + "issuance", + { + "serial": cert["serial"], + "kid": cert.get("kid"), + "code_sha256": cert.get("code_sha256"), + "verified": cert.get("verified"), + }, + int(time.time()), + ) + self._db.commit() + except Exception: + self._db.rollback() + raise return cert def get_cert(self, serial: str) -> dict | None: @@ -164,14 +227,20 @@ def get_cert(self, serial: str) -> dict | None: return json.loads(row["cert_json"]) if row else None def revoke(self, serial: str, reason: str = "") -> None: + """Revoke a certificate + append the revocation audit event, + in ONE transaction (same W3 hardening as record_cert).""" ts = int(time.time()) with self._lock: - self._db.execute( - "INSERT OR REPLACE INTO revocations (serial, reason, ts) VALUES (?, ?, ?)", (serial, reason, ts) - ) - self._db.execute("UPDATE certificates SET status = 'revoked' WHERE serial = ?", (serial,)) - self._db.commit() - self.append_audit("revocation", {"serial": serial, "reason": reason}) + try: + self._db.execute( + "INSERT OR REPLACE INTO revocations (serial, reason, ts) VALUES (?, ?, ?)", (serial, reason, ts) + ) + self._db.execute("UPDATE certificates SET status = 'revoked' WHERE serial = ?", (serial,)) + self._insert_audit_locked("revocation", {"serial": serial, "reason": reason}, ts) + self._db.commit() + except Exception: + self._db.rollback() + raise def is_revoked(self, serial: str) -> bool: with self._lock: @@ -185,12 +254,14 @@ def revoked_serials(self) -> set: # ─── metering ──────────────────────────────────────────────────── def record_meter(self, entry: dict) -> dict: - """Persist one metered call (the full billing entry is kept).""" + """Persist one metered call (the full billing entry is kept). + Trial cases arrive with credits = 0 and entry["trial"] set; + the flag is indexed so trial usage is countable per key.""" with self._lock: self._db.execute( "INSERT INTO meter_events (ts, key_id, code_sha256," - " verified, credits, usd, entry_json)" - " VALUES (?, ?, ?, ?, ?, ?, ?)", + " verified, credits, usd, trial, entry_json)" + " VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ( int(entry.get("ts", time.time())), entry.get("key_id", ""), @@ -198,12 +269,21 @@ def record_meter(self, entry: dict) -> dict: 1 if entry.get("verified") else 0, float(entry.get("credits", 0)), float(entry.get("usd", 0)), + 1 if entry.get("trial") else 0, _canonical(entry), ), ) self._db.commit() return entry + def trial_cases_used(self, key_id: str) -> int: + """Free trial verification cases consumed by a key (W3).""" + with self._lock: + row = self._db.execute( + "SELECT COUNT(*) AS n FROM meter_events WHERE key_id = ? AND trial = 1", (key_id,) + ).fetchone() + return int(row["n"]) + def usage_for(self, key_id: str) -> dict: """Aggregate metered spend — same shape as billing.usage_for.""" with self._lock: @@ -230,6 +310,136 @@ def meter_events(self, key_id: str | None = None) -> list[dict]: rows = self._db.execute("SELECT entry_json FROM meter_events ORDER BY id").fetchall() return [json.loads(r["entry_json"]) for r in rows] + # ─── API keys (W3) ─────────────────────────────────────────────── + def create_api_key(self, label: str = "") -> str: + """Create a key and return its PLAINTEXT — shown once, never + stored. Only the SHA-256 hash lands in the store (plus an + audit event carrying the hash and label, never the secret).""" + plaintext = KEY_PREFIX + secrets.token_urlsafe(32) + key_hash = hash_api_key(plaintext) + ts = int(time.time()) + with self._lock: + try: + self._db.execute( + "INSERT INTO api_keys (key_hash, label, created_at, revoked) VALUES (?, ?, ?, 0)", + (key_hash, label, ts), + ) + self._insert_audit_locked("api_key_created", {"key_hash": key_hash, "label": label}, ts) + self._db.commit() + except Exception: + self._db.rollback() + raise + return plaintext + + def ensure_api_key(self, plaintext: str, label: str = "") -> str: + """Provision a specific key (e.g. from AXIOMCODE_API_KEY at + server start) if its hash is not already known. Returns the + key_hash. An existing row is left untouched — in particular a + revocation is never silently undone by a restart.""" + key_hash = hash_api_key(plaintext) + ts = int(time.time()) + with self._lock: + row = self._db.execute("SELECT 1 FROM api_keys WHERE key_hash = ?", (key_hash,)).fetchone() + if row is None: + try: + self._db.execute( + "INSERT INTO api_keys (key_hash, label, created_at, revoked) VALUES (?, ?, ?, 0)", + (key_hash, label, ts), + ) + self._insert_audit_locked("api_key_created", {"key_hash": key_hash, "label": label}, ts) + self._db.commit() + except Exception: + self._db.rollback() + raise + return key_hash + + def lookup_api_key(self, plaintext: str) -> dict | None: + """Store row for a presented key (any state), or None.""" + key_hash = hash_api_key(plaintext) + with self._lock: + row = self._db.execute( + "SELECT key_hash, label, created_at, revoked FROM api_keys WHERE key_hash = ?", (key_hash,) + ).fetchone() + return dict(row) if row else None + + def authenticate_api_key(self, plaintext: str) -> dict | None: + """Store row for a presented key iff it exists and is not + revoked — the only check the servers rely on.""" + row = self.lookup_api_key(plaintext) + if row is None or row["revoked"]: + return None + return row + + def revoke_api_key(self, key_hash: str) -> bool: + """Revoke by hash (never by plaintext). True if a key changed.""" + ts = int(time.time()) + with self._lock: + try: + cur = self._db.execute( + "UPDATE api_keys SET revoked = 1 WHERE key_hash = ? AND revoked = 0", (key_hash,) + ) + if cur.rowcount: + self._insert_audit_locked("api_key_revoked", {"key_hash": key_hash}, ts) + self._db.commit() + except Exception: + self._db.rollback() + raise + return bool(cur.rowcount) + + # ─── credit grants & balance (W3) ──────────────────────────────── + def grant_credits(self, key_hash: str, credits: float, source: str = "", external_id: str | None = None) -> bool: + """Record a credit grant (purchase, comp, or a negative entry + for a refund reversal). Idempotent on external_id: returns + False when a grant with that external_id already exists, so a + replayed webhook grants nothing twice.""" + ts = int(time.time()) + with self._lock: + try: + self._db.execute( + "INSERT INTO credit_grants (key_hash, credits, source, external_id, ts) VALUES (?, ?, ?, ?, ?)", + (key_hash, float(credits), source, external_id, ts), + ) + self._insert_audit_locked( + "credit_grant", + { + "key_hash": key_hash, + "credits": float(credits), + "source": source, + "external_id": external_id, + }, + ts, + ) + self._db.commit() + except sqlite3.IntegrityError: + self._db.rollback() + return False + except Exception: + self._db.rollback() + raise + return True + + def find_grant(self, external_id: str) -> dict | None: + """The grant recorded under an external id, if any.""" + with self._lock: + row = self._db.execute( + "SELECT key_hash, credits, source, external_id, ts FROM credit_grants WHERE external_id = ?", + (external_id,), + ).fetchone() + return dict(row) if row else None + + def credits_granted(self, key_hash: str) -> float: + """Total credits ever granted to a key (net of reversals).""" + with self._lock: + row = self._db.execute( + "SELECT COALESCE(SUM(credits), 0) AS g FROM credit_grants WHERE key_hash = ?", (key_hash,) + ).fetchone() + return round(float(row["g"]), 4) + + def balance_for(self, key_hash: str) -> float: + """Spendable balance: grants minus metered credit usage.""" + used = float(self.usage_for(key_hash)["credits_used"]) + return round(self.credits_granted(key_hash) - used, 4) + _DEFAULT_STORE: Store | None = None @@ -240,3 +450,34 @@ def default_store() -> Store: if _DEFAULT_STORE is None: _DEFAULT_STORE = Store() return _DEFAULT_STORE + + +def issue_api_key(store: Store, label: str) -> str: + """Operator helper: create a key in `store`, return the plaintext + (to be handed to the customer once — it cannot be recovered).""" + return store.create_api_key(label) + + +def _main(argv: list[str] | None = None) -> int: + """CLI: python -m store create-key --label "Acme" [--db PATH] + Prints the new plaintext key exactly once.""" + import argparse + + parser = argparse.ArgumentParser(prog="store", description="AxiomCode store operator tools") + sub = parser.add_subparsers(dest="cmd", required=True) + create = sub.add_parser("create-key", help="create an API key (plaintext printed once)") + create.add_argument("--label", default="", help="human label for the key") + create.add_argument("--db", default=None, help="store path (default: AXIOMCODE_DB)") + args = parser.parse_args(argv) + if args.cmd == "create-key": + store = Store(args.db) + try: + print(issue_api_key(store, args.label)) + finally: + store.close() + return 0 + return 2 + + +if __name__ == "__main__": + raise SystemExit(_main()) diff --git a/services/api/stripe_billing.py b/services/api/stripe_billing.py new file mode 100644 index 0000000..b88b133 --- /dev/null +++ b/services/api/stripe_billing.py @@ -0,0 +1,189 @@ +"""AxiomCode Stripe billing (W4 code layer) — credit packs over +Stripe Checkout, credits granted by webhook. + +NO live Stripe calls live in this module's tested paths: building a +checkout payload is pure, the API call sits behind a thin client +(create_checkout_session(client=...)), and the webhook core takes a +signature-verification callable. Product/price creation in the live +account and the webhook secret arrive at deploy time. + +Packs (pricing doctrine — 1 credit = $0.01, metered at 0.9 credits +per compute-second; see billing.py): + starter 1,000 credits $10 + growth 5,000 credits $50 + scale 20,000 credits $200 +Price IDs come from env: AXIOMCODE_STRIPE_PRICE_STARTER / _GROWTH / +_SCALE. The checkout session carries the buyer's key HASH (never the +plaintext key) as client_reference_id + metadata, so the webhook can +grant credits to exactly that key. + +Webhook behaviour: + checkout.session.completed -> grant the pack's credits with + external_id = the checkout session id. Replays are no-ops: + store.grant_credits is idempotent on external_id. A zero-credit + link row (external_id = the session's payment_intent id, + source = "stripe_pi_for:") is recorded alongside so + a later refund can find the grant — charges know the payment + intent, not the checkout session. + charge.refunded -> if the charge's payment_intent resolves to a + link row, append a negative grant reversing the original + credits (external_id = ":refund:", so + replayed refund events are no-ops too) and the key's balance + drops accordingly. Unknown payments are reported unhandled; + deploy-time monitoring owns alerting on those. +""" + +import os + +PACKS = { + "starter": {"credits": 1000, "usd": 10, "price_env": "AXIOMCODE_STRIPE_PRICE_STARTER"}, + "growth": {"credits": 5000, "usd": 50, "price_env": "AXIOMCODE_STRIPE_PRICE_GROWTH"}, + "scale": {"credits": 20000, "usd": 200, "price_env": "AXIOMCODE_STRIPE_PRICE_SCALE"}, +} + +DEFAULT_SUCCESS_URL = os.environ.get("AXIOMCODE_CHECKOUT_SUCCESS_URL", "https://axiom-code.com/billing/success") +DEFAULT_CANCEL_URL = os.environ.get("AXIOMCODE_CHECKOUT_CANCEL_URL", "https://axiom-code.com/pricing/") + + +def price_id_for(pack: str) -> str | None: + """The configured Stripe price id for a pack, or None.""" + spec = PACKS.get(pack) + if spec is None: + return None + return os.environ.get(str(spec["price_env"])) or None + + +def pack_catalogue() -> dict: + """Pack list with live price-id wiring status (no secrets).""" + return { + name: { + "credits": spec["credits"], + "usd": spec["usd"], + "price_id": price_id_for(name), + "price_env": spec["price_env"], + } + for name, spec in PACKS.items() + } + + +def build_checkout_payload( + pack: str, key_hash: str, *, success_url: str = DEFAULT_SUCCESS_URL, cancel_url: str = DEFAULT_CANCEL_URL +) -> dict: + """The exact Checkout Session create payload for a pack + key. + Pure function — the network call is create_checkout_session's.""" + spec = PACKS.get(pack) + if spec is None: + raise ValueError(f"unknown pack: {pack!r}") + price_id = price_id_for(pack) + if price_id is None: + raise ValueError(f"no Stripe price configured for pack {pack!r} (set {spec['price_env']})") + return { + "mode": "payment", + "line_items": [{"price": price_id, "quantity": 1}], + "client_reference_id": key_hash, + "metadata": {"pack": pack, "key_hash": key_hash}, + "success_url": success_url, + "cancel_url": cancel_url, + } + + +class StripeClient: + """Thin wrapper over the stripe SDK so tests can substitute a + fake with the same create_checkout_session(payload) shape. The + SDK is imported lazily — this module never requires it.""" + + def __init__(self, secret_key: str | None = None): + import stripe + + stripe.api_key = secret_key or os.environ.get("STRIPE_SECRET_KEY", "") + self._stripe = stripe + + def create_checkout_session(self, payload: dict) -> dict: + session = self._stripe.checkout.Session.create(**payload) + return session.to_dict() if hasattr(session, "to_dict") else dict(session) + + +def create_checkout_session( + pack: str, + key_hash: str, + *, + client=None, + success_url: str = DEFAULT_SUCCESS_URL, + cancel_url: str = DEFAULT_CANCEL_URL, +) -> dict: + """Create a Checkout Session for a pack. `client` defaults to the + real StripeClient; tests pass a fake.""" + payload = build_checkout_payload(pack, key_hash, success_url=success_url, cancel_url=cancel_url) + client = client or StripeClient() + session: dict = client.create_checkout_session(payload) + return session + + +def handle_webhook(store, raw_payload, signature, verify_signature) -> dict: + """Webhook core. `verify_signature(raw_payload, signature)` must + return the event dict or raise (bad signature / malformed event) + — the route wires it to stripe.Webhook.construct_event; tests + pass a trivial callable. Signature failure propagates. + + Returns a result dict describing what happened; grants are + idempotent on Stripe ids, so redelivery is always safe.""" + event = verify_signature(raw_payload, signature) + if not isinstance(event, dict): + event = dict(event) + event_type = event.get("type", "") + obj = (event.get("data") or {}).get("object") or {} + + if event_type == "checkout.session.completed": + return _on_checkout_completed(store, obj) + if event_type == "charge.refunded": + return _on_charge_refunded(store, obj) + return {"handled": False, "reason": f"unhandled event type: {event_type}"} + + +def _on_checkout_completed(store, session: dict) -> dict: + key_hash = session.get("client_reference_id") or (session.get("metadata") or {}).get("key_hash") + pack = (session.get("metadata") or {}).get("pack") + spec = PACKS.get(pack or "") + if not key_hash or spec is None: + return {"handled": False, "reason": "session missing key_hash or known pack metadata"} + session_id = session.get("id", "") + granted = store.grant_credits( + key_hash, spec["credits"], source="stripe:checkout.session.completed", external_id=session_id + ) + payment_intent = session.get("payment_intent") + if payment_intent: + # Zero-credit link row: lets charge.refunded (which knows the + # payment intent, not the session) find this grant later. + store.grant_credits(key_hash, 0, source=f"stripe_pi_for:{session_id}", external_id=payment_intent) + return { + "handled": True, + "event": "checkout.session.completed", + "granted": granted, + "credits": spec["credits"] if granted else 0, + "key_hash": key_hash, + "pack": pack, + "session": session_id, + } + + +def _on_charge_refunded(store, charge: dict) -> dict: + payment_intent = charge.get("payment_intent") + link = store.find_grant(payment_intent) if payment_intent else None + if link is None or not str(link["source"]).startswith("stripe_pi_for:"): + return {"handled": False, "reason": "refund for an unknown payment"} + session_id = link["source"].split(":", 1)[1] + original = store.find_grant(session_id) + if original is None or original["credits"] <= 0: + return {"handled": False, "reason": "no grant found for refunded session"} + reversal_id = f"{session_id}:refund:{charge.get('id', '')}" + reversed_now = store.grant_credits( + original["key_hash"], -original["credits"], source="stripe:charge.refunded", external_id=reversal_id + ) + return { + "handled": True, + "event": "charge.refunded", + "reversed": reversed_now, + "credits": -original["credits"] if reversed_now else 0, + "key_hash": original["key_hash"], + "session": session_id, + } diff --git a/services/api/tests/test_access.py b/services/api/tests/test_access.py new file mode 100644 index 0000000..6bcc672 --- /dev/null +++ b/services/api/tests/test_access.py @@ -0,0 +1,190 @@ +"""W3/W5 — REST access control: API-key auth, trial enforcement, +rate limiting, submission size cap, proof timeout.""" + +import os +import sqlite3 +import time +from pathlib import Path + +import limits +import pytest +import verify_api +from fastapi.testclient import TestClient +from store import hash_api_key + +CODE = "theorem t : True := trivial" + + +def _counting_search(calls): + def search(writer, proj, code, *args, **kwargs): + calls.append(code) + writer(code) + return True, code, 1, "stubbed prover log" + + return search + + +@pytest.fixture() +def client(monkeypatch): + calls: list = [] + monkeypatch.setattr(verify_api, "iterative_proof_search", _counting_search(calls)) + monkeypatch.setattr(verify_api, "lean_version", lambda: "lean-test") + return TestClient(verify_api.app), calls + + +@pytest.fixture() +def slow_client(monkeypatch): + def slow(writer, proj, code, *args, **kwargs): + time.sleep(1.0) + return True, code, 1, "slow stub" + + monkeypatch.setattr(verify_api, "iterative_proof_search", slow) + monkeypatch.setattr(verify_api, "lean_version", lambda: "lean-test") + monkeypatch.setattr(verify_api, "PROOF_TIMEOUT_S", 0.2) + return TestClient(verify_api.app) + + +def _new_key(label, credits=0): + store = verify_api.STORE + plaintext = store.create_api_key(label) + key_hash = hash_api_key(plaintext) + if credits: + store.grant_credits(key_hash, credits, source="test-fixture", external_id=f"fixture-{key_hash[:12]}") + return plaintext, key_hash + + +def _cert_count_for(key_hash): + raw = sqlite3.connect(os.environ["AXIOMCODE_DB"]) + try: + (n,) = raw.execute( + "SELECT COUNT(*) FROM certificates WHERE cert_json LIKE ?", + (f"%{key_hash}%",), + ).fetchone() + return n + finally: + raw.close() + + +# ─── auth ──────────────────────────────────────────────────────────── + + +def test_missing_and_unknown_keys_get_401(client): + c, _calls = client + assert c.post("/verify", json={"code": CODE}).status_code == 401 + assert c.post("/verify", json={"code": CODE}, headers={"X-Api-Key": "axk_not-a-real-key"}).status_code == 401 + assert c.post("/check", json={"certificate": {}}).status_code == 401 + # A caller-supplied identity field buys nothing: still no key, still 401. + assert c.post("/verify", json={"code": CODE, "key_id": "victim"}).status_code == 401 + + +def test_bearer_and_x_api_key_both_authenticate(client): + c, _calls = client + plaintext, _h = _new_key("auth-shapes", credits=100) + body = {"certificate": {"serial": "nope"}} + assert c.post("/check", json=body, headers={"X-Api-Key": plaintext}).status_code == 200 + assert c.post("/check", json=body, headers={"Authorization": f"Bearer {plaintext}"}).status_code == 200 + + +def test_revoked_key_gets_401(client): + c, _calls = client + plaintext, key_hash = _new_key("auth-revoked", credits=100) + auth = {"X-Api-Key": plaintext} + assert c.post("/check", json={"certificate": {}}, headers=auth).status_code == 200 + verify_api.STORE.revoke_api_key(key_hash) + assert c.post("/check", json={"certificate": {}}, headers=auth).status_code == 401 + assert c.post("/verify", json={"code": CODE}, headers=auth).status_code == 401 + + +def test_open_endpoints_need_no_key(client): + c, _calls = client + assert c.get("/health").json() == {"status": "ok"} + assert c.get("/.well-known/axiomcode-keys.json").status_code == 200 + assert c.get("/revoked").status_code == 200 + + +def test_check_consumes_no_trial_or_credits(client): + c, _calls = client + plaintext, key_hash = _new_key("check-is-free") + auth = {"X-Api-Key": plaintext} + for _ in range(3): + r = c.post("/check", json={"certificate": {"serial": "x"}}, headers=auth) + assert r.status_code == 200 + store = verify_api.STORE + assert store.trial_cases_used(key_hash) == 0 + assert store.usage_for(key_hash)["calls"] == 0 + + +# ─── trial flow ────────────────────────────────────────────────────── + + +def test_trial_flow_then_unlock(client): + c, calls = client + store = verify_api.STORE + plaintext, key_hash = _new_key("trial-flow") + auth = {"X-Api-Key": plaintext} + + for i in range(10): + r = c.post("/verify", json={"code": CODE}, headers=auth) + assert r.status_code == 200 + body = r.json() + assert body["verified"] is True + assert body["certificate"] is None # the whole point of the trial + assert body["usage"]["credits"] == 0 # ...and it is free + assert body["trial"]["cases_used"] == i + 1 + assert body["trial"]["cases_left"] == 10 - (i + 1) + assert body["trial"]["certificates"] == "unlock with a credit pack" + assert len(calls) == 10 + assert store.trial_cases_used(key_hash) == 10 + assert store.usage_for(key_hash)["credits_used"] == 0 + assert _cert_count_for(key_hash) == 0 # no cert was ever recorded + + # 11th case: refused BEFORE the proof runs. + r = c.post("/verify", json={"code": CODE}, headers=auth) + assert r.status_code == 402 + assert r.json()["error"] == "payment_required" + assert len(calls) == 10 # prover never invoked + + # Credits land (what the Stripe webhook does) -> certificates unlock. + store.grant_credits(key_hash, 1000, source="test", external_id="cs_trial_unlock") + r = c.post("/verify", json={"code": CODE}, headers=auth) + assert r.status_code == 200 + body = r.json() + assert body["certificate"] is not None + assert body["certificate"]["key_id"] == key_hash + assert store.get_cert(body["certificate"]["serial"]) is not None + assert _cert_count_for(key_hash) == 1 + + +# ─── limits & isolation (W5) ───────────────────────────────────────── + + +def test_rate_limit_returns_429(client, monkeypatch): + c, _calls = client + monkeypatch.setattr(verify_api, "LIMITER", limits.RateLimiter(3, 60)) + plaintext, _h = _new_key("rate-limited", credits=100) + auth = {"X-Api-Key": plaintext} + for _ in range(3): + r = c.post("/check", json={"certificate": {}}, headers=auth) + assert r.status_code == 200 + r = c.post("/check", json={"certificate": {}}, headers=auth) + assert r.status_code == 429 + assert r.json()["error"] == "rate_limited" + + +def test_size_cap_returns_413(client, monkeypatch): + c, calls = client + monkeypatch.setattr(verify_api, "MAX_CODE_BYTES", 8) + plaintext, _h = _new_key("size-cap", credits=100) + r = c.post("/verify", json={"code": CODE}, headers={"X-Api-Key": plaintext}) + assert r.status_code == 413 + assert r.json()["error"] == "code_too_large" + assert calls == [] # refused before proving + + +def test_proof_timeout_returns_504_and_cleans_up(slow_client): + plaintext, _h = _new_key("timeout", credits=100) + r = slow_client.post("/verify", json={"code": CODE}, headers={"X-Api-Key": plaintext}) + assert r.status_code == 504 + assert r.json()["error"] == "proof_timeout" + root = Path(os.environ["AXIOMCODE_WORK_ROOT"]) + assert [p.name for p in root.iterdir() if p.name.startswith("ax-api-")] == [] diff --git a/services/api/tests/test_billing_flow.py b/services/api/tests/test_billing_flow.py new file mode 100644 index 0000000..6738367 --- /dev/null +++ b/services/api/tests/test_billing_flow.py @@ -0,0 +1,171 @@ +"""W3/W4 — balance math, grant idempotency, Stripe checkout payload +and webhook core (grant once, replay safe, refund reversal).""" + +import importlib.util + +import billing +import pytest +import stripe_billing +import verify_api +from fastapi.testclient import TestClient +from store import Store, hash_api_key + + +@pytest.fixture() +def store(tmp_path): + s = Store(tmp_path / "billing.db") + yield s + s.close() + + +@pytest.fixture() +def key(store): + plaintext = store.create_api_key("billing-flow") + return plaintext, hash_api_key(plaintext) + + +# ─── balance math ──────────────────────────────────────────────────── + + +def test_balance_is_grants_minus_metered_credits(store, key): + _plaintext, h = key + assert store.balance_for(h) == 0 + store.grant_credits(h, 1000, source="test", external_id="cs_1") + billing.meter(store, h, "aa" * 32, True, 2.0) # 2.0s x 0.9 = 1.8 credits + assert store.balance_for(h) == pytest.approx(1000 - 1.8) + # Trial cases are free: they count as cases, never as spend. + billing.meter(store, h, "bb" * 32, True, 5.0, trial=True) + assert store.trial_cases_used(h) == 1 + assert store.balance_for(h) == pytest.approx(1000 - 1.8) + assert store.credits_granted(h) == 1000 + + +def test_grant_idempotent_on_external_id(store, key): + _plaintext, h = key + assert store.grant_credits(h, 500, source="stripe", external_id="cs_x") is True + assert store.grant_credits(h, 500, source="stripe", external_id="cs_x") is False + assert store.credits_granted(h) == 500 + # Grants without an external id are never deduplicated. + assert store.grant_credits(h, 5, source="comp") is True + assert store.grant_credits(h, 5, source="comp") is True + assert store.credits_granted(h) == 510 + + +def test_only_key_hashes_are_stored(store, key): + plaintext, h = key + row = store.lookup_api_key(plaintext) + assert row["key_hash"] == h + assert plaintext not in str(row) # the secret itself is nowhere in the row + assert store.authenticate_api_key(plaintext)["key_hash"] == h + assert store.authenticate_api_key("axk_wrong") is None + + +# ─── checkout payload / client seam ────────────────────────────────── + + +def test_checkout_payload(monkeypatch): + monkeypatch.setenv("AXIOMCODE_STRIPE_PRICE_STARTER", "price_starter_123") + payload = stripe_billing.build_checkout_payload("starter", "cafe" * 16) + assert payload["mode"] == "payment" + assert payload["line_items"] == [{"price": "price_starter_123", "quantity": 1}] + assert payload["client_reference_id"] == "cafe" * 16 + assert payload["metadata"] == {"pack": "starter", "key_hash": "cafe" * 16} + with pytest.raises(ValueError): + stripe_billing.build_checkout_payload("platinum", "cafe") + monkeypatch.delenv("AXIOMCODE_STRIPE_PRICE_STARTER") + with pytest.raises(ValueError): # known pack, price not configured + stripe_billing.build_checkout_payload("starter", "cafe") + + +def test_create_checkout_session_with_fake_client(monkeypatch): + monkeypatch.setenv("AXIOMCODE_STRIPE_PRICE_GROWTH", "price_growth_9") + seen = {} + + class FakeClient: + def create_checkout_session(self, payload): + seen.update(payload) + return {"id": "cs_fake", "url": "https://checkout.stripe.test/cs_fake"} + + out = stripe_billing.create_checkout_session("growth", "beef" * 16, client=FakeClient()) + assert out["id"] == "cs_fake" + assert seen["line_items"][0]["price"] == "price_growth_9" + assert seen["metadata"]["pack"] == "growth" + + +# ─── webhook core ──────────────────────────────────────────────────── + + +def _event(event_type, obj): + return {"type": event_type, "data": {"object": obj}} + + +def _completed(key_hash, session="cs_1", pack="growth", pi="pi_1"): + return _event( + "checkout.session.completed", + { + "id": session, + "client_reference_id": key_hash, + "payment_intent": pi, + "metadata": {"pack": pack, "key_hash": key_hash}, + }, + ) + + +def _verify_with(event): + return lambda _payload, _sig: event + + +def test_webhook_grants_pack_credits_once(store, key): + _plaintext, h = key + result = stripe_billing.handle_webhook(store, b"{}", "sig", _verify_with(_completed(h))) + assert result["handled"] is True + assert result["granted"] is True + assert result["credits"] == 5000 # growth pack + assert store.balance_for(h) == 5000 + + replay = stripe_billing.handle_webhook(store, b"{}", "sig", _verify_with(_completed(h))) + assert replay["handled"] is True + assert replay["granted"] is False # idempotent: redelivery grants nothing + assert store.balance_for(h) == 5000 + + +def test_webhook_refund_reverses_the_grant(store, key): + _plaintext, h = key + stripe_billing.handle_webhook(store, b"{}", "sig", _verify_with(_completed(h))) + assert store.balance_for(h) == 5000 + + refund = _event("charge.refunded", {"id": "ch_1", "payment_intent": "pi_1"}) + result = stripe_billing.handle_webhook(store, b"{}", "sig", _verify_with(refund)) + assert result["handled"] is True + assert result["reversed"] is True + assert store.balance_for(h) == 0 + + replay = stripe_billing.handle_webhook(store, b"{}", "sig", _verify_with(refund)) + assert replay["reversed"] is False # a refund also lands exactly once + assert store.balance_for(h) == 0 + + +def test_webhook_unknown_refund_and_event(store, key): + _plaintext, _h = key + refund = _event("charge.refunded", {"id": "ch_9", "payment_intent": "pi_nope"}) + result = stripe_billing.handle_webhook(store, b"{}", "sig", _verify_with(refund)) + assert result["handled"] is False + other = _event("invoice.paid", {"id": "in_1"}) + assert stripe_billing.handle_webhook(store, b"{}", "sig", _verify_with(other))["handled"] is False + + +def test_webhook_bad_signature_propagates(store): + def bad(_payload, _sig): + raise ValueError("signature mismatch") + + with pytest.raises(ValueError): + stripe_billing.handle_webhook(store, b"{}", "sig", bad) + + +def test_webhook_route_501_until_stripe_configured(): + if importlib.util.find_spec("stripe") is not None: + pytest.skip("stripe installed here; the deploy-config path is covered at deploy") + c = TestClient(verify_api.app, raise_server_exceptions=False) + r = c.post("/billing/webhook", content=b"{}", headers={"Stripe-Signature": "t=1,v1=x"}) + assert r.status_code == 501 + assert r.json()["error"] == "stripe_not_configured" diff --git a/services/api/tests/test_hardening.py b/services/api/tests/test_hardening.py new file mode 100644 index 0000000..e7fef96 --- /dev/null +++ b/services/api/tests/test_hardening.py @@ -0,0 +1,191 @@ +"""PR #21 hardening regressions + W3 service-level behaviour: +CA-fixed fields win in issue(); kid must match the verifying key; +the audit chain binds event_type and ts; per-key proof concurrency; +MCP trial flow + credential-bound identity.""" + +import inspect +import sqlite3 +import threading + +import ca +import limits +import mcp_server +import pytest +import verify_service +from store import Store, hash_api_key + +SUBJECT = { + "code_sha256": "ab" * 32, + "verified": True, + "attempts": 1, + "toolchain": "leanprover/lean4:v4.35.0-rc3", + "key_id": "test-caller", +} +CODE = "theorem t : True := trivial" + + +@pytest.fixture() +def authority(tmp_path): + return ca.CA.load_or_create(tmp_path / "ca_key.pem") + + +# ─── CA hardening ──────────────────────────────────────────────────── + + +def test_issue_fixed_fields_win_over_subject(authority): + forged = dict( + SUBJECT, issuer="Mallory CA", serial="forged-serial", kid="0" * 16, alg="none", version=99, valid_until=1 + ) + cert = authority.issue(forged) + assert cert["issuer"] == "AxiomCode CA" + assert cert["serial"] != "forged-serial" + assert cert["kid"] == authority.kid + assert cert["alg"] == "Ed25519" + assert cert["version"] == 2 + assert cert["valid_until"] - cert["valid_from"] == 90 * 86400 + # ...and the resulting cert still verifies cleanly. + assert ca.verify_certificate(cert, ca=authority)["valid"] is True + + +def test_cert_naming_another_kid_is_rejected(tmp_path): + ca_a = ca.CA.load_or_create(tmp_path / "a.pem") + ca_b = ca.CA.load_or_create(tmp_path / "b.pem") + # B signs a body that claims A's kid: signature is genuinely B's. + body = {k: v for k, v in ca_b.issue(dict(SUBJECT)).items() if k != "signature"} + body["kid"] = ca_a.kid + forged = {**body, "signature": ca_b.sign_body(body)} + result = ca.verify_certificate(forged, public_key=ca_b.public_key) + assert result == {"valid": False, "reason": "unknown_kid"} + # Against A's key the same cert fails the signature outright. + assert ca.verify_certificate(forged, public_key=ca_a.public_key)["valid"] is False + + +# ─── audit chain binds event_type + ts (format v2) ─────────────────── + + +def test_chain_detects_event_type_tamper(tmp_path): + db = tmp_path / "tamper-type.db" + s = Store(db) + s.append_audit("verification", {"serial": "s1", "valid": True}) + s.append_audit("verification", {"serial": "s2", "valid": True}) + assert s.verify_chain()["ok"] is True + s.close() + + raw = sqlite3.connect(db) + raw.execute("UPDATE audit_log SET event_type = 'issuance' WHERE seq = 1") + raw.commit() + raw.close() + + reopened = Store(db) + result = reopened.verify_chain() + assert result == {"ok": False, "broken_seq": 1} + reopened.close() + + +def test_chain_detects_timestamp_tamper(tmp_path): + db = tmp_path / "tamper-ts.db" + s = Store(db) + s.append_audit("verification", {"serial": "s1", "valid": True}) + s.append_audit("verification", {"serial": "s2", "valid": True}) + s.close() + + raw = sqlite3.connect(db) + raw.execute("UPDATE audit_log SET ts = ts + 3600 WHERE seq = 2") + raw.commit() + raw.close() + + reopened = Store(db) + assert reopened.verify_chain() == {"ok": False, "broken_seq": 2} + reopened.close() + + +# ─── concurrency gate ──────────────────────────────────────────────── + + +def test_concurrency_gate_unit(): + gate = limits.ConcurrencyGate(2) + assert gate.acquire("k") is True + assert gate.acquire("k") is True + assert gate.acquire("k") is False # at cap for this key + assert gate.acquire("other") is True # ...but not for others + gate.release("k") + assert gate.acquire("k") is True + + +def test_service_rejects_second_concurrent_proof(tmp_path): + store = Store(tmp_path / "conc.db") + authority = ca.CA.load_or_create(tmp_path / "ca.pem") + plaintext = store.create_api_key("conc") + key_hash = hash_api_key(plaintext) + store.grant_credits(key_hash, 1000, source="test") + + started = threading.Event() + release = threading.Event() + + def blocking_search(writer, proj, code, *args, **kwargs): + writer(code) + started.set() + release.wait(10) + return True, code, 1, "blocked stub" + + gate = limits.ConcurrencyGate(1) + kwargs = dict( + store=store, + authority=authority, + key_hash=key_hash, + code=CODE, + max_attempts=1, + work_root=str(tmp_path), + prefix="ax-test-", + toolchain="tc", + search=blocking_search, + gate=gate, + ) + results = [] + t = threading.Thread(target=lambda: results.append(verify_service.execute_verification(**kwargs))) + t.start() + try: + assert started.wait(5) + with pytest.raises(verify_service.TooManyConcurrentError): + verify_service.execute_verification(**kwargs) + finally: + release.set() + t.join(10) + assert results[0]["certificate"] is not None + store.close() + + +# ─── MCP: trial flow + identity cannot be supplied ────────────────── + + +def _unwrap(tool): + return getattr(tool, "fn", tool) + + +def test_mcp_trial_flow(monkeypatch): + def fake_search(writer, proj, code, *args, **kwargs): + writer(code) + return True, code, 1, "stubbed prover log" + + monkeypatch.setattr(mcp_server, "iterative_proof_search", fake_search) + plaintext = mcp_server.STORE.create_api_key("mcp-trial") + key_hash = hash_api_key(plaintext) + mcp_server._KEY.key_hash = key_hash + + verify_fn = _unwrap(mcp_server.verify) + out = verify_fn(code=CODE) + assert out["verified"] is True + assert out["certificate"] is None # trial: verdict, no certificate + assert out["trial"]["cases_used"] == 1 + assert mcp_server.STORE.trial_cases_used(key_hash) == 1 + + # The tools take identity from the credential context only: no + # parameter lets a caller name another key. + params = inspect.signature(verify_fn).parameters + assert "key_id" not in params and "key_hash" not in params + + # Grant credits -> the same tool now issues certificates. + mcp_server.STORE.grant_credits(key_hash, 1000, source="test") + out = verify_fn(code=CODE) + assert out["certificate"] is not None + assert out["certificate"]["key_id"] == key_hash diff --git a/services/api/tests/test_wiring.py b/services/api/tests/test_wiring.py index 419c76f..588c744 100644 --- a/services/api/tests/test_wiring.py +++ b/services/api/tests/test_wiring.py @@ -10,6 +10,7 @@ import pytest import verify_api from fastapi.testclient import TestClient +from store import hash_api_key CODE = "theorem t : True := trivial" @@ -26,18 +27,29 @@ def client(monkeypatch): return TestClient(verify_api.app) +def _paid_key(store, label): + """A provisioned key holding credits (the paid path: certs issue).""" + plaintext = store.create_api_key(label) + key_hash = hash_api_key(plaintext) + store.grant_credits(key_hash, 1000, source="test-fixture") + return plaintext, key_hash + + def _work_root_leftovers(prefix): root = Path(os.environ["AXIOMCODE_WORK_ROOT"]) return [p.name for p in root.iterdir() if p.name.startswith(prefix)] def test_rest_end_to_end(client): - r = client.post("/verify", json={"code": CODE}) + plaintext, key_hash = _paid_key(verify_api.STORE, "wiring-rest") + auth = {"X-Api-Key": plaintext} + r = client.post("/verify", json={"code": CODE}, headers=auth) assert r.status_code == 200 body = r.json() assert body["verified"] is True cert = body["certificate"] assert cert["alg"] == "Ed25519" and cert["version"] == 2 + assert cert["key_id"] == key_hash # temp Lean project cleaned up by the finally block assert _work_root_leftovers("ax-api-") == [] @@ -55,16 +67,16 @@ def test_rest_end_to_end(client): assert ca.verify_certificate(cert, public_key=entry["public_key"])["valid"] is True # check endpoint: valid, then revoked after operator revocation - ok = client.post("/check", json={"certificate": cert}) + ok = client.post("/check", json={"certificate": cert}, headers=auth) assert ok.json()["valid"] is True verify_api.STORE.revoke(cert["serial"], reason="test revocation") - revoked = client.post("/check", json={"certificate": cert}) + revoked = client.post("/check", json={"certificate": cert}, headers=auth) assert revoked.json() == {"valid": False, "reason": "revoked", "payload": cert} listing = client.get("/revoked") assert cert["serial"] in listing.json()["revoked"] # metering + audit landed in the store; chain intact - assert verify_api.STORE.usage_for("rest-anonymous")["calls"] >= 1 + assert verify_api.STORE.usage_for(key_hash)["calls"] >= 1 chain = verify_api.STORE.verify_chain() assert chain["ok"] is True types = [e["event_type"] for e in verify_api.STORE.audit_events()] @@ -78,14 +90,15 @@ def _unwrap(tool): def test_mcp_tools_use_ed25519_and_store(monkeypatch): monkeypatch.setattr(mcp_server, "iterative_proof_search", _fake_search) - mcp_server._KEY.id = "mcp-test-key" + _plaintext, key_hash = _paid_key(mcp_server.STORE, "wiring-mcp") + mcp_server._KEY.key_hash = key_hash verify_fn = _unwrap(mcp_server.verify) out = verify_fn(code=CODE) cert = out["certificate"] assert out["verified"] is True assert cert["alg"] == "Ed25519" - assert cert["key_id"] == "mcp-test-key" + assert cert["key_id"] == key_hash assert _work_root_leftovers("ax-mcp-") == [] assert mcp_server.STORE.get_cert(cert["serial"]) == cert @@ -94,7 +107,7 @@ def test_mcp_tools_use_ed25519_and_store(monkeypatch): usage_fn = _unwrap(mcp_server.usage_report) report = usage_fn() - assert report["key_id"] == "mcp-test-key" + assert report["key_id"] == key_hash assert report["calls"] >= 1 assert mcp_server.STORE.verify_chain()["ok"] is True diff --git a/services/api/verify_api.py b/services/api/verify_api.py index 40df951..2f7108f 100644 --- a/services/api/verify_api.py +++ b/services/api/verify_api.py @@ -2,36 +2,45 @@ POST /verify {code} -> {verified, attempts, certificate, usage} POST /check {certificate} -> {valid, ...} -GET /revoked -> {issuer, revoked: [serial...]} +GET /revoked -> {issuer, revoked: [serial...]} (public CRL) GET /.well-known/axiomcode-keys.json -> published CA public keys +GET /health -> {status: ok} +POST /billing/webhook -> Stripe webhook (credits granted here) + +Access (W3): /verify and /check require an API key (X-Api-Key header +or Authorization: Bearer). Identity comes ONLY from the credential — +the key's SHA-256 hash is the caller identity for metering, trial +counting and limits; request bodies carry no identity at all. A new +key's first 10 verification cases are a free trial: verdict + +transcript, NO certificate. Certificates unlock once the key holds +paid credits (packs via Stripe, W4). Per-key rate limit + proof +concurrency cap apply (limits.py); submission size and proof +wall-clock are capped (W5). Certificates are Ed25519-signed attestations (ca.py, format v2) binding: code hash + verified verdict + toolchain version + validity window. Anyone holding the cert and the published public key can confirm *what* was checked and *what* the machine said — without trusting the server's word for it. All state (certificates, -revocations, meter, audit) lives in the durable store (store.py); -issuance / revocation / verification events land in its hash-chained -audit log. - -Temp Lean projects are created under AXIOMCODE_WORK_ROOT (default: -system temp dir) and always removed after the verification finishes. +revocations, meter, grants, audit) lives in the durable store +(store.py); issuance / revocation / verification events land in its +hash-chained audit log. """ -import hashlib +import json import os -import shutil import sys import tempfile -import time -from pathlib import Path sys.path.insert(0, "/home/hatch/workspace/axiomcode/repo") sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import ca -from billing import meter -from fastapi import FastAPI +import limits +import stripe_billing +import verify_service +from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse from pydantic import BaseModel from store import Store @@ -39,9 +48,14 @@ TOOLCHAIN = "leanprover/lean4:v4.35.0-rc3" WORK_ROOT = os.environ.get("AXIOMCODE_WORK_ROOT", tempfile.gettempdir()) +MAX_CODE_BYTES = int(os.environ.get("AXIOMCODE_MAX_CODE_BYTES", "262144")) +TRIAL_CASES = verify_service.TRIAL_CASES +PROOF_TIMEOUT_S = verify_service.PROOF_TIMEOUT_S CA = ca.CA.load_or_create() STORE = Store() +LIMITER = limits.RateLimiter() +GATE = limits.ConcurrencyGate() app = FastAPI( title="AxiomCode Verify", @@ -59,58 +73,87 @@ class CheckRequest(BaseModel): certificate: dict +def require_api_key(request: Request) -> str: + """Authenticate the caller; return the key's hash (its identity). + Missing, unknown or revoked keys get a 401.""" + presented = request.headers.get("x-api-key", "") + if not presented: + auth = request.headers.get("authorization", "") + presented = auth[7:] if auth.lower().startswith("bearer ") else "" + row = STORE.authenticate_api_key(presented) if presented else None + if row is None: + raise HTTPException(status_code=401, detail="invalid or missing API key") + return str(row["key_hash"]) + + +def _rate_limited(key_hash: str) -> JSONResponse | None: + if LIMITER.allow(key_hash): + return None + return JSONResponse( + status_code=429, + content={"error": "rate_limited", "detail": f"over {LIMITER.limit} requests per minute for this key"}, + ) + + +@app.get("/health") +def health(): + return {"status": "ok"} + + @app.post("/verify") -def verify(req: VerifyRequest): - proj = Path(tempfile.mkdtemp(prefix="ax-api-", dir=WORK_ROOT)) - try: - (proj / "lean-toolchain").write_text(TOOLCHAIN) - (proj / "lakefile.lean").write_text( - "import Lake\nopen Lake DSL\npackage v where\nlean_lib V where\n roots := #[`V]\n" +def verify(req: VerifyRequest, key_hash: str = Depends(require_api_key)): + if len(req.code.encode()) > MAX_CODE_BYTES: + return JSONResponse( + status_code=413, + content={"error": "code_too_large", "max_bytes": MAX_CODE_BYTES}, ) - src = proj / "V.lean" - - def writer(code: str) -> Path: - src.write_text(code) - return src - - t0 = time.time() - verified, _final_code, attempts, _log = iterative_proof_search( - writer, - proj, - req.code, - None, - "none", + limited = _rate_limited(key_hash) + if limited is not None: + return limited + try: + return verify_service.execute_verification( + store=STORE, + authority=CA, + key_hash=key_hash, + code=req.code, max_attempts=req.max_attempts, - use_pantograph=False, + work_root=WORK_ROOT, + prefix="ax-api-", + toolchain=TOOLCHAIN, + search=iterative_proof_search, + extra_subject=lambda: {"lean_version": lean_version()}, + gate=GATE, + timeout_s=PROOF_TIMEOUT_S, + trial_cases=TRIAL_CASES, + ) + except verify_service.PaymentRequiredError: + return JSONResponse( + status_code=402, + content={ + "error": "payment_required", + "detail": "trial cases exhausted and no credit balance; certificates unlock with a credit pack", + "trial": {"cases_used": TRIAL_CASES, "cases_left": 0, "certificates": "unlock with a credit pack"}, + }, + ) + except verify_service.TooManyConcurrentError: + return JSONResponse( + status_code=429, + content={"error": "too_many_concurrent_proofs", "detail": f"at most {GATE.limit} proofs in flight per key"}, + ) + except verify_service.ProofTimeoutError: + return JSONResponse( + status_code=504, + content={"error": "proof_timeout", "timeout_s": PROOF_TIMEOUT_S}, ) - compute_s = time.time() - t0 - finally: - shutil.rmtree(proj, ignore_errors=True) - - code_sha = hashlib.sha256(req.code.encode()).hexdigest() - # REST callers are metered under a per-request key; key auth lives on MCP. - usage = meter(STORE, "rest-anonymous", code_sha, verified, compute_s) - cert = CA.issue( - { - "code_sha256": code_sha, - "verified": verified, - "attempts": attempts, - "toolchain": TOOLCHAIN, - "lean_version": lean_version(), - "key_id": "rest-anonymous", - } - ) - STORE.record_cert(cert) - return { - "verified": verified, - "attempts": attempts, - "certificate": cert, - "usage": usage, - } @app.post("/check") -def check(req: CheckRequest): +def check(req: CheckRequest, key_hash: str = Depends(require_api_key)): + # A read of an existing certificate: authenticated + rate limited, + # but consumes no trial case and no credits. + limited = _rate_limited(key_hash) + if limited is not None: + return limited result = ca.verify_certificate(req.certificate, ca=CA) serial = req.certificate.get("serial") if isinstance(req.certificate, dict) else None if result.get("valid") and serial and STORE.is_revoked(serial): @@ -135,3 +178,33 @@ def revoked(): @app.get("/.well-known/axiomcode-keys.json") def ca_public_keys(): return CA.public_keys_payload() + + +@app.post("/billing/webhook") +async def billing_webhook(request: Request): + """Stripe webhook: grants pack credits on checkout completion + and reverses them on refund (stripe_billing.handle_webhook, + idempotent on Stripe ids). Needs the stripe package and + STRIPE_WEBHOOK_SECRET in the deploy environment; without them + the route answers 501 rather than accepting unverifiable events.""" + payload = await request.body() + signature = request.headers.get("stripe-signature", "") + secret = os.environ.get("STRIPE_WEBHOOK_SECRET", "") + try: + import stripe + except ImportError: + print("billing_webhook: stripe package not installed — webhook disabled", flush=True) + return JSONResponse(status_code=501, content={"error": "stripe_not_configured"}) + if not secret: + print("billing_webhook: STRIPE_WEBHOOK_SECRET not set — webhook disabled", flush=True) + return JSONResponse(status_code=501, content={"error": "stripe_not_configured"}) + + def verify_signature(raw: bytes, sig: str) -> dict: + event = stripe.Webhook.construct_event(raw, sig, secret) + parsed: dict = json.loads(json.dumps(event)) + return parsed + + try: + return stripe_billing.handle_webhook(STORE, payload, signature, verify_signature) + except Exception: # bad signature or malformed event — not from Stripe as-is + return JSONResponse(status_code=400, content={"error": "invalid_signature_or_payload"}) diff --git a/services/api/verify_service.py b/services/api/verify_service.py new file mode 100644 index 0000000..ea33ab0 --- /dev/null +++ b/services/api/verify_service.py @@ -0,0 +1,185 @@ +"""AxiomCode verification service (W3 + W5) — the one verify flow +both servers (REST verify_api, MCP mcp_server) run, so trial rules, +metering and isolation cannot drift between interfaces. + +execute_verification(): + 1. Look at the key's balance (store.balance_for) and trial cases + used (store.trial_cases_used). + 2. Balance > 0 -> paid case: run proof, meter the call, + issue + record the certificate. + Trial cases left -> trial case: run proof, record a free + zero-credit trial meter event, return + verdict + attempts with certificate + None and a trial summary. NO cert is + recorded. + Neither -> PaymentRequiredError, raised BEFORE the + proof runs (no free compute). + 3. The proof itself runs under the per-key ConcurrencyGate and a + wall-clock timeout (run_proof). + +Caller identity is the API key's SHA-256 hash everywhere (metering, +certificate key_id, limits) — never a caller-supplied string. + +W5 isolation: every proof gets a fresh temp project under +AXIOMCODE_WORK_ROOT, removed in a finally block (including on the +timeout path). The timeout is a client-facing deadline enforced with +a worker thread: Python threads cannot be killed, so on timeout the +response goes out and the orphaned prover thread finishes in the +background against a project dir that is already being removed — +its writes start failing and it exits. No state outside the store +(lock-guarded) is shared between concurrent jobs. +""" + +import hashlib +import os +import shutil +import tempfile +import time +from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import TimeoutError as FutureTimeout +from pathlib import Path + +import billing +from limits import ConcurrencyGate + +TRIAL_CASES = int(os.environ.get("AXIOMCODE_TRIAL_CASES", "10")) +PROOF_TIMEOUT_S = float(os.environ.get("AXIOMCODE_PROOF_TIMEOUT_S", "600")) + +_LAKEFILE = "import Lake\nopen Lake DSL\npackage v where\nlean_lib V where\n roots := #[`V]\n" + + +class PaymentRequiredError(Exception): + """Trial exhausted and no credit balance — refuse before proving.""" + + def __init__(self, key_hash: str, trial_cases: int): + super().__init__("payment required: trial cases exhausted and no credit balance") + self.key_hash = key_hash + self.trial_cases = trial_cases + + +class ProofTimeoutError(Exception): + """The proof exceeded its wall-clock budget.""" + + +class TooManyConcurrentError(Exception): + """The key already has its maximum proofs in flight.""" + + +def run_proof(code, max_attempts, *, work_root, prefix, toolchain, timeout_s, search): + """Run the Lean prover in a fresh temp project with a deadline. + + Returns (verified, attempts, compute_s). Raises ProofTimeoutError. + The project dir is always removed, timeout path included.""" + proj = Path(tempfile.mkdtemp(prefix=prefix, dir=work_root)) + try: + (proj / "lean-toolchain").write_text(toolchain) + (proj / "lakefile.lean").write_text(_LAKEFILE) + src = proj / "V.lean" + + def writer(c: str) -> Path: + src.write_text(c) + return Path(src) + + executor = ThreadPoolExecutor(max_workers=1) + try: + t0 = time.time() + future = executor.submit( + search, + writer, + proj, + code, + None, + "none", + max_attempts=max_attempts, + use_pantograph=False, + ) + try: + verified, _final_code, attempts, _log = future.result(timeout=timeout_s) + except FutureTimeout: + raise ProofTimeoutError(f"proof exceeded {timeout_s:g}s") from None + return verified, attempts, time.time() - t0 + finally: + # Never wait for an orphaned prover thread on the timeout + # path; it dies against the removed project dir. + executor.shutdown(wait=False) + finally: + shutil.rmtree(proj, ignore_errors=True) + + +def execute_verification( + *, + store, + authority, + key_hash, + code, + max_attempts, + work_root, + prefix, + toolchain, + search, + extra_subject=None, + gate: ConcurrencyGate | None = None, + timeout_s: float = PROOF_TIMEOUT_S, + trial_cases: int = TRIAL_CASES, +) -> dict: + """Run one verification case for an authenticated key. + + Returns the response dict (REST shape): + paid: {verified, attempts, certificate, usage} + trial: {verified, attempts, certificate: None, usage, trial: + {cases_used, cases_left, + certificates: "unlock with a credit pack"}} + Raises PaymentRequiredError (before proving), ProofTimeoutError, + TooManyConcurrentError.""" + balance = store.balance_for(key_hash) + used = store.trial_cases_used(key_hash) + paid = balance > 0 + if not paid and used >= trial_cases: + raise PaymentRequiredError(key_hash, trial_cases) + + if gate is not None and not gate.acquire(key_hash): + raise TooManyConcurrentError(key_hash) + try: + verified, attempts, compute_s = run_proof( + code, + max_attempts, + work_root=work_root, + prefix=prefix, + toolchain=toolchain, + timeout_s=timeout_s, + search=search, + ) + finally: + if gate is not None: + gate.release(key_hash) + + code_sha = hashlib.sha256(code.encode()).hexdigest() + if paid: + usage = billing.meter(store, key_hash, code_sha, verified, compute_s) + subject = { + "code_sha256": code_sha, + "verified": verified, + "attempts": attempts, + "toolchain": toolchain, + "key_id": key_hash, + } + if extra_subject: + resolved = extra_subject() if callable(extra_subject) else extra_subject + subject.update(resolved) + cert = authority.issue(subject) + store.record_cert(cert) + return {"verified": verified, "attempts": attempts, "certificate": cert, "usage": usage} + + usage = billing.meter(store, key_hash, code_sha, verified, compute_s, trial=True) + used += 1 + return { + "verified": verified, + "attempts": attempts, + "certificate": None, + "usage": usage, + "trial": { + "cases_used": used, + "cases_left": max(trial_cases - used, 0), + "certificates": "unlock with a credit pack", + }, + }