Skip to main content

mdbook_pagecrypt/
lib.rs

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/// Error type for [`PageCrypt`].
14#[derive(Debug, Error)]
15pub enum Error {
16    /// Missing password
17    #[error("Password is required")]
18    MissingPassword,
19
20    /// Fail to encrypt payload
21    #[error("Fail to encrypt")]
22    EncryptError(#[from] aes_gcm::Error),
23}
24
25/// Result type for [`PageCrypt`].
26pub 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
35/// Builder for [`PageCrypt`].
36pub struct PageCryptBuilder {
37    /// Password
38    pub password: String,
39    /// Number of rounds
40    pub rounds: u32,
41}
42
43impl PageCryptBuilder {
44    /// Create a new [`PageCryptBuilder`].
45    #[allow(clippy::new_without_default)]
46    pub fn new() -> Self {
47        Self {
48            password: String::new(),
49            rounds: 600_000,
50        }
51    }
52
53    /// Set password.
54    pub fn password(mut self, password: String) -> Self {
55        self.password = password;
56        self
57    }
58
59    /// Set number of rounds.
60    pub fn rounds(mut self, rounds: u32) -> Self {
61        self.rounds = rounds;
62        self
63    }
64
65    /// Build [`PageCrypt`].
66    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
88/// PageCrypt implementation.
89pub struct PageCrypt {
90    rounds: u32,
91    salt: [u8; SALT_LEN],
92    hmac: [u8; HMAC_LEN],
93}
94
95impl PageCrypt {
96    /// Create a new [`PageCryptBuilder`].
97    pub fn builder() -> PageCryptBuilder {
98        PageCryptBuilder::new()
99    }
100
101    /// Create a new [`PageCrypt`] from hmac.
102    pub fn from_hmac(rounds: u32, salt: [u8; SALT_LEN], hmac: [u8; HMAC_LEN]) -> Self {
103        Self { rounds, salt, hmac }
104    }
105
106    /// Encrypt payload.
107    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        // salt + iv + cipher_text
118        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    /// Encrypt HTML.
128    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    /// Encrypt JS.
145    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}