feat(CPL-285): allow Stripe billing in ChainSecured mode with SIWE auth#327
Conversation
Restores the Add Funds button for ChainSecured accounts and closes the
trust-boundary gap that the wallet-hash-as-bearer approach would have
left open.
Server:
- BillingAuth request guard accepts either an API key (legacy) or a
SIWE-style EIP-191 signed message via X-Wallet-Auth header. The
signature proves the caller holds the wallet's private key.
- accounts::get_account_wallet_address and stripe::cache_key now flow
through usage_api_key_to_hash so a 0x-prefixed 32-byte hex hash
passes through unhashed — both forms collapse to the same on-chain
account / Stripe customer.
- New SIWE_PURPOSE_* tags pin each signature flow (billing-auth,
create-wallet, convert-account). Cross-flow replay is now blocked:
a billing signature can no longer be presented to
/create_wallet_with_signature and vice versa.
- API key issuance rejects strings shaped like 0x[hex]{64} so a future
format change can't collide with the precomputed-hash detection.
- BillingAuth falls through to the API-key path on a malformed
X-Wallet-Auth header (junk proxies, stale storage); only verified-
but-rejected signatures return 401.
Frontend:
- Mode-branched billing auth (no || fallback) so a stale STORAGE_KEY_API
from a prior session can never override the wallet hash.
- SIWE prompt cached for 4 minutes (server allows 5) so users sign once
per billing session.
- AbortController on logout/wallet-switch aborts in-flight billing.
- Post-await re-checks across loadBillingBalance and the pay flow.
- Cache eviction only on HTTP 401/400 — network/5xx don't trigger
signature-prompt fatigue.
Tests: 206 passing (was 197 before this branch). New coverage for
is_precomputed_hash_shape, cache_key cross-form collision,
BillingAuth::identity_string, and encode_api_key_from_secret.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Updates the X-Api-Key parameter description on the three /billing/* endpoints to mention the new X-Wallet-Auth alternative. Generated by openapi-to-k6 — no behavioral changes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Documents the X-Wallet-Auth header, the 4-minute signature cache, and the Purpose-pinning that prevents cross-flow replay so future contributors understand the security envelope without having to reverse-engineer it from billing.js. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Enables Stripe billing flows for ChainSecured (sovereign-mode) dashboard sessions by adding SIWE-lite wallet-signature authentication for /billing/*, while preserving legacy API-key billing support and improving frontend session-safety (abort + auth-snapshot checks).
Changes:
- Added a
BillingAuthRocket request guard that accepts either legacy API keys orX-Wallet-Auth(base64 JSON message+signature) for billing endpoints. - Updated billing-related identity hashing/caching to accept precomputed 32-byte
0x…hashes (wallet-derived identity) in addition to raw API keys. - Updated the dashboard billing UI + JS SDK to send
X-Wallet-Auth, cache signatures briefly, and abort/ignore in-flight requests on session changes.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| lit-static/dapps/dashboard/billing.js | Adds SIWE-lite wallet auth header generation/caching, AbortController session aborts, and post-await identity re-checks for billing requests. |
| lit-static/dapps/dashboard/auth.js | Clears/aborts billing session state on API key changes, ChainSecured session clears, and logout. |
| lit-static/core_sdk.js | Surfaces HTTP status on errors and adds billing request options (walletAuthHeader, signal) + billing header selection helper. |
| lit-api-server/src/utils/parse_with_hash.rs | Adds is_precomputed_hash_shape and uses it to pass through precomputed hashes in usage_api_key_to_hash. |
| lit-api-server/src/stripe.rs | Updates Stripe cache key derivation to accept precomputed hashes; adds tests for hash/raw collapse. |
| lit-api-server/src/core/v1/guards/mod.rs | Exposes the new billing_auth guard module. |
| lit-api-server/src/core/v1/guards/billing_auth.rs | New request guard implementing API-key or wallet-signed auth for billing endpoints + OpenAPI hook + unit tests. |
| lit-api-server/src/core/v1/endpoints/billing.rs | Switches /billing/* endpoints from ApiKey guard to BillingAuth. |
| lit-api-server/src/core/account_management.rs | Adds SIWE Purpose: pinning constants, central SIWE signature verification helper, and API key issuance invariant guard. |
| lit-api-server/src/accounts/mod.rs | Updates wallet-address resolution to accept either raw API key or precomputed hash via usage_api_key_to_hash. |
| k6/litApiServer.ts | Regenerated OpenAPI-derived typings/comments reflecting the new billing auth option. |
Comments suppressed due to low confidence (1)
lit-api-server/src/stripe.rs:238
cache_key()/resolve_wallet_address()now accept a precomputed0x…32-byte hash viausage_api_key_to_hash. That means any caller that authenticates with a raw API key string (e.g.X-Api-Key) can instead supply the public ChainSecured identity hashkeccak256(walletAddress)and be treated as that account. This reintroduces the “wallet-hash-as-bearer” problem for any endpoint/guard that ultimately callsstripe::resolve_wallet_address(not just/billing/*; e.g. the lit-action billing guard also uses it).
To keep SIWE as the trust boundary, consider separating the concepts:
- keep the legacy path strictly
keccak256(raw_api_key_string)(never pass-through), and - add an explicit API that takes an already-hashed
U256identity for the wallet-signed flow (or rejectis_precomputed_hash_shapeinputs on the API-key path).
/// Compute a non-sensitive cache key from an account identity string.
///
/// Accepts either a raw API key (hashed via keccak256) or a precomputed
/// 0x-prefixed 32-byte hex hash (used by ChainSecured callers, whose on-chain
/// identity is `keccak256(walletAddress)`). Both forms collapse to the same
/// U256 cache key, so a raw key and its hash share a cache entry.
///
/// Using the hash means no secret material is held in the cache's key set —
/// avoids leaking raw API keys via memory dumps, debug tooling, or telemetry.
fn cache_key(key_or_hash: &str) -> String {
crate::utils::parse_with_hash::usage_api_key_to_hash(key_or_hash).to_string()
}
/// Remove an API key from the wallet address cache.
///
/// Call this when a usage API key is deleted so that stale mappings are not served.
pub async fn invalidate_wallet_cache(api_key: &str, state: &StripeState) {
state.wallet_cache.invalidate(&cache_key(api_key)).await;
}
/// Resolve any account identity to its admin wallet address.
///
/// Accepts a raw API key (master or usage) or — for ChainSecured callers — a
/// precomputed 0x-prefixed 32-byte hex hash (the wallet-derived
/// `keccak256(walletAddress)`). Uses the on-chain `allApiKeyHashesToMaster`
/// mapping so that usage API keys resolve to the same wallet (and therefore
/// same Stripe customer) as their parent account key. Results are cached for
/// 1 hour.
///
/// The cache is keyed by the keccak256 hash of the input (not the raw key)
/// to avoid holding secret material in memory.
#[instrument(name = "stripe::resolve_wallet_address", skip_all, err)]
pub async fn resolve_wallet_address(api_key: &str, state: &StripeState) -> Result<String> {
let key = cache_key(api_key);
tracing::debug!("stripe::resolve_wallet_address: looking up wallet");
let result = state
.wallet_cache
.try_get_with(key, async {
tracing::debug!("stripe::resolve_wallet_address: cache miss, calling contract");
crate::accounts::get_account_wallet_address(api_key).await
})
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…auth The `X-Wallet-Auth: <…>` example was on its own line indented 6 spaces under the numbered list item, which Rust 1.91's doc_overindented_list_items lint treats as either a code block or lazy continuation. Inlining the header example into the list-item paragraph keeps the explanation intact and stays aligned with the 4-space continuation that the rest of the docstring uses. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…back) Addresses Copilot review on PR #327. Security (comments 6, 7): - BillingAuth's API-key path now rejects strings shaped like a precomputed account hash (`is_precomputed_hash_shape`). Without this, the new hash-passthrough in usage_api_key_to_hash let an attacker send `X-Api-Key: 0x{keccak256(walletAddress)}` and bypass SIWE entirely — precomputed hashes must come through the verified WalletSigned branch only. Added a regression test pinning that decision. UX (comments 4, 5): - refreshBillingUI no longer auto-fires loadBillingBalance in sovereign mode unless a valid SIWE cache already exists. Previously a non-empty textContent (from a prior render or post-payment refresh) would silently pop a wallet-sign prompt every time the auth UI updated. - openAddFundsModal now primes the SIWE cache up front when the user explicitly opts in by clicking Add Funds, matching the comment that promised "user signs once when they click Add Funds." Cancellation is surfaced in the modal status with a retry path. DX (comments 1, 2, 3, 8): - BillingAuth's OpenApiFromRequest now emits X-Api-Key as optional and documents X-Wallet-Auth in the description. k6 codegen now produces `X-Api-Key?: string` and `headers?: BillingBalanceHeaders`, so ChainSecured callers can compile-time omit the API key. rocket_okapi 0.9 doesn't support emitting two alternative parameters from one guard, so the description carries the X-Wallet-Auth contract. Tests: 207 passing (added precomputed_hash_strings_are_recognized). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Thanks @copilot-pull-request-reviewer — eight comments addressed in ef914af. Per-thread replies on each one. Quick summary: Security (comments 6, 7) — real bypass, now closed. UX (comments 4, 5) — wallet-popup hygiene. DX (comments 1, 2, 3, 8) — OpenAPI type contract. Tests: 207 passing (was 206). cargo fmt + clippy clean. k6 client regenerated. |
|
@GTC6244 I'm unable to start working on this because of repository rules that prevent me from pushing to the branch:
See the documentation for more details. |
…pose tags CPL-285 (#327) shipped to next while this PR was open. It introduced two overlapping changes: - SIWE-lite domain separation via a `Purpose:` line + per-flow `SIWE_PURPOSE_*` constants (cross-flow replay prevention) — the same follow-up I flagged on this PR. - `encode_api_key_from_secret()` defense-in-depth that rejects base64-encoded secrets shaped like a precomputed account hash, preventing a confused-deputy collision with `usage_api_key_to_hash`. Resolution merges CPL-285's primitives with CPL-284's hardening into a single shared helper: - Drop my CPL-284 helper rename (verify_chainsecured_siwe / parse_chainsecured_siwe / ParsedChainsecuredSiwe). Use CPL-285's verify_siwe_signature / parse_create_wallet_siwe / ParsedCreateWalletSiwe names so all SIWE-using flows route through one symbol set. - Fold CPL-284's hardening into verify_siwe_signature: * 4 KiB MAX_SIWE_MESSAGE_LEN length cap (front-loaded reject) * Cheap rejects (parse, purpose, chain ID, timestamp) before ECDSA recovery so attacker traffic with stale or wrong-purpose messages is dropped without doing crypto * i128 timestamp delta to prevent the i64::MIN abs() bypass * Duplicate-line rejection (now also covers `Purpose:`) - Refactor create_wallet_with_signature, convert_to_chain_secured_account, and add_usage_api_key_with_signature to ALL call verify_siwe_signature (CPL-285 left create_wallet/convert inlined; this PR's Copilot review flagged the convert duplication, and the same critique applies to the create_wallet path — fixing both here matches the consistent pattern). - Add SIWE_PURPOSE_ADD_USAGE_API_KEY = "lit-add-usage-api-key-v1" for the new endpoint and pin it in the handler. - Use encode_api_key_from_secret(&secret)? instead of base64_light::base64_encode_bytes(&secret) so the new endpoint inherits CPL-285's confused-deputy guard. Docs updated: documents the `Purpose:` line as part of the shared SIWE envelope (was previously a CPL-285 doc gap), adds the per-endpoint `Purpose:` mapping table, and updates each curl example. Verified locally: - cargo fmt --all -- --check clean - cargo clippy --all-features -- -D warnings clean - cargo test --all-features (221 passed, 0 failed) - Regenerated k6 client matches OpenAPI spec byte-for-byte Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Summary
Restores the Add Funds button for ChainSecured (sovereign-mode) accounts and closes the trust-boundary gap that the wallet-hash-as-bearer approach would otherwise leave open.
Server
BillingAuthrequest guard on/billing/*endpoints accepts either an API key (legacy) or a SIWE-style EIP-191 signed message viaX-Wallet-Auth: <base64(JSON{message, signature})>. The signature proves the caller holds the wallet's private key.accounts::get_account_wallet_addressandstripe::cache_keynow flow throughusage_api_key_to_hashso a 0x-prefixed 32-byte hex hash passes through unhashed. Both forms collapse to the same on-chain account / Stripe customer.SIWE_PURPOSE_*tags pin each signature flow (lit-billing-auth-v1,lit-create-wallet-v1,lit-convert-account-v1). Cross-flow replay is now blocked: a billing signature can't be replayed against/create_wallet_with_signatureand vice versa.0x[hex]{64}so a future format change can't collide with the precomputed-hash detection.X-Wallet-Authheader now falls through to the API-key path (junk proxies, stale storage); only verified-but-rejected signatures return 401.Frontend
||fallback) so a staleSTORAGE_KEY_APIfrom a prior session can never override the wallet hash.loadBillingBalanceand the pay flow.Tests
206 unit tests passing (was 197 before this branch). New coverage:
is_precomputed_hash_shape— 7 tests covering canonical hash, prefix variants, length, hex body, base64 collision space.cache_key_accepts_precomputed_hash— proves raw key + its keccak hex collapse to the same cache entry.BillingAuth::identity_string— both variants.encode_api_key_from_secret_never_matches_hash_shape— invariant test for the issuance guard.Pre-Landing Review
Two cross-model adversarial passes (Claude subagent + Codex) ran against this diff. Findings addressed:
BillingAuth.encode_api_key_from_secretrejection at issuance.Purpose:line pinning per flow.billingAuthKey().X-Wallet-Authlocks out API-key callers → fixed by graceful fall-through.Coverage
~75% AI-assessed coverage. Documented gaps (user accepted):
verify_siwe_signatureend-to-end — covered transitively by the existing productioncreate_wallet_with_signatureandconvert_to_chain_secured_accountflows that share the same parser/recovery pipeline.Manual QA recommended before merge
The frontend SIWE flow has no automated test. Please verify in a browser:
TODOS
No TODOS.md items completed in this PR.
Test plan
cargo buildcleancargo test --lib— 206 passed, 0 failedcargo fmt --checkcleank6/litApiServer.tsregenerated to match OpenAPI🤖 Generated with Claude Code