Skip to content

Commit 01c688d

Browse files
vvillait88claude
andauthored
feat(identity): UCP profile signing helpers (1.3.7) (#14)
## Summary - Add four helpers for UCP §6 trust-mode profiles: `generate_ucp_signing_key`, `sign_ucp_profile`, `verify_ucp_profile`, `build_jwks_response`. Profiles are JWS-signed (Compact Serialization) over a JCS-canonicalized body. Both EdDSA (Ed25519, default) and ES256 supported. - Cross-language byte parity with `@agent-score/commerce` Node SDK 1.3.4 — profiles signed by either SDK verify in the other. - `joserfc` is an optional extra (`pip install agentscore-commerce[ucp]`); vendors install it only when publishing signed UCP profiles. Unsigned `build_ucp_profile` keeps working. - `/.well-known/jwks.json` added to the default discovery-path allowlist. - Bumps to **1.3.7**. ## Test plan - [x] Round-trip sign+verify for Ed25519 and ES256 - [x] Multi-key JWKS resolves by `kid` - [x] Tamper detection via byte-equal canonical-body check after JWS verify - [x] Missing key in JWKS rejects with `InvalidKeyIdError` - [x] Profile without signature rejects - [x] Key-order-independent verification (canonicalization) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent dad28a2 commit 01c688d

39 files changed

Lines changed: 4566 additions & 526 deletions

CLAUDE.md

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,15 @@ Every helper is extracted from a real consumer, not speculated.
99
| Submodule | What it is |
1010
|---|---|
1111
| `agentscore_commerce.identity.{fastapi,flask,django,aiohttp,sanic,middleware}` | Trust gate middleware (KYC, age, sanctions, jurisdiction) |
12-
| `agentscore_commerce.payment` | Networks/USDC/rails registries, paymentauth.org directive builders, `create_x402_server` (wraps `x402[evm]>=2.9` + `cdp-sdk` for `facilitator="coinbase"`; install via the `coinbase` extra), `build_x402_accepts_for_402` (build the 402's `accepts[]` from the registered scheme derives the right `extra.name` per network), `process_x402_settle` (verify+settle in one call), `create_mppx_server` (wraps `pympp[server,tempo,stripe]>=0.6`), dispatch-by-network, signer extraction, WWW-Authenticate header, Settlement-Overrides header |
12+
| `agentscore_commerce.payment` | Networks/USDC/rails registries, paymentauth.org directive builders, `create_x402_server` (wraps `x402[evm]>=2.9` + `cdp-sdk` for `facilitator="coinbase"`; install via the `coinbase` extra), `build_x402_accepts_for_402` (build the 402's `accepts[]` from the registered scheme; derives the right `extra.name` per network), `process_x402_settle` (verify+settle in one call), `create_mppx_server` (wraps `pympp[server,tempo,stripe]>=0.6`), dispatch-by-network, signer extraction, WWW-Authenticate header, Settlement-Overrides header |
1313
| `agentscore_commerce.discovery` | Discovery probe, Bazaar wrapper, `/.well-known/mpp.json`, `llms.txt` builder, `skill.md` builder (Claude-Skill-compatible agent-discovery manifest), OpenAPI snippets, `NoindexNonDiscoveryMiddleware` ASGI middleware |
1414
| `agentscore_commerce.challenge` | 402-body builders: accepted_methods, identity_metadata, how_to_pay, agent_instructions, build_402_body, `build_validation_error` (4xx body builder) |
1515
| `agentscore_commerce.stripe_multichain` | Multichain PaymentIntent helper, deposit-address lookup, testnet simulator, mppx Stripe wrapper |
1616
| `agentscore_commerce.api` | Re-exports `AgentScore` from `agentscore` SDK |
1717

1818
## Architecture
1919

20-
Single Python package, hatchling-built, published to PyPI as `agentscore-commerce`. Per-framework identity adapters expose the same surface `AgentScoreGate` (or `agentscore_gate(app, ...)` for Flask/Sanic), `capture_wallet`, `verify_wallet_signer_match`, `get_assess_data`, `get_gate_degraded_state`, `get_gate_quota_info` with network-aware address normalization (EVM lowercased, Solana base58 preserved verbatim).
20+
Single Python package, hatchling-built, published to PyPI as `agentscore-commerce`. Per-framework identity adapters expose the same surface (`AgentScoreGate`, or `agentscore_gate(app, ...)` for Flask/Sanic; `capture_wallet`, `verify_wallet_signer_match`, `get_assess_data`, `get_gate_degraded_state`, `get_gate_quota_info`) with network-aware address normalization (EVM lowercased, Solana base58 preserved verbatim).
2121

2222
| Directory | Contents |
2323
|---|---|
@@ -30,11 +30,11 @@ Single Python package, hatchling-built, published to PyPI as `agentscore-commerc
3030
| `examples/` | Runnable single-file FastAPI apps for each common scenario |
3131
| `tests/` | pytest, one file per surface |
3232

33-
Peer-dep pattern: payment/x402/mppx/stripe modules import lazily at runtimevendors install only what they use via extras (`pip install agentscore-commerce[fastapi,stripe]` etc.). Underlying packages: `x402[evm]`, `pympp[server,tempo,stripe]`, `stripe`, `cdp-sdk` (the `coinbase` extra only needed when `facilitator="coinbase"`). Missing peer dep raises a guiding `ImportError` with the install command.
33+
Peer-dep pattern: payment/x402/mppx/stripe modules import lazily at runtime; vendors install only what they use via extras (`pip install agentscore-commerce[fastapi,stripe]` etc.). Underlying packages: `x402[evm]`, `pympp[server,tempo,stripe]`, `stripe`, `cdp-sdk` (the `coinbase` extra; only needed when `facilitator="coinbase"`). Missing peer dep raises a guiding `ImportError` with the install command.
3434

3535
## Examples
3636

37-
`examples/` contains full single-file FastAPI apps for the most common merchant scenarios copy-paste templates, not frameworks:
37+
`examples/` contains full single-file FastAPI apps for the most common merchant scenarios; copy-paste templates, not frameworks:
3838

3939
| Example | Scenario |
4040
|---|---|
@@ -43,28 +43,29 @@ Peer-dep pattern: payment/x402/mppx/stripe modules import lazily at runtime —
4343
| `multi_rail_merchant.py` | Full agent-commerce: identity + Tempo MPP + x402 + Stripe SPT |
4444
| `stripe_multichain_merchant.py` | Stripe-anchored multichain (PaymentIntent → tempo/base/solana deposit addresses) |
4545
| `variable_cost_merchant.py` | Pay-per-actual-usage on **two protocols**: x402 upto (Permit2 + Settlement-Overrides) AND MPP tempo session (channel + SSE + mid-stream vouchers) |
46-
| `compliance_merchant.py` | Regulated-goods merchant full compliance gate + custom `on_denied` composing the denial helpers (`verification_agent_instructions`, `is_fixable_denial`, `build_signer_mismatch_body`, `build_contact_support_next_steps`, `denial_reason_to_body`/`denial_reason_status`) |
46+
| `compliance_merchant.py` | Regulated-goods merchant: full compliance gate + custom `on_denied` composing the denial helpers (`verification_agent_instructions`, `is_fixable_denial`, `build_signer_mismatch_body`, `build_contact_support_next_steps`, `denial_reason_to_body`/`denial_reason_status`) |
4747
| `per_product_policy_merchant.py` | Multi-product merchant where each row carries its own compliance policy. One product hard-gates KYC + age + state; another is anonymous; a third uses `enforcement="soft"` (request KYC but don't block sale). Demonstrates `PolicyBlock`, `build_gate_from_policy`, `run_gate_with_enforcement`, `shipping_country_allowed`, `shipping_state_allowed`. |
48+
| `signed_ucp_merchant.py` | Signed UCP profile (`/.well-known/ucp`) + JWKS endpoint (`/.well-known/jwks.json`). AgentScore's `agentscore-profile+jws` is a vendor extension on top of UCP for trust-mode verifiers (Visa AP2 pilots, regulated-commerce verifiers) that opt into auditable cryptographic provenance — UCP §6 itself does NOT mandate signing; Pura Vida and other Shopify-backed UCP merchants ship unsigned in production. Wires ephemeral-for-dev / env-JWK-for-prod signing, kid rotation, and `Cache-Control` posture. Uses `generate_ucp_signing_key`, `sign_ucp_profile`, `build_jwks_response`, `UCPSigningKey.from_jwk`, `UCPVerificationError`. |
4849

4950
## Identity model
5051

51-
Two identity types: wallet (`X-Wallet-Address`) and operator-token (`X-Operator-Token`). Default checks operator-token first, then wallet. Address normalization is network-aware via `agentscore_commerce/identity/address.py`: EVM lowercased, Solana base58 preserved verbatim — used for cache keys, wallet→operator resolves, and signer-match comparisons.
52+
Two identity types: wallet (`X-Wallet-Address`) and operator-token (`X-Operator-Token`). Default checks operator-token first, then wallet. Address normalization is network-aware via `agentscore_commerce/identity/address.py`: EVM lowercased, Solana base58 preserved verbatim. Used for cache keys, wallet→operator resolves, and signer-match comparisons.
5253

5354
`DenialReason` codes (`missing_identity`, `identity_verification_required`, `token_expired`, `invalid_credential`, `wallet_signer_mismatch`, `wallet_auth_requires_wallet_signing`, `wallet_not_trusted`, `api_error`, `payment_required`) each carry a structured `agent_instructions` JSON block describing concrete recovery actions. See `agentscore_commerce/identity/_response.py` for the canned action copy.
5455

5556
`create_session_on_missing` auto-mints a verification session when no identity is present and returns 403 with `verify_url` + poll instructions. `verify_wallet_signer_match` (per-adapter) compares the recovered signer against `linked_wallets[]` for cross-chain wallet-stack matching.
5657

57-
Captured wallets: `capture_wallet(...)` is fire-and-forget — reads `operator_token` stashed during gating and POSTs to `/v1/credentials/wallets`. No-ops for wallet-authenticated requests.
58+
Captured wallets: `capture_wallet(...)` is fire-and-forget. Reads `operator_token` stashed during gating and POSTs to `/v1/credentials/wallets`. No-ops for wallet-authenticated requests.
5859

59-
Wallet-signer-match: `verify_wallet_signer_match` / `averify_wallet_signer_match` makes a single `/v1/assess` call with `resolve_signer` set; the API resolves both wallets and emits a `signer_match` verdict in the same response — collapses the legacy 2 follow-up assess calls into one round trip. Repeat lookups for the same `(claimed, signer)` pair hit a per-cache-entry `signer_match_by_signer` sub-dict and skip the API entirely. Falls back to a 2-resolve path when the API doesn't emit `signer_match` (canary rollout safety).
60+
Wallet-signer-match: `verify_wallet_signer_match` / `averify_wallet_signer_match` makes a single `/v1/assess` call with `resolve_signer` set; the API resolves both wallets and emits a `signer_match` verdict in the same response, collapsing the legacy 2 follow-up assess calls into one round trip. Repeat lookups for the same `(claimed, signer)` pair hit a per-cache-entry `signer_match_by_signer` sub-dict and skip the API entirely. Falls back to a 2-resolve path when the API doesn't emit `signer_match` (canary rollout safety).
6061

6162
### Fail-open (opt-in)
6263

63-
`fail_open=True` on `AgentScoreGate(...)` (or `agentscore_gate(app, ...)`) flips infra-failure handling: 429 / 5xx / network-timeout pass through to the handler with the gate state stamped `degraded=True` + `infra_reason="quota_exceeded" | "api_error" | "network_timeout"`. `get_gate_degraded_state(request)` (Flask: `get_gate_degraded_state()`reads from `g`) returns `{"degraded": bool, "infra_reason"?: str}` for merchant logging/alerting. Default stays `fail_open=False`regulated commerce should keep it. Compliance denials (sanctions, age, jurisdiction, signer-mismatch) still deny regardless of the flag. The gate's `try` wraps only the AgentScore call never the downstream user handler.
64+
`fail_open=True` on `AgentScoreGate(...)` (or `agentscore_gate(app, ...)`) flips infra-failure handling: 429 / 5xx / network-timeout pass through to the handler with the gate state stamped `degraded=True` + `infra_reason="quota_exceeded" | "api_error" | "network_timeout"`. `get_gate_degraded_state(request)` (Flask: `get_gate_degraded_state()`, reads from `g`) returns `{"degraded": bool, "infra_reason"?: str}` for merchant logging/alerting. Default stays `fail_open=False`; regulated commerce should keep it. Compliance denials (sanctions, age, jurisdiction, signer-mismatch) still deny regardless of the flag. The gate's `try` wraps only the AgentScore call, never the downstream user handler.
6465

6566
### Mount posture: gate-first vs gate-conditional
6667

67-
`AgentScoreGate(...)` (or `agentscore_gate(app, ...)` on Flask/Sanic) is mounted directly when the route is AgentScore-onlyevery request runs identity + policy. To support **anonymous discovery by any spec-compliant x402 wallet** (Coinbase awal, Phantom, Solflare, ), wrap the gate so it fires only when a payment credential is attached:
68+
`AgentScoreGate(...)` (or `agentscore_gate(app, ...)` on Flask/Sanic) is mounted directly when the route is AgentScore-only; every request runs identity + policy. To support **anonymous discovery by any spec-compliant x402 wallet** (Coinbase awal, Phantom, Solflare, ...), wrap the gate so it fires only when a payment credential is attached:
6869

6970
```python
7071
_gate = AgentScoreGate(api_key=..., require_kyc=True, ...)
@@ -87,20 +88,20 @@ Anonymous POST flows through to the handler unauthenticated and gets a 402 with
8788

8889
### `compatible_clients` field on emitted 402s
8990

90-
`build_agent_instructions` emits a `compatible_clients` field in the 402 body, derived automatically from `how_to_pay` per-rail list of CLIs the AgentScore team has smoke-verified end-to-end. Vendors override with `BuildAgentInstructionsInput(compatible_clients={...})` to add their own tested clients. Set to an empty dict `{}` to suppress the default. Same data is published as `core/docs/integrations/x402-clients.mdx` for human-side rationale + per-rail commands.
91+
`build_agent_instructions` emits a `compatible_clients` field in the 402 body, derived automatically from `how_to_pay`: per-rail list of CLIs the AgentScore team has smoke-verified end-to-end. Vendors override with `BuildAgentInstructionsInput(compatible_clients={...})` to add their own tested clients. Set to an empty dict `{}` to suppress the default. Same data is published as `core/docs/integrations/x402-clients.mdx` for human-side rationale + per-rail commands.
9192

9293
## Tooling
9394

94-
- **uv** package manager.
95-
- **ruff** linting + formatting.
96-
- **ty** type checker (Astral).
97-
- **vulture** dead code detection.
98-
- **pytest** tests.
99-
- **Lefthook** pre-commit ruff, pre-push ty + vulture (parallel).
95+
- **uv**: package manager.
96+
- **ruff**: linting + formatting.
97+
- **ty**: type checker (Astral).
98+
- **vulture**: dead code detection.
99+
- **pytest**: tests.
100+
- **Lefthook**: pre-commit ruff, pre-push ty + vulture (parallel).
100101

101102
```bash
102103
uv sync --all-extras
103-
uv run lefthook install # one-time per clone wires pre-commit + pre-push
104+
uv run lefthook install # one-time per clone; wires pre-commit + pre-push
104105
uv run ruff check .
105106
uv run ruff format .
106107
uv run ty check agentscore_commerce/
@@ -112,16 +113,16 @@ uv run pytest tests/
112113
1. Create a branch
113114
2. Make changes
114115
3. Lefthook runs ruff on commit, ty + vulture on push
115-
4. Open a PR CI runs automatically
116+
4. Open a PR (CI runs automatically)
116117
5. Merge (squash)
117118

118119
## Rules
119120

120121
- **No silent refactors**
121122
- **Never commit .env files or secrets**
122-
- **Use PRs** never push directly to main
123+
- **Use PRs**: never push directly to main
123124
- **Helpers are protocol translations + configurable opinions, not opinionated frameworks**
124-
- **Cross-language API parity** keep the surface area identical between the node and python flavors so vendors switching languages have the same mental model
125+
- **Cross-language API parity**: keep the surface area identical between the node and python flavors so vendors switching languages have the same mental model
125126

126127
## Releasing
127128

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Thanks for your interest in contributing! Here's how to get started.
1515

1616
- All PRs require 1 approval before merging
1717
- Squash merge to `main` is the standard
18-
- Keep PRs focused one feature or fix per PR
18+
- Keep PRs focused: one feature or fix per PR
1919
- Include tests for new functionality
2020
- Make sure CI passes before requesting review
2121

0 commit comments

Comments
 (0)