1#![doc = include_str!("../README.md")]
2#![warn(missing_docs)]
3
4use aes_gcm::aead::Aead;
5use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce};
6use base64::prelude::BASE64_STANDARD;
7use base64::Engine;
8use pbkdf2::pbkdf2_hmac;
9use rand::Rng;
10use sha2::Sha256;
11use thiserror::Error;
12
13#[derive(Debug, Error)]
15pub enum Error {
16 #[error("Password is required")]
18 MissingPassword,
19
20 #[error("Fail to encrypt")]
22 EncryptError(#[from] aes_gcm::Error),
23}
24
25pub type Result<T> = std::result::Result<T, Error>;
27
28const HTML_TEMPLATE: &str = include_str!(concat!(env!("OUT_DIR"), "/decrypt.minify.html"));
29const JS_TEMPLATE: &str = include_str!(concat!(env!("OUT_DIR"), "/decrypt.minify.js"));
30
31const SALT_LEN: usize = 32;
32const NONCE_LEN: usize = 12;
33const HMAC_LEN: usize = 32;
34
35pub struct PageCryptBuilder {
37 pub password: String,
39 pub rounds: u32,
41}
42
43impl PageCryptBuilder {
44 #[allow(clippy::new_without_default)]
46 pub fn new() -> Self {
47 Self {
48 password: String::new(),
49 rounds: 600_000,
50 }
51 }
52
53 pub fn password(mut self, password: String) -> Self {
55 self.password = password;
56 self
57 }
58
59 pub fn rounds(mut self, rounds: u32) -> Self {
61 self.rounds = rounds;
62 self
63 }
64
65 pub fn build(self) -> Result<PageCrypt> {
67 if self.password.is_empty() {
68 return Err(Error::MissingPassword);
69 }
70 if self.rounds < 100_000 {
71 log::warn!(
72 "The specified number of password rounds ({}) is not secure. If possible, use at least 100_000 or more.",
73 self.rounds
74 );
75 }
76
77 let salt = getrandom::<SALT_LEN>();
78 log::info!("Salt generated for password hashing.");
79
80 let mut hmac = [0; HMAC_LEN];
81 pbkdf2_hmac::<Sha256>(self.password.as_bytes(), &salt, self.rounds, &mut hmac);
82 log::info!("Password hashed.");
83
84 Ok(PageCrypt::from_hmac(self.rounds, salt, hmac))
85 }
86}
87
88pub struct PageCrypt {
90 rounds: u32,
91 salt: [u8; SALT_LEN],
92 hmac: [u8; HMAC_LEN],
93}
94
95impl PageCrypt {
96 pub fn builder() -> PageCryptBuilder {
98 PageCryptBuilder::new()
99 }
100
101 pub fn from_hmac(rounds: u32, salt: [u8; SALT_LEN], hmac: [u8; HMAC_LEN]) -> Self {
103 Self { rounds, salt, hmac }
104 }
105
106 pub fn encrypt_payload(&self, data: &[u8]) -> Result<Vec<u8>> {
108 let nonce = getrandom::<NONCE_LEN>();
109 let nonce = Nonce::from_slice(&nonce);
110 log::info!("Nonce generated for encryption.");
111
112 let key = Key::<Aes256Gcm>::from_slice(&self.hmac);
113 let cipher = Aes256Gcm::new(key);
114 let cipher_text = cipher.encrypt(nonce, data)?;
115 log::info!("Payload encrypted.");
116
117 let mut payload = Vec::with_capacity(SALT_LEN + NONCE_LEN + cipher_text.len());
119 payload.extend_from_slice(&self.salt);
120 payload.extend_from_slice(nonce);
121 payload.extend_from_slice(&cipher_text);
122 log::info!("Payload assembled.");
123
124 Ok(payload)
125 }
126
127 pub fn encrypt_html(&self, html: &[u8]) -> Result<String> {
129 let encrypted = self.encrypt_payload(html)?;
130 log::info!("HTML encrypted.");
131
132 let encrypted = BASE64_STANDARD.encode(encrypted);
133 let encrypted = urlencoding::encode(&encrypted);
134 log::info!("HTML encoded.");
135
136 let result = HTML_TEMPLATE
137 .replacen("{{ rounds }}", &self.rounds.to_string(), 1)
138 .replacen("{{ encrypted }}", &encrypted, 1);
139 log::info!("HTML rendered.");
140
141 Ok(result)
142 }
143
144 pub fn encrypt_js(&self, js: &[u8]) -> Result<String> {
146 let encrypted = self.encrypt_payload(js)?;
147 log::info!("JS encrypted.");
148
149 let encrypted = BASE64_STANDARD.encode(encrypted);
150 log::info!("JS encoded.");
151
152 let result = JS_TEMPLATE.replacen("{{ encrypted }}", &encrypted, 1);
153 log::info!("JS rendered.");
154
155 Ok(result)
156 }
157}
158
159fn getrandom<const N: usize>() -> [u8; N] {
160 let mut buf = [0; N];
161 if getrandom::fill(&mut buf).is_err() {
162 log::warn!(
163 "Fail to generate random from system entropy. Using pseudo-random generator instead."
164 );
165 rand::rng().fill_bytes(&mut buf);
166 }
167 buf
168}