Tokens
PrivacyPass issuance is the identity-bound half of the token lifecycle. Redemption is specified per-operation on the pages that consume tokens; nothing here reappears there, which is the point.
Issuance is blinded. The client generates nonces, blinds them, and sends only the blinded elements. The server evaluates them under its issuance key and returns blinded results, which the client unblinds locally. The server never sees a finished Token - the value it signs is not the value it later verifies.
Tokens are fungible. One token buys one metered action, whichever action that is.
Issuer Key
Section titled “Issuer Key”/// The published issuance key. Rotates on a fixed schedule; the previous/// key stays valid through an overlap window.struct IssuerKey { token_type: u16,
// Server domain, bound into every TokenChallenge as issuer_name issuer_name: String,
// VOPRF public key, 49 bytes compressed for P-384 token_key: [u8; 49],
// SHA-256 of token key token_key_id: [u8; 32],
// When this key stops being issued under. Tokens minted under it stay // spendable for the overlap window past this point. not_after: u64,
// Seconds the retired key remains redeemable after not_after overlap_secs: u32,}A client needs the current issuance key before it can construct a request. There are two ways to get it, and implementations should support both.
GetIssuerKey
Section titled “GetIssuerKey”Fetches the current key directly. Use this to pre-warm, so a client’s first metered operation does not have to fail before it can succeed.
- Input: None
- Output:
IssuerKey - Auth: None
- Idempotent: Yes
- Errors: None beyond the universal set
Requirements
Section titled “Requirements”- Servers MUST publish the current issuance public key and its
token_key_id - The response MUST be cacheable, and clients SHOULD honor
not_after - Clients SHOULD refresh well inside the overlap window
HTTP Binding
Section titled “HTTP Binding”GET /api/v1/tokens/keyDiscovery via Challenge
Section titled “Discovery via Challenge”A client that attempts a metered operation without a usable token receives a 401 carrying the key and the challenge, per RFC 9577:
WWW-Authenticate: PrivateToken challenge="<base64url>", token-key="<base64url>"This is the recovery path, not the normal one. A client relying on it spends a failed round trip per key rotation.
- Servers MUST include this header on
TOKEN_MISSING,TOKEN_INVALID, andTOKEN_KEY_UNKNOWN
The Token Challenge
Section titled “The Token Challenge”Each server has exactly one challenge, and every token it issues is minted against it:
/// From RFC 9577TokenChallenge { token_type: 0x0001, issuer_name: <server domain, from IssuerKey.issuer_name>, redemption_context: <empty>, origin_info: <empty>,}challenge_digest is its SHA-256, carried in every token. It binds a token to the server that minted it and nothing more.
- Clients MUST construct the challenge from
IssuerKey.issuer_name - Servers MUST reject a token whose
challenge_digestdoes not match their own
IssueTokens
Section titled “IssueTokens”Exchanges a blinded batch for blinded evaluations.
/// A blinded issuance request, per RFC 9578. The server evaluates this/// without learning the nonce inside it.struct TokenRequest { // 0x0001. Servers MUST reject any other value. token_type: u16,
// Least significant byte of the issuance key's token_key_id. Lets the // server pick the right key without the client sending all 32 bytes. truncated_token_key_id: u8,
// Blinded element. 49 bytes is the compressed point length for P-384. blinded_msg: [u8; 49],}
/// Clients batch so that issuance frequency is not itself a signal.struct TokenRequestBatch { requests: Vec<TokenRequest>,}/// A blinded evaluation. The client unblinds this locally to obtain a Token;/// the server never sees the finished value it will later verify.struct TokenResponse { // Evaluated element, 49 bytes for P-384 evaluate_msg: [u8; 49],
// DLEQ proof that the evaluation used the advertised issuance key. // Two P-384 scalars, 48 bytes each. evaluate_proof: [u8; 96],}
struct TokenResponseBatch { // MAY be shorter than the requested batch. A short vector is a partial // grant, not an error, and the entries correspond to the first N requests // in order. responses: Vec<TokenResponse>,
// Current bucket state, so clients top up before running dry rather // than mid-conversation bucket_level: u32, bucket_rate: u32,}- Input:
TokenRequestBatch - Output:
TokenResponseBatch - Auth: Device authentication
- Idempotent: No
- Errors:
DEVICE_NOT_ANNOUNCED,DEVICE_BLOCKED,BUDGET_EXHAUSTED,TOKEN_TYPE_UNSUPPORTED,TOKEN_KEY_UNKNOWN
Requirements
Section titled “Requirements”- Devices MUST authenticate; servers MUST reject a device with no current record
- Servers MUST reject any
token_typeother than0x0001 - Servers MUST enforce the device’s Trust Tier bucket at issuance
- Servers MUST grant partial batches rather than rejecting when a bucket is short
- Servers MUST return
BUDGET_EXHAUSTEDonly when zero were available - Servers MUST NOT record which tokens were issued to which device beyond a count
- Servers MUST NOT consult bucket state at redemption
- Clients MUST batch, and SHOULD request replacements well before exhausting their supply
Partial Grants
Section titled “Partial Grants”responses MAY be shorter than requests. That is a successful response, not an error, and entries correspond to the first N requests in order.
bucket_level and bucker_rate accompany every grant so a client can schedule its next top-up without probing.
HTTP binding
Section titled “HTTP binding”POST /api/v1/tokens/issueAuthorization: <device authentication>Rotation
Section titled “Rotation”- Issuance keys MUST rotate on a fixed schedule, defaulting to 30 days
- Servers MUST continue accepting tokens under the previous key for a 7-day overlap
- The spent-nonce set for a retired key MAY be discarded once its overlap closes
- A token is spendable for at most
rotation + overlapfrom issuance
A client holding tokens minted under a retired key receives TOKEN_KEY_UNKNOWN. The recovery is to re-fetch the issuer key and request a fresh batch; retrying the old token cannot succeed.
Privacy Boundary
Section titled “Privacy Boundary”| The server learns at issuance | The server learns at redemption |
|---|---|
| Which device asked | That a valid token was spent |
| How many it granted | Nothing else |
| Roughly when |
Nothing joins the two columns, and every token looks like every other. Tokens are also server-local i.e., they redeem only at the issuing server, and servers MUST reject tokens minted under another server’s key.