1use base64::{Engine, engine::general_purpose::STANDARD};
2use futures::TryStreamExt;
3use serde::Serialize;
4
5use crate::{AuthorizationContext, SignatureGenerationError};
6
7pub struct Utils {
9 pub(crate) app_id: String,
10}
11pub struct RequestSigner {
13 app_id: String,
14}
15pub struct RequestFormatter {
17 app_id: String,
18}
19
20impl Utils {
21 pub fn signer(&self) -> RequestSigner {
23 RequestSigner {
24 app_id: self.app_id.clone(),
25 }
26 }
27
28 pub fn formatter(&self) -> RequestFormatter {
30 RequestFormatter {
31 app_id: self.app_id.clone(),
32 }
33 }
34}
35
36impl RequestFormatter {
37 pub async fn build_canonical_request<S: Serialize>(
38 &self,
39 method: Method,
40 url: String,
41 body: S,
42 idempotency_key: Option<String>,
43 ) -> Result<String, serde_json::Error> {
44 format_request_for_authorization_signature(&self.app_id, method, url, body, idempotency_key)
45 }
46}
47
48impl RequestSigner {
49 pub async fn sign_canonical_request<S: Serialize>(
50 &self,
51 ctx: &AuthorizationContext,
52 method: Method,
53 url: String,
54 body: S,
55 idempotency_key: Option<String>,
56 ) -> Result<String, SignatureGenerationError> {
57 generate_authorization_signatures(ctx, &self.app_id, method, url, body, idempotency_key)
58 .await
59 }
60}
61
62pub fn format_request_for_authorization_signature<S: Serialize>(
67 app_id: &str,
68 method: Method,
69 url: String,
70 body: S,
71 idempotency_key: Option<String>,
72) -> Result<String, serde_json::Error> {
73 let mut headers = serde_json::Map::new();
74 headers.insert(
75 "privy-app-id".into(),
76 serde_json::Value::String(app_id.to_owned()),
77 );
78 if let Some(key) = idempotency_key {
79 headers.insert(
80 "privy-idempotency-key".to_string(),
81 serde_json::Value::String(key),
82 );
83 }
84
85 WalletApiRequestSignatureInput::new(method, url)
86 .headers(serde_json::Value::Object(headers))
87 .body(body)
88 .canonicalize()
89}
90
91pub async fn generate_authorization_signatures<S: Serialize>(
108 ctx: &AuthorizationContext,
109 app_id: &str,
110 method: Method,
111 url: String,
112 body: S,
113 idempotency_key: Option<String>,
114) -> Result<String, SignatureGenerationError> {
115 let canonical =
116 format_request_for_authorization_signature(app_id, method, url, body, idempotency_key)?;
117
118 #[cfg(all(feature = "unsafe_debug", debug_assertions))]
119 {
120 tracing::debug!("canonical request data: {}", canonical);
121 }
122
123 Ok(ctx
124 .sign(canonical.as_bytes())
125 .map_ok(|s| {
126 let der_bytes = s.to_der();
127 STANDARD.encode(&der_bytes)
128 })
129 .try_collect::<Vec<_>>()
130 .await?
131 .join(","))
132}
133
134#[derive(serde::Serialize, Debug)]
139pub enum Method {
140 PATCH,
142 POST,
144 PUT,
146 DELETE,
148}
149
150#[derive(serde::Serialize)]
159pub struct WalletApiRequestSignatureInput<S: Serialize> {
160 version: u32,
161 method: Method,
162 url: String,
163 body: Option<S>,
164 headers: Option<serde_json::Value>,
165}
166
167impl<S: Serialize> WalletApiRequestSignatureInput<S> {
168 #[must_use]
170 pub fn new(method: Method, url: String) -> Self {
171 Self {
172 version: 1,
173 method,
174 url,
175 body: None,
176 headers: None,
177 }
178 }
179
180 #[must_use]
182 pub fn body(mut self, body: S) -> Self {
183 self.body = Some(body);
184 self
185 }
186
187 #[must_use]
189 pub fn headers(mut self, headers: serde_json::Value) -> Self {
190 self.headers = Some(headers);
191 self
192 }
193
194 pub fn canonicalize(self) -> Result<String, serde_json::Error> {
199 serde_json_canonicalizer::to_string(&self)
200 }
201}
202
203#[cfg(test)]
204mod tests {
205 use std::f64;
206
207 use serde_json::json;
208 use test_case::test_case;
209 use tracing_test::traced_test;
210
211 use super::*;
212 use crate::{
213 AuthorizationContext, IntoKey, PrivateKey,
214 generated::types::{OwnerInput, UpdateWalletBody},
215 get_auth_header,
216 };
217
218 const TEST_PRIVATE_KEY_PEM: &str = include_str!("../tests/test_private_key.pem");
219
220 #[tokio::test]
221 async fn test_build_canonical_request() {
222 let private_key = include_str!("../tests/test_private_key.pem");
223 let key = PrivateKey::new(private_key.to_string());
224 let public_key = key.get_key().await.unwrap().public_key();
225
226 let update_wallet_body = UpdateWalletBody {
228 owner: Some(OwnerInput::PublicKey(public_key.to_string())),
229 ..Default::default()
230 };
231
232 let canonical_data = format_request_for_authorization_signature(
234 "cmf418pa801bxl40b5rcgjvd9",
235 Method::PATCH,
236 "https://api.privy.io/v1/wallets/o5zuf7fbygwze9l9gaxyc0bm".into(),
237 update_wallet_body.clone(),
238 None,
239 )
240 .unwrap();
241
242 assert_eq!(
243 canonical_data,
244 "{\"body\":{\"owner\":{\"public_key\":\"-----BEGIN PUBLIC KEY-----\\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESYrvEwooR33jt/8Up0lWdDNAcxmg\\nNZrCX23OThCPA+WxDx+dHYrjRlfPmHX0/aMTopp1PdKAtlQjRJDHSNd8XA==\\n-----END PUBLIC KEY-----\\n\"}},\"headers\":{\"privy-app-id\":\"cmf418pa801bxl40b5rcgjvd9\"},\"method\":\"PATCH\",\"url\":\"https://api.privy.io/v1/wallets/o5zuf7fbygwze9l9gaxyc0bm\",\"version\":1}"
245 );
246 }
247
248 #[test]
250 fn test_method_serialization() {
251 assert_eq!(serde_json::to_string(&Method::PATCH).unwrap(), "\"PATCH\"");
252 assert_eq!(serde_json::to_string(&Method::POST).unwrap(), "\"POST\"");
253 assert_eq!(serde_json::to_string(&Method::PUT).unwrap(), "\"PUT\"");
254 assert_eq!(
255 serde_json::to_string(&Method::DELETE).unwrap(),
256 "\"DELETE\""
257 );
258 }
259
260 #[test]
262 fn test_wallet_api_request_signature_input_new() {
263 let input = WalletApiRequestSignatureInput::new(
264 Method::POST,
265 "https://api.privy.io/v1/test".to_string(),
266 )
267 .body(json!({}));
268
269 let canonical = input.canonicalize().unwrap();
271 assert!(canonical.contains("\"version\":1"));
272 assert!(canonical.contains("\"method\":\"POST\""));
273 assert!(canonical.contains("https://api.privy.io/v1/test"));
274 }
275
276 #[test]
277 fn test_wallet_api_request_signature_input_with_body() {
278 let body = json!({"test": "value"});
279 let input = WalletApiRequestSignatureInput::new(
280 Method::POST,
281 "https://api.privy.io/v1/test".to_string(),
282 )
283 .body(body);
284
285 let canonical = input.canonicalize().unwrap();
286 assert!(canonical.contains("\"body\":{\"test\":\"value\"}"));
287 }
288
289 #[test]
290 fn test_wallet_api_request_signature_input_with_headers() {
291 let headers = json!({"header1": "value1", "header2": "value2"});
292 let input = WalletApiRequestSignatureInput::new(
293 Method::POST,
294 "https://api.privy.io/v1/test".to_string(),
295 )
296 .body(json!({}))
297 .headers(headers);
298
299 let canonical = input.canonicalize().unwrap();
300 assert!(canonical.contains("\"headers\":{\"header1\":\"value1\",\"header2\":\"value2\"}"));
301 }
302
303 #[test]
304 fn test_wallet_api_request_signature_input_complete() {
305 let body = json!({"data": "test"});
306 let headers = json!({"auth": "token"});
307 let input = WalletApiRequestSignatureInput::new(
308 Method::PATCH,
309 "https://api.privy.io/v1/wallets/123".to_string(),
310 )
311 .body(body)
312 .headers(headers);
313
314 let canonical = input.canonicalize().unwrap();
315 assert!(canonical.contains("\"body\":{\"data\":\"test\"}"));
316 assert!(canonical.contains("\"headers\":{\"auth\":\"token\"}"));
317 assert!(canonical.contains("\"method\":\"PATCH\""));
318 assert!(canonical.contains("\"version\":1"));
319 }
320
321 #[test]
322 fn test_wallet_api_request_signature_input_no_body() {
323 let input = WalletApiRequestSignatureInput::new(
324 Method::DELETE,
325 "https://api.privy.io/v1/test".to_string(),
326 )
327 .body(json!(null));
328
329 let canonical = input.canonicalize().unwrap();
330 assert!(canonical.contains("\"body\":null"));
331 }
332
333 #[test]
334 fn test_wallet_api_request_signature_input_no_headers() {
335 let input = WalletApiRequestSignatureInput::new(
336 Method::POST,
337 "https://api.privy.io/v1/test".to_string(),
338 )
339 .body(json!({}));
340
341 let canonical = input.canonicalize().unwrap();
342 assert!(canonical.contains("\"headers\":null"));
343 }
344
345 #[test]
346 fn test_build_canonical_request_different_methods() {
347 for method in [Method::POST, Method::PUT, Method::PATCH, Method::DELETE] {
348 let result = format_request_for_authorization_signature(
349 "test_app_id",
350 method,
351 "https://api.privy.io/v1/test".to_string(),
352 json!({}),
353 None,
354 );
355
356 assert!(result.is_ok());
357 let canonical = result.unwrap();
358 assert!(canonical.contains("\"version\":1"));
359 }
360 }
361
362 #[test]
363 fn test_key_ordering() {
364 let builder =
365 WalletApiRequestSignatureInput::new(Method::POST, "https://example.com".to_string())
366 .body(json!({
367 "z_last": "last",
368 "a_first": "first",
369 "m_middle": "middle"
370 }))
371 .headers(json!({
372 "z-header": "last",
373 "a-header": "first"
374 }));
375
376 let canonical = builder
377 .canonicalize()
378 .expect("canonicalization should succeed");
379
380 assert!(canonical.contains(r#"{"a_first":"first","m_middle":"middle","z_last":"last"}"#));
382 assert!(canonical.contains(r#"{"a-header":"first","z-header":"last"}"#));
383 }
384
385 #[test]
386 fn test_nested_object_sorting() {
387 let builder =
388 WalletApiRequestSignatureInput::new(Method::POST, "https://example.com".to_string())
389 .body(json!({
390 "outer": {
391 "z_inner": "last",
392 "a_inner": "first"
393 }
394 }));
395
396 let canonical = builder
397 .canonicalize()
398 .expect("canonicalization should succeed");
399
400 assert!(canonical.contains(r#"{"a_inner":"first","z_inner":"last"}"#));
402 }
403
404 #[test]
405 fn test_array_preservation() {
406 let builder =
407 WalletApiRequestSignatureInput::new(Method::POST, "https://example.com".to_string())
408 .body(json!({
409 "items": ["third", "first", "second"]
410 }));
411
412 let canonical = builder
413 .canonicalize()
414 .expect("canonicalization should succeed");
415
416 assert!(canonical.contains(r#"["third","first","second"]"#));
418 }
419
420 #[test]
421 fn test_canonicalization_special_values() {
422 let builder =
423 WalletApiRequestSignatureInput::new(Method::POST, "https://example.com".to_string())
424 .body(json!({
425 "null_value": null,
426 "boolean_true": true,
427 "boolean_false": false,
428 "number_int": 42,
429 "number_float": f64::consts::PI,
430 "string_empty": "",
431 "string_with_quotes": "He said \"Hello\"",
432 "string_with_newlines": "line1\nline2\r\nline3",
433 "array_mixed": [null, true, 1, "string"]
434 }));
435
436 let canonical = builder.canonicalize().unwrap();
437
438 assert!(canonical.contains("\"null_value\":null"));
440 assert!(canonical.contains("\"boolean_true\":true"));
441 assert!(canonical.contains("\"boolean_false\":false"));
442 assert!(canonical.contains("\"number_int\":42"));
443 assert!(canonical.contains("\"string_empty\":\"\""));
444 assert!(canonical.contains("\\\"Hello\\\""));
445 assert!(canonical.contains("\"array_mixed\":[null,true,1,\"string\"]"));
446 }
447
448 #[test]
449 fn test_canonicalization_unicode() {
450 let builder =
451 WalletApiRequestSignatureInput::new(Method::POST, "https://example.com".to_string())
452 .body(json!({
453 "unicode": "Hello 世界 🌍",
454 "emoji": "🔐🚀💎",
455 "accents": "café naïve résumé"
456 }));
457
458 let canonical = builder.canonicalize().unwrap();
459
460 assert!(canonical.contains("Hello 世界 🌍"));
462 assert!(canonical.contains("🔐🚀💎"));
463 assert!(canonical.contains("café naïve résumé"));
464 }
465
466 #[test_case(
467 &json!({"name": "John", "age": 30}),
468 r#"{"age":30,"name":"John"}"#;
469 "simple object"
470 )]
471 #[test_case(
472 &json!({"name": "John", "address": {"street": "123 Main St", "city": "Boston"}}),
473 r#"{"address":{"city":"Boston","street":"123 Main St"},"name":"John"}"#;
474 "nested object"
475 )]
476 #[test_case(
477 &json!({"name": "John", "numbers": [1, 2, 3]}),
478 r#"{"name":"John","numbers":[1,2,3]}"#;
479 "array"
480 )]
481 #[test_case(
482 &json!({"name": "John", "age": null}),
483 r#"{"age":null,"name":"John"}"#;
484 "null value"
485 )]
486 #[test_case(
487 &json!({"name": "John", "age": 30, "address": {"street": "123 Main St", "city": "Boston"}, "hobbies": ["reading", "gaming"], "middleName": null}),
488 r#"{"address":{"city":"Boston","street":"123 Main St"},"age":30,"hobbies":["reading","gaming"],"middleName":null,"name":"John"}"#;
489 "complex object"
490 )]
491 fn test_json_canonicalization(json: &serde_json::Value, expected: &str) {
492 let result =
493 serde_json_canonicalizer::to_string(json).expect("canonicalization should succeed");
494 assert_eq!(result, expected);
495 }
496
497 #[test]
498 fn test_build_canonical_request_with_idempotency_key() {
499 let body = serde_json::json!({"test": "data"});
500 let idempotency_key = "unique-key-123".to_string();
501
502 let canonical_data = format_request_for_authorization_signature(
503 "test_app_id",
504 Method::POST,
505 "https://api.privy.io/v1/test".to_string(),
506 body,
507 Some(idempotency_key.clone()),
508 )
509 .unwrap();
510
511 assert!(
512 canonical_data.contains(&idempotency_key),
513 "Should include idempotency key"
514 );
515 assert!(
516 canonical_data.contains("privy-idempotency-key"),
517 "Should include idempotency key header"
518 );
519 }
520
521 #[tokio::test]
522 #[traced_test]
523 async fn test_sign_canonical_request() {
524 let ctx =
525 AuthorizationContext::new().push(PrivateKey::new(TEST_PRIVATE_KEY_PEM.to_string()));
526
527 let body = serde_json::json!({"test": "data"});
528
529 let result = generate_authorization_signatures(
530 &ctx,
531 "test_app_id",
532 Method::POST,
533 "https://api.privy.io/v1/test".to_string(),
534 body,
535 None,
536 )
537 .await;
538
539 assert!(result.is_ok(), "Should successfully sign canonical request");
540
541 let signature = result.unwrap();
542 assert!(!signature.is_empty(), "Signature should not be empty");
543 assert!(
544 !signature.contains(',') || signature.split(',').count() == 1,
545 "Should have one signature for one key"
546 );
547 }
548
549 #[tokio::test]
550 #[traced_test]
551 async fn test_sign_canonical_request_multiple_keys() {
552 use p256::elliptic_curve::SecretKey;
554 let key_bytes = [2u8; 32];
555 let second_key = SecretKey::<p256::NistP256>::from_bytes(&key_bytes.into()).unwrap();
556
557 let ctx = AuthorizationContext::new()
558 .push(PrivateKey::new(TEST_PRIVATE_KEY_PEM.to_string()))
559 .push(second_key);
560
561 let body = serde_json::json!({"test": "data"});
562
563 let result = generate_authorization_signatures(
564 &ctx,
565 "test_app_id",
566 Method::POST,
567 "https://api.privy.io/v1/test".to_string(),
568 body,
569 None,
570 )
571 .await;
572
573 assert!(
574 result.is_ok(),
575 "Should successfully sign with multiple keys"
576 );
577
578 let signature = result.unwrap();
579 assert!(
580 signature.contains(','),
581 "Should have comma-separated signatures for multiple keys"
582 );
583 assert_eq!(
584 signature.split(',').count(),
585 2,
586 "Should have exactly two signatures"
587 );
588 }
589
590 #[tokio::test]
591 async fn test_sign_canonical_request_deterministic() {
592 let ctx =
593 AuthorizationContext::new().push(PrivateKey::new(TEST_PRIVATE_KEY_PEM.to_string()));
594
595 let body = serde_json::json!({"test": "data"});
596
597 let signature1 = generate_authorization_signatures(
598 &ctx,
599 "test_app_id",
600 Method::POST,
601 "https://api.privy.io/v1/test".to_string(),
602 body.clone(),
603 None,
604 )
605 .await
606 .unwrap();
607
608 let signature2 = generate_authorization_signatures(
609 &ctx,
610 "test_app_id",
611 Method::POST,
612 "https://api.privy.io/v1/test".to_string(),
613 body,
614 None,
615 )
616 .await
617 .unwrap();
618
619 assert_eq!(signature1, signature2, "Signatures should be deterministic");
620 }
621
622 #[test]
623 fn test_build_canonical_request_json_serialization_error() {
624 use std::f64;
626 let body = serde_json::json!({"invalid": f64::NAN});
627
628 let result = format_request_for_authorization_signature(
629 "test_app_id",
630 Method::POST,
631 "https://api.privy.io/v1/test".to_string(),
632 body,
633 None,
634 );
635
636 assert!(result.is_ok(), "serde_json handles NaN gracefully");
638 }
639
640 #[test]
642 fn test_auth_header_generation() {
643 let app_id = "test_app_id";
644 let app_secret = "test_app_secret";
645
646 let auth_header = get_auth_header(app_id, app_secret);
647
648 assert!(
649 auth_header.starts_with("Basic "),
650 "Should start with Basic "
651 );
652
653 let encoded = auth_header.strip_prefix("Basic ").unwrap();
655 let decoded = STANDARD.decode(encoded).unwrap();
656 let credentials = String::from_utf8(decoded).unwrap();
657
658 assert_eq!(credentials, "test_app_id:test_app_secret");
659 }
660
661 #[test]
662 fn test_canonical_request_url_encoding() {
663 let body = serde_json::json!({"test": "data"});
664 let url_with_query = "https://api.privy.io/v1/test?param=value&other=123";
665
666 let canonical_data = format_request_for_authorization_signature(
667 "test_app_id",
668 Method::POST,
669 url_with_query.to_string(),
670 body,
671 None,
672 )
673 .unwrap();
674
675 assert!(
676 canonical_data.contains(url_with_query),
677 "Should preserve URL as-is including query parameters"
678 );
679 }
680
681 #[test]
682 fn test_canonical_request_special_characters() {
683 let body = serde_json::json!({
684 "special": "test with spaces and símböls",
685 "unicode": "🔐🌟",
686 "escaped": "quotes \"inside\" string"
687 });
688
689 let canonical_data = format_request_for_authorization_signature(
690 "test_app_id",
691 Method::POST,
692 "https://api.privy.io/v1/test".to_string(),
693 body,
694 None,
695 )
696 .unwrap();
697
698 assert!(
700 canonical_data.contains("\\\"inside\\\""),
701 "Should escape internal quotes"
702 );
703 assert!(
704 canonical_data.contains("🔐🌟"),
705 "Should preserve Unicode characters"
706 );
707 }
708}