Skip to content

feat(CPL-285): allow Stripe billing in ChainSecured mode with SIWE auth#327

Merged
GTC6244 merged 5 commits into
nextfrom
feature/cpl-285-allow-stripe-in-chainsecured-mode
Apr 30, 2026
Merged

feat(CPL-285): allow Stripe billing in ChainSecured mode with SIWE auth#327
GTC6244 merged 5 commits into
nextfrom
feature/cpl-285-allow-stripe-in-chainsecured-mode

Conversation

@GTC6244
Copy link
Copy Markdown
Contributor

@GTC6244 GTC6244 commented Apr 29, 2026

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

  • New BillingAuth request guard on /billing/* endpoints accepts either an API key (legacy) or a SIWE-style EIP-191 signed message via X-Wallet-Auth: <base64(JSON{message, signature})>. 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 (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_signature and vice versa.
  • API key issuance rejects values shaped like 0x[hex]{64} so a future format change can't collide with the precomputed-hash detection.
  • A malformed X-Wallet-Auth header now falls through to the API-key path (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 ±5min) so users sign once per billing session.
  • AbortController on logout / wallet-switch aborts in-flight billing fetches.
  • 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 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:

  • CRITICAL — anonymous wallet-hash-as-bearer credential → fixed by SIWE requirement on BillingAuth.
  • HIGH — confused deputy (API key shaped like a hash) → fixed by encode_api_key_from_secret rejection at issuance.
  • HIGH — cross-flow signature replay between billing-auth, create-wallet, convert-account → fixed by Purpose: line pinning per flow.
  • HIGH — mixed-mode session (stale API key wins over wallet hash) → fixed by mode-branched billingAuthKey().
  • MEDIUM — malformed X-Wallet-Auth locks out API-key callers → fixed by graceful fall-through.
  • MEDIUM — over-aggressive cache eviction → fixed by limiting eviction to HTTP 401/400.
  • MEDIUM — mid-flight session race → fixed by AbortController + post-await re-checks.

Coverage

~75% AI-assessed coverage. Documented gaps (user accepted):

  • verify_siwe_signature end-to-end — covered transitively by the existing production create_wallet_with_signature and convert_to_chain_secured_account flows that share the same parser/recovery pipeline.
  • Rocket endpoint integration tests — no existing test infrastructure in the repo for HTTP-level tests.
  • Frontend SIWE flow — no JS test framework configured.

Manual QA recommended before merge

The frontend SIWE flow has no automated test. Please verify in a browser:

  1. ChainSecured user clicks Add Funds → wallet popup with SIWE message → sign → modal opens.
  2. Same user clicks Add Funds again within ~4 minutes → no second wallet popup (cached signature).
  3. After 5+ minutes → second wallet popup (cache expired or server rejected).
  4. API-mode user adds funds → no wallet popup (regression check, X-Api-Key path).
  5. API-mode → click ChainSecured wallet login without logging out → next billing call uses wallet hash, not stale API key.
  6. Logout mid-payment → in-flight fetch aborts cleanly; payment_intent_id surfaced for reconciliation if Stripe charge succeeded.

TODOS

No TODOS.md items completed in this PR.

Test plan

  • cargo build clean
  • cargo test --lib — 206 passed, 0 failed
  • cargo fmt --check clean
  • k6/litApiServer.ts regenerated to match OpenAPI
  • Browser dogfood of SIWE flow (see Manual QA above)

🤖 Generated with Claude Code

GTC6244 and others added 2 commits April 29, 2026 18:15
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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 BillingAuth Rocket request guard that accepts either legacy API keys or X-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 precomputed 0x… 32-byte hash via usage_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 hash keccak256(walletAddress) and be treated as that account. This reintroduces the “wallet-hash-as-bearer” problem for any endpoint/guard that ultimately calls stripe::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 U256 identity for the wallet-signed flow (or reject is_precomputed_hash_shape inputs 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.

Comment thread k6/litApiServer.ts
Comment thread lit-api-server/src/core/v1/guards/billing_auth.rs
Comment thread k6/litApiServer.ts
Comment thread lit-static/dapps/dashboard/billing.js Outdated
Comment thread lit-static/dapps/dashboard/billing.js Outdated
Comment thread lit-api-server/src/accounts/mod.rs
Comment thread lit-api-server/src/core/v1/guards/billing_auth.rs Outdated
Comment thread k6/litApiServer.ts
GTC6244 and others added 2 commits April 29, 2026 18:28
…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>
@GTC6244
Copy link
Copy Markdown
Contributor Author

GTC6244 commented Apr 29, 2026

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. BillingAuth's API-key path now rejects any string shaped like a precomputed account hash (is_precomputed_hash_shape). Without that guard, the new hash passthrough in usage_api_key_to_hash would have let an attacker send X-Api-Key: 0x{keccak256(walletAddress)} and skip SIWE entirely. Pre-computed hashes now only enter the system through the verified WalletSigned branch. Added regression test precomputed_hash_strings_are_recognized.

UX (comments 4, 5) — wallet-popup hygiene. refreshBillingUI no longer auto-fires loadBillingBalance in sovereign mode unless a valid SIWE cache already exists. The SIWE prompt is now triggered explicitly by the user clicking Add Funds, matching what the comment promised. Cancellation surfaces in the modal with a retry path.

DX (comments 1, 2, 3, 8) — OpenAPI type contract. BillingAuth::OpenApiFromRequest now marks X-Api-Key as required: false and documents X-Wallet-Auth in the description. Codegen now emits \"X-Api-Key\"?: string and headers?: BillingBalanceHeaders. rocket_okapi 0.9 only supports one Parameter per guard, so the description carries the rest of the contract.

Tests: 207 passing (was 206). cargo fmt + clippy clean. k6 client regenerated.

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 29, 2026

@GTC6244 I'm unable to start working on this because of repository rules that prevent me from pushing to the branch:

  • Changes must be made through a pull request due to repository rules

See the documentation for more details.

@GTC6244 GTC6244 merged commit 6c15d17 into next Apr 30, 2026
26 checks passed
GTC6244 added a commit that referenced this pull request Apr 30, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants