Commit f7628d02fef99b09ae2744f222927789a5928061

Cléo Rebert 2023-09-10T21:08:23

Merge pull request #61 from tmpfs/qr Expose QR code generation functions

diff --git a/Cargo.toml b/Cargo.toml
index 8d77314..d2657ff 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -12,13 +12,18 @@ homepage = "https://github.com/constantoine/totp-rs"
 keywords = ["authentication", "2fa", "totp", "hmac", "otp"]
 categories = ["authentication", "web-programming"]
 
+[workspace]
+members = [
+  "qrcodegen-image"
+]
+
 [package.metadata.docs.rs]
 features = [ "qr", "serde_support", "gen_secret" ]
 
 [features]
 default = []
 otpauth = ["url", "urlencoding"]
-qr = ["qrcodegen", "image", "base64", "otpauth"]
+qr = ["dep:qrcodegen-image", "otpauth"]
 serde_support = ["serde"]
 gen_secret = ["rand"]
 steam = []
@@ -32,8 +37,6 @@ base32 = "0.4"
 urlencoding = { version = "2.1", optional = true}
 url = { version = "2.4", optional = true }
 constant_time_eq = "0.2"
-qrcodegen = { version = "1.8", optional = true }
-image = { version = "0.24", features = ["png"], optional = true, default-features = false}
-base64 = { version = "0.21", optional = true }
 rand = { version = "0.8", features = ["std_rng", "std"], optional = true, default-features = false }
-zeroize = { version = "1.6", features = ["alloc", "derive"], optional = true }
\ No newline at end of file
+zeroize = { version = "1.6", features = ["alloc", "derive"], optional = true }
+qrcodegen-image = { version = "0.1", features = ["base64"], optional = true, path = "qrcodegen-image" }
\ No newline at end of file
diff --git a/qrcodegen-image/Cargo.toml b/qrcodegen-image/Cargo.toml
new file mode 100644
index 0000000..5927334
--- /dev/null
+++ b/qrcodegen-image/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "qrcodegen-image"
+version = "0.1.0"
+edition = "2021"
+authors = ["Cleo Rebert <cleo.rebert@gmail.com>"]
+rust-version = "1.61"
+readme = "README.md"
+license = "MIT"
+description = "Draw QR codes to a PNG canvas."
+repository = "https://github.com/constantoine/totp-rs"
+homepage = "https://github.com/constantoine/totp-rs"
+
+[package.metadata.docs.rs]
+features = [ "base64" ]
+
+[features]
+base64 = ["dep:base64"]
+
+[dependencies]
+qrcodegen = "1.8"
+image = { version = "0.24", features = ["png"], default-features = false}
+base64 = { version = "0.21", optional = true }
diff --git a/qrcodegen-image/src/lib.rs b/qrcodegen-image/src/lib.rs
new file mode 100644
index 0000000..b37eac4
--- /dev/null
+++ b/qrcodegen-image/src/lib.rs
@@ -0,0 +1,91 @@
+//! Utility functions for drawing QR codes generated using `qrcodegen`
+//! to a canvas provided by the `image` crate.
+use image::Luma;
+
+pub use image;
+pub use qrcodegen;
+
+/// Draw a QR code to an image buffer.
+pub fn draw_canvas(qr: qrcodegen::QrCode) -> image::ImageBuffer<Luma<u8>, Vec<u8>> {
+    let size = qr.size() as u32;
+    // "+ 8 * 8" is here to add padding (the white border around the QRCode)
+    // As some QRCode readers don't work without padding
+    let image_size = size * 8 + 8 * 8;
+    let mut canvas = image::GrayImage::new(image_size, image_size);
+
+    // Draw the border
+    for x in 0..image_size {
+        for y in 0..image_size {
+            if (y < 8 * 4 || y >= image_size - 8 * 4) || (x < 8 * 4 || x >= image_size - 8 * 4) {
+                canvas.put_pixel(x, y, Luma([255]));
+            }
+        }
+    }
+
+    // The QR inside the white border
+    for x_qr in 0..size {
+        for y_qr in 0..size {
+            // The canvas is a grayscale image without alpha. Hence it's only one 8-bits byte longs
+            // This clever trick to one-line the value was achieved with advanced mathematics
+            // And deep understanding of Boolean algebra.
+            let val = !qr.get_module(x_qr as i32, y_qr as i32) as u8 * 255;
+
+            // Multiply coordinates by width of pixels
+            // And take into account the 8*4 padding on top and left side
+            let x_start = x_qr * 8 + 8 * 4;
+            let y_start = y_qr * 8 + 8 * 4;
+
+            // Draw a 8-pixels-wide square
+            for x_img in x_start..x_start + 8 {
+                for y_img in y_start..y_start + 8 {
+                    canvas.put_pixel(x_img, y_img, Luma([val]));
+                }
+            }
+        }
+    }
+    canvas
+}
+
+/// Draw text to a PNG QR code.
+pub fn draw_png(text: &str) -> Result<Vec<u8>, String> {
+    use image::ImageEncoder;
+
+    let mut vec = Vec::new();
+
+    let qr: Result<qrcodegen::QrCode, String> =
+        match qrcodegen::QrCode::encode_text(text, qrcodegen::QrCodeEcc::Medium) {
+            Ok(qr) => Ok(qr),
+            Err(err) => Err(err.to_string()),
+        };
+
+    if qr.is_err() {
+        return Err(qr.err().unwrap());
+    }
+
+    let code = qr?;
+
+    // "+ 8 * 8" is here to add padding (the white border around the QRCode)
+    // As some QRCode readers don't work without padding
+    let image_size = (code.size() as u32) * 8 + 8 * 8;
+
+    let canvas = draw_canvas(code);
+
+    // Encode the canvas into a PNG
+    let encoder = image::codecs::png::PngEncoder::new(&mut vec);
+    match encoder.write_image(
+        &canvas.into_raw(),
+        image_size,
+        image_size,
+        image::ColorType::L8,
+    ) {
+        Ok(_) => Ok(vec),
+        Err(err) => Err(err.to_string()),
+    }
+}
+
+/// Draw text to a Base64-encoded PNG QR code.
+#[cfg(feature = "base64")]
+pub fn draw_base64(text: &str) -> Result<String, String> {
+    use base64::{engine::general_purpose, Engine as _};
+    Ok(draw_png(text).map(|vec| general_purpose::STANDARD.encode(vec))?)
+}
diff --git a/src/lib.rs b/src/lib.rs
index ebae025..699601b 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -52,6 +52,9 @@ mod rfc;
 mod secret;
 mod url_error;
 
+#[cfg(feature = "qr")]
+pub use qrcodegen_image;
+
 pub use rfc::{Rfc6238, Rfc6238Error};
 pub use secret::{Secret, SecretParseError};
 pub use url_error::TotpUrlError;
@@ -63,9 +66,6 @@ use serde::{Deserialize, Serialize};
 
 use core::fmt;
 
-#[cfg(feature = "qr")]
-use image::Luma;
-
 #[cfg(feature = "otpauth")]
 use url::{Host, Url};
 
@@ -450,7 +450,7 @@ impl TOTP {
     pub fn check(&self, token: &str, time: u64) -> bool {
         let basestep = time / self.step - (self.skew as u64);
         for i in 0..(self.skew as u16) * 2 + 1 {
-            let step_time = (basestep + (i as u64)) * (self.step as u64);
+            let step_time = (basestep + (i as u64)) * self.step;
 
             if constant_time_eq(self.generate(step_time).as_bytes(), token.as_bytes()) {
                 return true;
@@ -642,49 +642,10 @@ impl TOTP {
 
         format!("otpauth://{}/{}?{}", host, label, params.join("&"))
     }
+}
 
-    #[cfg(feature = "qr")]
-    fn get_qr_draw_canvas(&self, qr: qrcodegen::QrCode) -> image::ImageBuffer<Luma<u8>, Vec<u8>> {
-        let size = qr.size() as u32;
-        // "+ 8 * 8" is here to add padding (the white border around the QRCode)
-        // As some QRCode readers don't work without padding
-        let image_size = size * 8 + 8 * 8;
-        let mut canvas = image::GrayImage::new(image_size, image_size);
-
-        // Draw the border
-        for x in 0..image_size {
-            for y in 0..image_size {
-                if (y < 8 * 4 || y >= image_size - 8 * 4) || (x < 8 * 4 || x >= image_size - 8 * 4)
-                {
-                    canvas.put_pixel(x, y, Luma([255]));
-                }
-            }
-        }
-
-        // The QR inside the white border
-        for x_qr in 0..size {
-            for y_qr in 0..size {
-                // The canvas is a grayscale image without alpha. Hence it's only one 8-bits byte longs
-                // This clever trick to one-line the value was achieved with advanced mathematics
-                // And deep understanding of Boolean algebra.
-                let val = !qr.get_module(x_qr as i32, y_qr as i32) as u8 * 255;
-
-                // Multiply coordinates by width of pixels
-                // And take into account the 8*4 padding on top and left side
-                let x_start = x_qr * 8 + 8 * 4;
-                let y_start = y_qr * 8 + 8 * 4;
-
-                // Draw a 8-pixels-wide square
-                for x_img in x_start..x_start + 8 {
-                    for y_img in y_start..y_start + 8 {
-                        canvas.put_pixel(x_img, y_img, Luma([val]));
-                    }
-                }
-            }
-        }
-        canvas
-    }
-
+#[cfg(feature = "qr")]
+impl TOTP {
     /// Will return a qrcode to automatically add a TOTP as a base64 string. Needs feature `qr` to be enabled!
     /// Result will be in the form of a string containing a base64-encoded png, which you can embed in HTML without needing
     /// To store the png as a file.
@@ -696,43 +657,9 @@ impl TOTP {
     /// Which would be too long for some browsers anyway.
     ///
     /// It will also return an error in case it can't encode the qr into a png. This shouldn't happen unless either the qrcode library returns malformed data, or the image library doesn't encode the data correctly
-    #[cfg(feature = "qr")]
     pub fn get_qr(&self) -> Result<String, String> {
-        use base64::{engine::general_purpose, Engine as _};
-        use image::ImageEncoder;
-
         let url = self.get_url();
-        let mut vec = Vec::new();
-
-        let qr: Result<qrcodegen::QrCode, String> =
-            match qrcodegen::QrCode::encode_text(&url, qrcodegen::QrCodeEcc::Medium) {
-                Ok(qr) => Ok(qr),
-                Err(err) => Err(err.to_string()),
-            };
-
-        if qr.is_err() {
-            return Err(qr.err().unwrap());
-        }
-
-        let code = qr?;
-
-        // "+ 8 * 8" is here to add padding (the white border around the QRCode)
-        // As some QRCode readers don't work without padding
-        let image_size = (code.size() as u32) * 8 + 8 * 8;
-
-        let canvas = self.get_qr_draw_canvas(code);
-
-        // Encode the canvas into a PNG
-        let encoder = image::codecs::png::PngEncoder::new(&mut vec);
-        match encoder.write_image(
-            &canvas.into_raw(),
-            image_size,
-            image_size,
-            image::ColorType::L8,
-        ) {
-            Ok(_) => Ok(general_purpose::STANDARD.encode(vec)),
-            Err(err) => Err(err.to_string()),
-        }
+        qrcodegen_image::draw_base64(&url)
     }
 }
 
@@ -1270,6 +1197,7 @@ mod tests {
     #[test]
     #[cfg(feature = "qr")]
     fn generates_qr() {
+        use qrcodegen_image::qrcodegen;
         use sha2::{Digest, Sha512};
 
         let totp = TOTP::new(
@@ -1285,7 +1213,7 @@ mod tests {
         let url = totp.get_url();
         let qr = qrcodegen::QrCode::encode_text(&url, qrcodegen::QrCodeEcc::Medium)
             .expect("could not generate qr");
-        let data = totp.get_qr_draw_canvas(qr).into_raw();
+        let data = qrcodegen_image::draw_canvas(qr).into_raw();
 
         // Create hash from image
         let hash_digest = Sha512::digest(data);