Skip to main content

zkgroup/api/donations/
donation_permit.rs

1//
2// Copyright 2026 Signal Messenger, LLC.
3// SPDX-License-Identifier: AGPL-3.0-only
4//
5
6//! Provides DonationPermit and related types.
7//!
8//! DonationPermit is a single-use, unlinkable bearer token used to rate-limit access to
9//! unauthenticated donation endpoints.
10//!
11//! DonationPermit is a MAC over:
12//! - a random nonce (blinded by the client at issuance, revealed to the donation endpoint for
13//!   verification)
14//! - an expiration timestamp, truncated to day granularity (chosen by the issuing server, passed
15//!   publicly to the donation endpoint for verification)
16
17use std::fmt::Debug;
18use std::num::NonZeroUsize;
19
20use curve25519_dalek::{RistrettoPoint, Scalar};
21use partial_default::PartialDefault;
22use poksho::ShoApi;
23use poksho::shoapi::ShoApiExt as _;
24use serde::{Deserialize, Serialize};
25use zkcredential::sho::ShoExt as _;
26
27use crate::api::endorsement_expiration;
28use crate::common::serialization::ReservedByte;
29use crate::{RandomnessBytes, Timestamp, ZkGroupVerificationFailure};
30
31/// The length of a permit nonce, in bytes.
32///
33/// A full 256 bits of randomness: nonces are the spent-set key, so we want ample collision
34/// headroom at donation volume and zero chance of a guess. The nonce is cheap (sent once, at
35/// redemption), so there's no reason to economize here.
36///
37/// This is used as the `spend_id` to enforce single use. Keep this in mind if
38/// considering making it smaller.
39const NONCE_LEN: usize = 32;
40
41type NonceBytes = [u8; NONCE_LEN];
42
43/// Domain-separates the hash-to-point used to turn a nonce into a verifiable attribute point.
44fn nonce_to_point(nonce: &NonceBytes) -> RistrettoPoint {
45    let mut sho =
46        poksho::ShoHmacSha256::new(b"20260611_Signal_DonationPermitEndorsement_NonceToPoint");
47    sho.absorb_and_ratchet(nonce);
48    sho.get_point()
49}
50
51/// A key pair used to issue and verify donation permits for a particular expiration.
52///
53/// These are intended to be cheaply cached; the redeeming server only needs the derived key pair,
54/// never the root secret.
55#[derive(Clone, Serialize, Deserialize, PartialDefault)]
56pub struct DonationPermitDerivedKeyPair {
57    reserved: ReservedByte,
58    key_pair: zkcredential::endorsements::ServerDerivedKeyPair,
59    expiration: Timestamp,
60}
61
62impl DonationPermitDerivedKeyPair {
63    /// Encapsulates the "tag info", or public attributes, of a permit, which is used to derive the
64    /// appropriate signing key.
65    fn tag_info(expiration: Timestamp) -> impl ShoApi + Clone {
66        let mut sho = poksho::ShoHmacSha256::new(b"20260611_Signal_DonationPermitEndorsement");
67        sho.absorb_and_ratchet(&expiration.to_be_bytes());
68        sho
69    }
70
71    /// Derives the appropriate key pair for the given expiration.
72    pub fn for_expiration(
73        expiration: Timestamp,
74        root: impl AsRef<zkcredential::endorsements::ServerRootKeyPair>,
75    ) -> Self {
76        Self {
77            reserved: ReservedByte::default(),
78            key_pair: root.as_ref().derive_key(Self::tag_info(expiration)),
79            expiration,
80        }
81    }
82
83    /// The expiration this key pair was derived for.
84    pub fn expiration(&self) -> Timestamp {
85        self.expiration
86    }
87}
88
89/// The wire message a client sends to the issuing (chat) server to request permits.
90///
91/// It carries one blinded attribute point per requested permit. The server learns nothing about
92/// the underlying nonces, and does not need a proof from the client: a malformed point only wastes
93/// the client's own rate-limit allowance, because no nonce preimage will ever hash to it.
94#[derive(Clone, Serialize, Deserialize, PartialDefault, Debug)]
95pub struct DonationPermitRequest {
96    reserved: ReservedByte,
97    // Stored decompressed so that deserialization validates the point encodings up front.
98    blinded_points: Vec<RistrettoPoint>,
99}
100
101impl DonationPermitRequest {
102    /// The number of permits requested.
103    pub fn len(&self) -> usize {
104        self.blinded_points.len()
105    }
106
107    /// Whether the request asks for zero permits (which the server should reject).
108    pub fn is_empty(&self) -> bool {
109        self.blinded_points.is_empty()
110    }
111}
112
113/// Client-retained state for an in-flight permit request.
114///
115/// This holds the per-permit secrets (nonces and blinding scalars) that the client needs to unblind
116/// the eventual [`DonationPermitResponse`]. It must be persisted between sending the
117/// [`request`][Self::request] and calling [`receive`][Self::receive].
118#[derive(Clone, Serialize, Deserialize, PartialDefault)]
119pub struct DonationPermitRequestContext {
120    reserved: ReservedByte,
121    nonces: Vec<NonceBytes>,
122    blinding_scalars: Vec<Scalar>,
123    blinded_points: Vec<RistrettoPoint>,
124}
125
126impl DonationPermitRequestContext {
127    /// Samples `count` fresh permits' worth of nonces and blinding scalars from `randomness`.
128    ///
129    /// Each nonce gets an independent blinding scalar, so every eventual permit is
130    /// information-theoretically independent of issuance and of its siblings.
131    pub fn new(count: NonZeroUsize, randomness: RandomnessBytes) -> Self {
132        let count = count.get();
133        let mut sho =
134            poksho::ShoHmacSha256::new(b"20260611_Signal_DonationPermitEndorsement_RequestContext");
135        sho.absorb_and_ratchet(&randomness);
136
137        let mut nonces = Vec::with_capacity(count);
138        let mut blinding_scalars = Vec::with_capacity(count);
139        let mut blinded_points = Vec::with_capacity(count);
140        for _ in 0..count {
141            let nonce: NonceBytes = sho.squeeze_and_ratchet_as_array();
142            let blinding_scalar = sho.get_scalar();
143            let blinded_point = blinding_scalar * nonce_to_point(&nonce);
144            nonces.push(nonce);
145            blinding_scalars.push(blinding_scalar);
146            blinded_points.push(blinded_point);
147        }
148
149        Self {
150            reserved: ReservedByte::default(),
151            nonces,
152            blinding_scalars,
153            blinded_points,
154        }
155    }
156
157    /// The wire request to send to the issuing server.
158    pub fn request(&self) -> DonationPermitRequest {
159        DonationPermitRequest {
160            reserved: ReservedByte::default(),
161            blinded_points: self.blinded_points.clone(),
162        }
163    }
164
165    /// Validates the issuer's response and extracts the redeemable permits.
166    ///
167    /// `now` is used to validate the response's expiration window. `root_public_key` must be the
168    /// audited, client-pinned public key; verifying the issuance proof against it is what prevents
169    /// the issuing server from tagging this client with a per-client key.
170    ///
171    /// The returned permits are in the same order as the nonces in this context.
172    pub fn receive(
173        self,
174        response: DonationPermitResponse,
175        root_public_key: impl AsRef<zkcredential::endorsements::ServerRootPublicKey>,
176        now: Timestamp,
177    ) -> Result<Vec<DonationPermit>, ZkGroupVerificationFailure> {
178        let derived_key =
179            response.derive_public_signing_key_from_expiration(now, root_public_key)?;
180
181        let endorsements = response
182            .endorsements
183            .receive(self.blinded_points.iter().copied(), &derived_key)
184            .map_err(|_| ZkGroupVerificationFailure)?;
185
186        let permits = endorsements
187            .decompressed
188            .into_iter()
189            .zip(&self.nonces)
190            .zip(&self.blinding_scalars)
191            .map(|((endorsement, nonce), blinding_scalar)| {
192                let client_key =
193                    zkcredential::endorsements::ClientDecryptionKey::from_blinding_scalar(
194                        *blinding_scalar,
195                    );
196                DonationPermit {
197                    reserved: ReservedByte::default(),
198                    expiration: response.expiration,
199                    nonce: *nonce,
200                    raw_token: endorsement.to_token(&client_key),
201                }
202            })
203            .collect();
204        Ok(permits)
205    }
206}
207
208/// The issuing server's response: a batch of endorsements with a proof of honest issuance.
209///
210/// Mirrors the structure of the underlying [`zkcredential::endorsements::EndorsementResponse`],
211/// plus the expiration the key was derived for.
212#[derive(Clone, Serialize, Deserialize, PartialDefault, Debug)]
213pub struct DonationPermitResponse {
214    reserved: ReservedByte,
215    endorsements: zkcredential::endorsements::EndorsementResponse,
216    expiration: Timestamp,
217}
218
219impl DonationPermitResponse {
220    pub fn default_expiration(current_time: Timestamp) -> Timestamp {
221        endorsement_expiration::default_expiration(current_time)
222    }
223
224    /// Blindly issues an endorsement for each blinded point in `request`.
225    ///
226    /// The issuing server does not (and cannot) inspect the underlying nonces. Per-account rate
227    /// limiting is a policy decision made by the caller before issuing.
228    pub fn issue(
229        request: DonationPermitRequest,
230        key_pair: &DonationPermitDerivedKeyPair,
231        randomness: RandomnessBytes,
232    ) -> Self {
233        let endorsements = zkcredential::endorsements::EndorsementResponse::issue(
234            request.blinded_points,
235            &key_pair.key_pair,
236            randomness,
237        );
238        Self {
239            reserved: ReservedByte::default(),
240            endorsements,
241            expiration: key_pair.expiration,
242        }
243    }
244
245    /// The expiration shared by all permits in this response.
246    pub fn expiration(&self) -> Timestamp {
247        self.expiration
248    }
249
250    /// Validates `self.expiration` against `now` and derives the appropriate signing key (using
251    /// [`DonationPermitDerivedKeyPair::tag_info`]).
252    fn derive_public_signing_key_from_expiration(
253        &self,
254        now: Timestamp,
255        root_public_key: impl AsRef<zkcredential::endorsements::ServerRootPublicKey>,
256    ) -> Result<zkcredential::endorsements::ServerDerivedPublicKey, ZkGroupVerificationFailure>
257    {
258        endorsement_expiration::validate_expiration(self.expiration, now)?;
259
260        Ok(root_public_key
261            .as_ref()
262            .derive_key(DonationPermitDerivedKeyPair::tag_info(self.expiration)))
263    }
264}
265
266/// A single redeemable donation permit.
267///
268/// This is the value sent, over an unauthenticated connection, to a donation endpoint. It is a
269/// *bearer* token: it does not bind to any particular request, so it must be transmitted over a
270/// confidential channel (e.g. TLS).
271#[derive(Clone, Serialize, Deserialize, PartialDefault)]
272pub struct DonationPermit {
273    reserved: ReservedByte,
274    expiration: Timestamp,
275    nonce: NonceBytes,
276    raw_token: Box<[u8]>,
277}
278
279impl Debug for DonationPermit {
280    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
281        let Self {
282            reserved,
283            expiration,
284            nonce,
285            raw_token,
286        } = self;
287        f.debug_struct("DonationPermit")
288            .field("reserved", reserved)
289            .field("expiration", expiration)
290            .field("nonce", &zkcredential::PrintAsHex(nonce.as_slice()))
291            .field("raw_token", &zkcredential::PrintAsHex(&**raw_token))
292            .finish()
293    }
294}
295
296impl DonationPermit {
297    /// The expiration after which this permit can no longer be redeemed.
298    pub fn expiration(&self) -> Timestamp {
299        self.expiration
300    }
301
302    /// The key a redeeming server should use to detect double-spends: the permit's nonce.
303    ///
304    /// The nonce uniquely identifies the permit and cannot be altered without invalidating the
305    /// token (verification recomputes the attribute point from it), so a replayed permit produces
306    /// the same key. The redeeming server must record this (scoped to
307    /// [`expiration`][Self::expiration]) and reject any permit whose key it has already seen.
308    pub fn spend_id(&self) -> &[u8] {
309        &self.nonce
310    }
311
312    /// Verifies that this permit was honestly issued for the current day under `key_pair`.
313    ///
314    /// **This does not enforce single use.** The caller must additionally check
315    /// [`spend_id`][Self::spend_id] against its spent set and record it on success.
316    pub fn verify(
317        &self,
318        now: Timestamp,
319        key_pair: &DonationPermitDerivedKeyPair,
320    ) -> Result<(), ZkGroupVerificationFailure> {
321        endorsement_expiration::validate_expiration(self.expiration, now)?;
322        assert_eq!(
323            self.expiration, key_pair.expiration,
324            "wrong key pair used for this token"
325        );
326
327        let point = nonce_to_point(&self.nonce);
328        key_pair
329            .key_pair
330            .verify(&point, &self.raw_token)
331            .map_err(|_| ZkGroupVerificationFailure)
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use crate::{ServerPublicParams, ServerSecretParams};
339
340    const NOW: Timestamp = Timestamp::from_epoch_seconds(1_600_000_000);
341
342    fn server_params() -> (ServerSecretParams, ServerPublicParams) {
343        let secret = ServerSecretParams::generate([0x42; 32]);
344        let public = secret.get_public_params();
345        (secret, public)
346    }
347
348    /// Issues and receives a single valid permit for `NOW`, returning it alongside the matching
349    /// derived key pair (its expiration is reachable via [`DonationPermitDerivedKeyPair::expiration`]).
350    fn issue_one_permit(
351        request_seed: RandomnessBytes,
352        issue_seed: RandomnessBytes,
353    ) -> (DonationPermit, DonationPermitDerivedKeyPair) {
354        let (secret, public) = server_params();
355        let expiration = DonationPermitResponse::default_expiration(NOW);
356        let key_pair = DonationPermitDerivedKeyPair::for_expiration(expiration, &secret);
357
358        let context = DonationPermitRequestContext::new(NonZeroUsize::MIN, request_seed);
359        let response = DonationPermitResponse::issue(context.request(), &key_pair, issue_seed);
360        let permit = context
361            .receive(response, &public, NOW)
362            .expect("valid response")
363            .pop()
364            .expect("one permit");
365        (permit, key_pair)
366    }
367
368    #[test]
369    fn default_flow() {
370        let (secret, public) = server_params();
371        let expiration = DonationPermitResponse::default_expiration(NOW);
372        let key_pair = DonationPermitDerivedKeyPair::for_expiration(expiration, &secret);
373
374        // Client: request three permits.
375        let context = DonationPermitRequestContext::new(NonZeroUsize::new(3).unwrap(), [0x42; 32]);
376        let request = context.request();
377        assert_eq!(request.len(), 3);
378
379        // Issuing server: blindly issue.
380        let response = DonationPermitResponse::issue(request, &key_pair, [0x37; 32]);
381        assert_eq!(response.expiration(), expiration);
382
383        // Client: receive and unblind.
384        let permits = context
385            .receive(response, &public, NOW)
386            .expect("valid response");
387        assert_eq!(permits.len(), 3);
388
389        // Redeeming server: every permit verifies under the day's key.
390        for permit in &permits {
391            permit.verify(NOW, &key_pair).expect("valid permit");
392        }
393
394        // Spend IDs are distinct across permits.
395        let mut spent: Vec<&[u8]> = permits.iter().map(|p| p.spend_id()).collect();
396        spent.dedup();
397        assert_eq!(spent.len(), 3, "spend IDs should be distinct");
398    }
399
400    #[test]
401    fn wrong_key_fails() {
402        let (permit, key_pair) = issue_one_permit([0x86; 32], [0x67; 32]);
403
404        let other_secret = ServerSecretParams::generate([0xAF; 32]);
405        let wrong_key =
406            DonationPermitDerivedKeyPair::for_expiration(key_pair.expiration(), &other_secret);
407        permit.verify(NOW, &wrong_key).expect_err("wrong key");
408    }
409
410    #[test]
411    fn tampered_nonce_fails() {
412        let (mut permit, key_pair) = issue_one_permit([0x41; 32], [0x43; 32]);
413
414        permit.nonce[0] ^= 0xff;
415        permit.verify(NOW, &key_pair).expect_err("tampered nonce");
416    }
417
418    #[test]
419    fn expired_permit_fails() {
420        let (permit, key_pair) = issue_one_permit([0x14; 32], [0x28; 32]);
421
422        let after_expiry = Timestamp::from_epoch_seconds(key_pair.expiration().epoch_seconds() + 1);
423        permit
424            .verify(after_expiry, &key_pair)
425            .expect_err("expired permit");
426    }
427
428    #[test]
429    fn non_dayaligned_expiration_rejected() {
430        let (secret, public) = server_params();
431        // A non-day-aligned expiration must be rejected by the client on receive.
432        let expiration = Timestamp::from_epoch_seconds(
433            DonationPermitResponse::default_expiration(NOW).epoch_seconds() + 1,
434        );
435        let key_pair = DonationPermitDerivedKeyPair::for_expiration(expiration, &secret);
436
437        let context = DonationPermitRequestContext::new(NonZeroUsize::MIN, [0x01; 32]);
438        let response = DonationPermitResponse::issue(context.request(), &key_pair, [0x02; 32]);
439        context
440            .receive(response, &public, NOW)
441            .expect_err("non-day-aligned expiration");
442    }
443}