pyra-privy 0.9.3

Privy authentication client core: P-256 signing, wallet lookup, transaction signing
Documentation
use crate::error::{PrivyError, PrivyResult};
use crate::models::{SignTransactionResponse, Wallet, WalletsResponse};
use base64::Engine;
use p256::ecdsa::{signature::Signer, Signature, SigningKey};
use p256::pkcs8::DecodePrivateKey;
use reqwest::Client;
use secrecy::{ExposeSecret, SecretString};
use std::time::Duration;

const DEFAULT_API_URL: &str = "https://api.privy.io";
const DEFAULT_TIMEOUT_SECS: u64 = 30;

/// Configuration for creating a [`PrivyClient`].
///
/// Sensitive fields (`app_secret`, `authorization_key`) are wrapped in
/// `SecretString` to prevent accidental logging via `Debug`.
pub struct PrivyConfig {
    /// Privy API base URL (defaults to `https://api.privy.io`).
    pub api_url: String,
    /// Privy app ID.
    pub app_id: String,
    /// Privy app secret (used for HTTP basic auth).
    pub app_secret: SecretString,
    /// Authorization key in `"wallet-auth:<base64-pkcs8-der>"` format.
    pub authorization_key: SecretString,
}

impl PrivyConfig {
    pub fn new(app_id: String, app_secret: SecretString, authorization_key: SecretString) -> Self {
        Self {
            api_url: DEFAULT_API_URL.to_string(),
            app_id,
            app_secret,
            authorization_key,
        }
    }

    pub fn with_api_url(mut self, url: String) -> Self {
        self.api_url = url;
        self
    }
}

/// Privy API client for wallet operations and transaction signing.
///
/// Shared core used by both api-v2 and settlement-service:
/// - P-256 ECDSA authorization signatures
/// - Wallet lookup (GET /v1/wallets)
/// - Transaction signing (POST /v1/wallets/{id}/rpc)
#[derive(Clone)]
pub struct PrivyClient {
    http_client: Client,
    api_url: String,
    app_id: String,
    app_secret: SecretString,
    authorization_signing_key: SigningKey,
}

impl PrivyClient {
    pub fn new(config: PrivyConfig) -> PrivyResult<Self> {
        let authorization_signing_key =
            parse_authorization_key(config.authorization_key.expose_secret())?;
        let http_client = Client::builder()
            .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
            .build()
            .map_err(|e| PrivyError::Config(format!("Failed to build HTTP client: {e}")))?;

        Ok(Self {
            http_client,
            api_url: config.api_url.trim_end_matches('/').to_string(),
            app_id: config.app_id,
            app_secret: config.app_secret,
            authorization_signing_key,
        })
    }

    /// Return the app ID.
    pub fn app_id(&self) -> &str {
        &self.app_id
    }

    /// Get the user's first Solana wallet from Privy.
    pub async fn get_user_solana_wallet(&self, user_did: &str) -> PrivyResult<Option<Wallet>> {
        let response = self
            .http_client
            .get(format!("{}/v1/wallets", self.api_url))
            .query(&[("user_id", user_did), ("chain_type", "solana")])
            .basic_auth(&self.app_id, Some(self.app_secret.expose_secret()))
            .header("privy-app-id", &self.app_id)
            .send()
            .await?;

        let status = response.status();
        if !status.is_success() {
            let body = response
                .text()
                .await
                .unwrap_or_else(|_| "Unknown error".to_string());
            return Err(PrivyError::Api {
                status: status.as_u16(),
                body,
            });
        }

        let wallets: WalletsResponse = response.json().await?;
        Ok(wallets.data.into_iter().find(|w| w.chain_type == "solana"))
    }

    /// Sign a transaction using a Privy-managed wallet.
    ///
    /// Takes the Privy wallet ID and a base64-encoded serialized transaction.
    /// Returns the signed transaction as base64.
    pub async fn sign_transaction(
        &self,
        wallet_id: &str,
        transaction_base64: &str,
    ) -> PrivyResult<String> {
        let url = format!("{}/v1/wallets/{}/rpc", self.api_url, wallet_id);

        let body = serde_json::json!({
            "method": "signTransaction",
            "params": {
                "transaction": transaction_base64,
                "encoding": "base64"
            }
        });

        let authorization_signature = self.compute_authorization_signature("POST", &url, &body)?;

        let response = self
            .http_client
            .post(&url)
            .basic_auth(&self.app_id, Some(self.app_secret.expose_secret()))
            .header("privy-app-id", &self.app_id)
            .header("privy-authorization-signature", &authorization_signature)
            .json(&body)
            .send()
            .await?;

        let status = response.status();
        if !status.is_success() {
            let body = response
                .text()
                .await
                .unwrap_or_else(|_| "Unknown error".to_string());
            return Err(PrivyError::Api {
                status: status.as_u16(),
                body,
            });
        }

        let resp: SignTransactionResponse = response.json().await?;
        Ok(resp.data.signed_transaction)
    }

    /// Compute the `privy-authorization-signature` header value.
    ///
    /// P-256 ECDSA signature over canonical JSON payload, encoded as base64 DER.
    /// serde_json serializes `json!({})` maps using BTreeMap (alphabetical key order),
    /// which satisfies RFC 8785 for our payload.
    pub fn compute_authorization_signature(
        &self,
        http_method: &str,
        url: &str,
        body: &serde_json::Value,
    ) -> PrivyResult<String> {
        let signing_payload = serde_json::json!({
            "version": 1,
            "method": http_method,
            "url": url,
            "body": body,
            "headers": {
                "privy-app-id": self.app_id
            }
        });

        let payload_string = serde_json::to_string(&signing_payload)?;
        let signature: Signature = self
            .authorization_signing_key
            .sign(payload_string.as_bytes());

        Ok(base64::engine::general_purpose::STANDARD.encode(signature.to_der()))
    }

    /// Make a basic-auth-only request (no authorization signature).
    /// Used for GET endpoints like /v1/wallets and /v1/policies/{id}.
    pub async fn get_with_basic_auth(&self, url: &str) -> PrivyResult<reqwest::Response> {
        let response = self
            .http_client
            .get(url)
            .basic_auth(&self.app_id, Some(self.app_secret.expose_secret()))
            .header("privy-app-id", &self.app_id)
            .send()
            .await?;
        Ok(response)
    }

    /// Make a request with authorization signature.
    /// Used for POST/PATCH endpoints that modify wallet state.
    pub async fn request_with_signature(
        &self,
        method: reqwest::Method,
        url: &str,
        body: &serde_json::Value,
    ) -> PrivyResult<reqwest::Response> {
        let authorization_signature =
            self.compute_authorization_signature(method.as_str(), url, body)?;

        let response = self
            .http_client
            .request(method, url)
            .basic_auth(&self.app_id, Some(self.app_secret.expose_secret()))
            .header("privy-app-id", &self.app_id)
            .header("privy-authorization-signature", &authorization_signature)
            .json(body)
            .send()
            .await?;
        Ok(response)
    }
}

/// Parse the authorization key from config format `"wallet-auth:<base64-pkcs8-der>"`.
fn parse_authorization_key(authorization_key: &str) -> PrivyResult<SigningKey> {
    let key_b64 = authorization_key
        .strip_prefix("wallet-auth:")
        .ok_or_else(|| {
            PrivyError::Config("authorization_key must start with 'wallet-auth:'".to_string())
        })?;

    let der_bytes = base64::engine::general_purpose::STANDARD
        .decode(key_b64)
        .map_err(|e| PrivyError::Config(format!("Invalid authorization key base64: {e}")))?;

    SigningKey::from_pkcs8_der(&der_bytes)
        .map_err(|e| PrivyError::Config(format!("Invalid P-256 PKCS#8 private key: {e}")))
}

#[cfg(test)]
#[allow(
    clippy::allow_attributes,
    clippy::allow_attributes_without_reason,
    clippy::unwrap_used,
    clippy::expect_used,
    clippy::panic,
    clippy::arithmetic_side_effects
)]
mod tests {
    use super::*;

    #[test]
    fn parse_authorization_key_missing_prefix() {
        let result = parse_authorization_key("invalid-key");
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(
            err.to_string().contains("wallet-auth:"),
            "error should mention expected prefix: {}",
            err
        );
    }

    #[test]
    fn parse_authorization_key_invalid_base64() {
        let result = parse_authorization_key("wallet-auth:not-valid-base64!!!");
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(
            err.to_string().contains("base64"),
            "error should mention base64: {}",
            err
        );
    }

    #[test]
    fn parse_authorization_key_invalid_der() {
        // Valid base64 but not a valid PKCS#8 key
        let result = parse_authorization_key("wallet-auth:AAAA");
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(
            err.to_string().contains("P-256") || err.to_string().contains("PKCS"),
            "error should mention key format: {}",
            err
        );
    }

    #[test]
    fn compute_signature_deterministic() {
        use p256::pkcs8::EncodePrivateKey;

        // Generate a test key
        let signing_key = SigningKey::random(&mut p256::elliptic_curve::rand_core::OsRng);
        let der = signing_key
            .to_pkcs8_der()
            .expect("should serialize to PKCS8");
        let key_b64 = base64::engine::general_purpose::STANDARD.encode(der.as_bytes());
        let auth_key = format!("wallet-auth:{key_b64}");

        let config = PrivyConfig::new(
            "test-app-id".to_string(),
            SecretString::from("test-secret".to_string()),
            SecretString::from(auth_key),
        );
        let client = PrivyClient::new(config).expect("should create client");

        let body = serde_json::json!({"test": "data"});
        let sig1 = client
            .compute_authorization_signature("POST", "https://api.privy.io/test", &body)
            .expect("should compute signature");
        let sig2 = client
            .compute_authorization_signature("POST", "https://api.privy.io/test", &body)
            .expect("should compute signature");

        // ECDSA with P-256 uses random nonce, so signatures differ each time.
        // But both should be valid base64 and non-empty.
        assert!(!sig1.is_empty());
        assert!(!sig2.is_empty());
        // Both should be valid base64
        assert!(base64::engine::general_purpose::STANDARD
            .decode(&sig1)
            .is_ok());
        assert!(base64::engine::general_purpose::STANDARD
            .decode(&sig2)
            .is_ok());
    }

    #[test]
    fn client_creation_with_valid_key() {
        use p256::pkcs8::EncodePrivateKey;

        let signing_key = SigningKey::random(&mut p256::elliptic_curve::rand_core::OsRng);
        let der = signing_key.to_pkcs8_der().expect("should encode");
        let key_b64 = base64::engine::general_purpose::STANDARD.encode(der.as_bytes());

        let config = PrivyConfig::new(
            "app-id".to_string(),
            SecretString::from("secret".to_string()),
            SecretString::from(format!("wallet-auth:{key_b64}")),
        );
        let client = PrivyClient::new(config);
        assert!(client.is_ok());
    }
}