1use 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
31const NONCE_LEN: usize = 32;
40
41type NonceBytes = [u8; NONCE_LEN];
42
43fn 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#[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 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 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 pub fn expiration(&self) -> Timestamp {
85 self.expiration
86 }
87}
88
89#[derive(Clone, Serialize, Deserialize, PartialDefault, Debug)]
95pub struct DonationPermitRequest {
96 reserved: ReservedByte,
97 blinded_points: Vec<RistrettoPoint>,
99}
100
101impl DonationPermitRequest {
102 pub fn len(&self) -> usize {
104 self.blinded_points.len()
105 }
106
107 pub fn is_empty(&self) -> bool {
109 self.blinded_points.is_empty()
110 }
111}
112
113#[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 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 pub fn request(&self) -> DonationPermitRequest {
159 DonationPermitRequest {
160 reserved: ReservedByte::default(),
161 blinded_points: self.blinded_points.clone(),
162 }
163 }
164
165 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#[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 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 pub fn expiration(&self) -> Timestamp {
247 self.expiration
248 }
249
250 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#[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 pub fn expiration(&self) -> Timestamp {
299 self.expiration
300 }
301
302 pub fn spend_id(&self) -> &[u8] {
309 &self.nonce
310 }
311
312 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 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 let context = DonationPermitRequestContext::new(NonZeroUsize::new(3).unwrap(), [0x42; 32]);
376 let request = context.request();
377 assert_eq!(request.len(), 3);
378
379 let response = DonationPermitResponse::issue(request, &key_pair, [0x37; 32]);
381 assert_eq!(response.expiration(), expiration);
382
383 let permits = context
385 .receive(response, &public, NOW)
386 .expect("valid response");
387 assert_eq!(permits.len(), 3);
388
389 for permit in &permits {
391 permit.verify(NOW, &key_pair).expect("valid permit");
392 }
393
394 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 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}