fitgirl_decrypt/
lib.rs

1#![doc = include_str!("../../README.md")]
2#![cfg_attr(feature = "nightly", feature(doc_cfg))]
3
4use std::num::NonZero;
5
6use base64::{prelude::BASE64_STANDARD, Engine};
7
8mod types;
9pub use types::{Attachment, Cipher, CipherInfo, CompressionType};
10
11mod crypto;
12use crypto::decrypt_aes_256_gcm;
13
14mod error;
15pub use error::Error;
16
17/// [`Paste`] stores pasteid and key.
18#[allow(unused)]
19#[derive(Debug, Clone)]
20pub struct Paste<'a> {
21    key: Vec<u8>,
22    key_base58: &'a str,
23    pasteid: &'a str,
24    base_url: &'a str,
25}
26
27/// Alias of Result with [`Error`] as E.
28pub type Result<T> = std::result::Result<T, Error>;
29
30impl Paste<'_> {
31    /// Parse paste info from an URL.
32    ///
33    /// ```rust
34    /// use fitgirl_decrypt::{Paste, Error};
35    ///
36    /// let url1 = "https://paste.fitgirl-repacks.site/?225484ced69df1d1#SKYwGaZwZmRbN2fR4R9QQJzLTmzpctbDE7kZNpwesRW";
37    /// let url2 = "https://paste.fitgirl-repacks.site/?225484ced69df1d1#kWYCcn3qmpehWMMBmZ1NJciKNA6eXfK6LPzwgGXFdJ";
38    ///
39    /// assert!(Paste::parse_url(url1).is_ok());
40    /// assert!(matches!(Paste::parse_url(url2), Err(Error::KeyLengthMismatch(31))));
41    /// ```
42    pub fn parse_url<'a>(url: &'a str) -> Result<Paste<'a>> {
43        let (base_url, pasteinfo) = url.split_once('?').ok_or(Error::IllFormedURL)?;
44        let (pasteid, key_base58) = pasteinfo.split_once("#").ok_or(Error::IllFormedURL)?;
45
46        let paste = Self::try_from_key_and_pasteid(key_base58, pasteid)?;
47        Ok(Paste { base_url, ..paste })
48    }
49
50    /// Parse paste info from key_base58 (the url segment after '#') and pasteid.
51    ///
52    /// ```rust
53    /// use fitgirl_decrypt::{Paste, Error};
54    ///
55    /// assert!(
56    ///     Paste::try_from_key_and_pasteid(
57    ///         "SKYwGaZwZmRbN2fR4R9QQJzLTmzpctbDE7kZNpwesRW",
58    ///         "",
59    ///     )
60    ///     .is_ok()
61    /// );
62    /// assert!(
63    ///     matches!(
64    ///         Paste::try_from_key_and_pasteid(
65    ///             "kWYCcn3qmpehWMMBmZ1NJciKNA6eXfK6LPzwgGXFdJ",
66    ///             "225484ced69df1d1",
67    ///         ),
68    ///         Err(Error::KeyLengthMismatch(31)),
69    ///     )
70    /// );
71    /// ```
72    pub fn try_from_key_and_pasteid<'a>(
73        key_base58: &'a str,
74        pasteid: &'a str,
75    ) -> Result<Paste<'a>> {
76        let key = bs58::decode(key_base58).into_vec()?;
77
78        if key.len() != 32 {
79            return Err(Error::KeyLengthMismatch(key.len()));
80        }
81
82        let base_url = "https://paste.fitgirl-repacks.site/";
83
84        Ok(Paste {
85            key,
86            key_base58,
87            pasteid,
88            base_url,
89        })
90    }
91
92    /// Decrypt paste from [`CipherInfo`].
93    pub fn decrypt(&self, cipher: impl AsRef<CipherInfo>) -> Result<Attachment> {
94        let CipherInfo { adata, ct } = cipher.as_ref();
95
96        let Cipher {
97            cipher_iv,
98            kdf_salt,
99            kdf_iterations,
100            compression_type,
101            ..
102        } = &adata.cipher;
103
104        let master_key = &self.key;
105        let ct = BASE64_STANDARD.decode(ct)?;
106        let cipher_iv = BASE64_STANDARD.decode(cipher_iv)?;
107        let kdf_salt = BASE64_STANDARD.decode(kdf_salt)?;
108        let iterations = NonZero::new(*kdf_iterations).ok_or(Error::ZeroIterations)?;
109        let algorithm = ring::pbkdf2::PBKDF2_HMAC_SHA256;
110
111        let mut derived_key = [0u8; 32];
112        ring::pbkdf2::derive(
113            algorithm,
114            iterations,
115            &kdf_salt,
116            master_key,
117            &mut derived_key,
118        );
119
120        let adata_json = serde_json::to_string(&adata)?;
121
122        let data =
123            decrypt_aes_256_gcm(&ct, &derived_key, cipher_iv, &adata_json, compression_type)?;
124        Ok(serde_json::from_slice(&data)?)
125    }
126
127    /// Get [`CipherInfo`] to decrypt synchronously, with [`ureq`].
128    #[cfg_attr(feature = "nightly", doc(cfg(feature = "ureq")))]
129    #[cfg(feature = "ureq")]
130    pub fn request(&self) -> Result<CipherInfo> {
131        use ureq::{http::header::ACCEPT, Agent};
132
133        let pasteid = self.pasteid;
134        let key_base58 = self.key_base58;
135
136        let base = self.base_url;
137        let init_cookies = format!("{base}?{pasteid}#{key_base58}");
138        let cipher_info = format!("{base}?pasteid={pasteid}");
139
140        let agent = Agent::new_with_defaults();
141
142        agent.get(init_cookies).call()?;
143
144        let resp = agent
145            .get(cipher_info)
146            .header(ACCEPT, "application/json")
147            .call()?
148            .body_mut()
149            .read_json()?;
150        Ok(resp)
151    }
152
153    /// Get [`CipherInfo`] to decrypt asynchronously, with [`reqwest`].
154    ///
155    /// ## NOTE
156    ///
157    /// Because reqwest depends on tokio types, if you are using an async runtime
158    ///
159    /// other than tauri and tokio, try to spawn this inside tokio context.
160    ///
161    /// Also see [async_compat](https://docs.rs/async-compat/latest/async_compat/)
162    #[cfg_attr(feature = "nightly", doc(cfg(feature = "reqwest")))]
163    #[cfg(feature = "reqwest")]
164    pub async fn request_async(&self) -> Result<CipherInfo> {
165        use reqwest::{header::ACCEPT, ClientBuilder};
166
167        let pasteid = self.pasteid;
168        let key_base58 = self.key_base58;
169
170        let base = self.base_url;
171        let init_cookies = format!("{base}?{pasteid}#{key_base58}");
172        let cipher_info = format!("{base}?pasteid={pasteid}");
173
174        let client = ClientBuilder::new().gzip(true).build()?;
175        client.get(init_cookies).send().await?;
176
177        let resp = client
178            .get(cipher_info)
179            .header(ACCEPT, "application/json")
180            .send()
181            .await?
182            .json()
183            .await?;
184        Ok(resp)
185    }
186
187    /// Get [`CipherInfo`] to decrypt asynchronously, with [`nyquest`].
188    ///
189    /// Nyquest can be much more lightweight than [`reqwest`], though it's experimental.
190    #[cfg_attr(feature = "nightly", doc(cfg(feature = "nyquest")))]
191    #[cfg(feature = "nyquest")]
192    pub async fn request_async_ny(&self) -> Result<CipherInfo> {
193        use nyquest::{r#async::Request, ClientBuilder};
194
195        let pasteid = self.pasteid;
196        let key_base58 = self.key_base58;
197
198        let base = self.base_url;
199        let init_cookies = format!("/?{pasteid}#{key_base58}");
200        let cipher_info = format!("/?pasteid={pasteid}");
201
202        let client = ClientBuilder::default()
203            .base_url(base)
204            .build_async()
205            .await?;
206        client.request(Request::get(init_cookies)).await?;
207
208        let resp = client
209            .request(Request::get(cipher_info).with_header("Accept", "application/json"))
210            .await?
211            .json()
212            .await?;
213        Ok(resp)
214    }
215}
216
217/// re-export of base64 for torrent decoding.
218pub use base64;