This is in conformance with the guidelines that `.md` files under `docs/` must use 80-columns.
11 KiB
Privacy-Preserving Subscription Credentials
Brave's premium services (VPN, Leo, Search Premium, etc.) use a blind token protocol based on Privacy Pass to decouple payment identity from service usage. When you use a premium feature, the service verifies you have a valid subscription without learning who you are.
Overview
The system uses a Verifiable Oblivious Pseudorandom Function (VOPRF) via the challenge-bypass-ristretto library. The core idea: the server signs tokens it cannot see, and the client later presents those tokens to access services. Because the server never saw the original tokens, it cannot link a presented credential back to the signing request.
Payment Provider Subscription Service Challenge Bypass Server (CBR)
(Stripe, Apple, Google) (payment.bsg.brave.com) (blind signer)
| | |
| 1. User pays | |
|----------------------------->| |
| | 2. Order marked "paid" |
| | |
| | |
Browser --3. Generate random tokens-->| |
--4. Blind tokens locally | |
--5. Send blinded tokens----->|--6. Forward blinded tokens-->|
|<--7. Signed tokens + proof---|
Browser <--8. Return signed tokens-----| |
--9. Verify DLEQ proof | |
--10. Unblind tokens locally | |
| |
Premium Service (Leo, VPN, etc.)
|
Browser --11. Present unblinded token->|
|--12. Verify via CBR--------->|
|<--13. Valid (or 409 reused)--|
|--14. Grant access----------->Browser
Credential Lifecycle
1. Token generation (client-side)
After a purchase, the browser generates random tokens locally using a
cryptographic RNG and blinds them before transmitting anything
(components/skus/browser/rs/lib/src/sdk/credentials/fetch.rs):
let creds: Vec<Token> =
iter::repeat_with(|| Token::random::<Sha512, _>(&mut csprng))
.take(num_creds)
.collect();
let blinded_creds: Vec<BlindedToken> =
creds.iter().map(|t| t.blind()).collect();
Only the blinded versions leave the device.
2. Blind signing (server-side)
The subscription service forwards the blinded tokens to the Challenge Bypass Server (CBR), which signs them without ever seeing the originals. The CBR returns:
- Signed tokens -- blind signatures over the blinded tokens.
- Batch proof -- a DLEQ (Discrete Log Equality) proof that the signing was performed correctly with the issuer's key.
3. Unblinding and verification (client-side)
The browser verifies the DLEQ batch proof and unblinds the signed tokens
(fetch.rs):
let unblinded_creds = batch_proof
.verify_and_unblind::<Sha512, _>(
&bucket_creds, // original tokens (never sent to server)
&bucket_blinded_creds, // blinded versions that were sent
&signed_creds, // server's blind signatures
&public_key, // issuer's public key
)
.or(Err(InternalError::InvalidProof))?;
After this step the browser holds signed tokens that are cryptographically valid but cannot be correlated to the signing request by the server.
4. Credential presentation (client-side)
When accessing a premium service, the browser picks an unspent credential,
derives a verification key, and signs the issuer identifier to bind the token to
the specific service
(components/skus/browser/rs/lib/src/sdk/credentials/present.rs):
let verification_key =
cred.unblinded_cred.derive_verification_key::<Sha512>();
let signature =
verification_key.sign::<HmacSha512>(issuer.as_bytes()).encode_base64();
The presentation is sent as an HTTP cookie containing the token preimage, HMAC signature, and validity window. No user identity is included.
5. Redemption (server-side)
The premium service forwards the presentation to the CBR, which verifies the signature against its issuer key and checks the token has not been spent with that issuer before. A duplicate redemption attempt with the same token preimage against the same issuer but with a different binding payload is rejected with 409 Conflict. An exact retry of a previously accepted redemption (same token, issuer, and payload) is treated as an idempotent replay and acknowledged without error.
Credential Types
| Type | Description |
|---|---|
| TimeLimitedV2 | Primary production type. Tokens are bucketed into time windows (e.g. daily) with per-window sub-issuer keys. Single-use per time window, enforced server-side by the CBR's redemption store. The client also tracks a local spent flag to avoid re-presenting a token, but the authoritative duplicate check is in the CBR. |
| SingleUse | One-time tokens using the same blind signature scheme. |
| TimeLimited (v1) | Legacy. Server-generated HMAC-derived tokens -- weaker privacy since the server produces the token values directly. |
TimeLimitedV2 credentials store a valid_from / valid_to window and a spent
flag per token in local storage, allowing the SDK to automatically select the
right credential for the current time period and track usage.
Privacy Properties
| Property | How it is achieved |
|---|---|
| Unlinkability | Blind signing means the CBR signs tokens it cannot see; it cannot later match a presented token to a signing request. |
| No identity in presentation | The credential cookie contains only the token, its HMAC, and the validity window -- no account ID, email, or payment info. |
| Double-spend prevention | The CBR records redeemed token preimages keyed per-issuer and rejects true duplicates (same token, different binding) with 409 Conflict; exact replays of a prior redemption are accepted as idempotent. Spent-token records are retained until the issuer's expiration time, after which they may be purged. |
| Issuer binding | The HMAC signature binds each token to a specific merchant and SKU (e.g. brave.com?sku=leo-monthly), preventing cross-service usage. |
| Time-limited validity | V3 issuers create sub-issuer keys per time window; expired credentials are cryptographically unverifiable. |
What Each Party Knows
| Party | Knows | Does not know |
|---|---|---|
| Payment provider (Stripe, Apple, Google) | Your payment identity and that you purchased a Brave subscription | Which credentials you use or when you use services |
| Subscription service (payment.bsg.brave.com) | Your account, order, and that blinded tokens were requested | The original token values; cannot link signing requests to presented credentials |
| Premium service (Leo, VPN, Search, etc.) | That a valid credential was presented | Who presented it -- no account or payment info is included |
| CBR (blind signer) | That it signed some blinded tokens and that some tokens were redeemed | Cannot link signing to redemption due to the blinding step |
Key Source Locations
| Component | Path |
|---|---|
| SKUs SDK (Rust) | components/skus/browser/rs/lib/src/ |
| Token generation and fetch | components/skus/browser/rs/lib/src/sdk/credentials/fetch.rs |
| Credential presentation | components/skus/browser/rs/lib/src/sdk/credentials/present.rs |
| Data models | components/skus/browser/rs/lib/src/models.rs |
| SKUs C++ service | components/skus/browser/skus_service_impl.cc |
| VPN credential integration | components/brave_vpn/browser/connection/brave_vpn_region_data_manager.cc |