From 795451bbbb741d21eec859f3b3cdd843523a997b Mon Sep 17 00:00:00 2001 From: Frederik Rothenberger Date: Tue, 24 Sep 2024 10:07:05 +0200 Subject: [PATCH] Extract captcha logic into new module (#2619) In preparation for the dynamic captcha feature, the captcha creation logic is extracted into it's own module. This makes it easier to reuse the specific parts needed. --- .../src/anchor_management/registration.rs | 175 +----------------- .../anchor_management/registration/captcha.rs | 167 +++++++++++++++++ 2 files changed, 175 insertions(+), 167 deletions(-) create mode 100644 src/internet_identity/src/anchor_management/registration/captcha.rs diff --git a/src/internet_identity/src/anchor_management/registration.rs b/src/internet_identity/src/anchor_management/registration.rs index f88191172b..0eec67d70e 100644 --- a/src/internet_identity/src/anchor_management/registration.rs +++ b/src/internet_identity/src/anchor_management/registration.rs @@ -1,29 +1,22 @@ use crate::anchor_management::{activity_bookkeeping, post_operation_bookkeeping}; +use crate::state; +use crate::state::temp_keys::TempKeyId; use crate::state::ChallengeInfo; use crate::storage::anchor::Device; -use crate::{random_salt, secs_to_nanos, state}; use candid::Principal; use ic_cdk::api::time; use ic_cdk::{caller, trap}; use internet_identity_interface::archive::types::{DeviceDataWithoutAlias, Operation}; use internet_identity_interface::internet_identity::types::*; -use rand_core::{RngCore, SeedableRng}; -use std::collections::{HashMap, HashSet}; - -use crate::state::temp_keys::TempKeyId; -#[cfg(not(feature = "dummy_captcha"))] -use captcha::filters::Wave; -use captcha::fonts::Default as DefaultFont; -use captcha::fonts::Font; -use lazy_static::lazy_static; +mod captcha; mod rate_limit; pub async fn create_challenge() -> Challenge { - let mut rng = make_rng().await; + let mut rng = captcha::make_rng().await; state::inflight_challenges_mut(|inflight_challenges| { - prune_expired_challenges(inflight_challenges); + captcha::prune_expired_challenges(inflight_challenges); // Error out if there are too many inflight challenges if inflight_challenges.len() @@ -39,10 +32,10 @@ pub async fn create_challenge() -> Challenge { const MAX_TRIES: u8 = 10; for _ in 0..MAX_TRIES { - let challenge_key = random_string(&mut rng, 10); + let challenge_key = captcha::random_string(&mut rng, 10); if !inflight_challenges.contains_key(&challenge_key) { // Then we create the CAPTCHA - let (Base64(png_base64), chars) = create_captcha(rng); + let (Base64(png_base64), chars) = captcha::create_captcha(rng); // Finally insert inflight_challenges.insert( @@ -64,158 +57,6 @@ pub async fn create_challenge() -> Challenge { }) } -/// Remove challenges older than CAPTCHA_CHALLENGE_LIFETIME from the inflight challenges map -fn prune_expired_challenges(inflight_challenges: &mut HashMap) { - // 5 mins - const CAPTCHA_CHALLENGE_LIFETIME_NS: u64 = secs_to_nanos(300); - - inflight_challenges.retain(|_, v| v.created > time() - CAPTCHA_CHALLENGE_LIFETIME_NS); -} - -// Get a random number generator based on 'raw_rand' -async fn make_rng() -> rand_chacha::ChaCha20Rng { - let seed = random_salt().await; - rand_chacha::ChaCha20Rng::from_seed(seed) -} - -// Generate an n-char long string of random characters. The characters are sampled from the rang -// a-z. -// -// NOTE: The 'rand' crate (currently) does not build on wasm32-unknown-unknown so we have to -// make-do with the RngCore trait (as opposed to Rng), therefore we have to implement this -// ourselves as opposed to using one of rand's distributions. -fn random_string(rng: &mut T, n: usize) -> String { - let mut chars: Vec = vec![]; - - // The range - let a: u8 = b'a'; - let z: u8 = b'z'; - - // n times, get a random number as u32, then shrink to u8, and finally shrink to the size of - // our range. Finally, offset by the start of our range. - for _ in 0..n { - let next: u8 = rng.next_u32() as u8 % (z - a) + a; - chars.push(next); - } - - return String::from_utf8_lossy(&chars).to_string(); -} - -#[cfg(feature = "dummy_captcha")] -fn create_captcha(rng: T) -> (Base64, String) { - let mut captcha = captcha::new_captcha_with(rng, CAPTCHA_FONT.clone()); - let captcha = captcha.set_charset(&vec!['a']).add_chars(1).view(96, 48); - - let resp = match captcha.as_base64() { - Some(png_base64) => Base64(png_base64), - None => trap("Could not get base64 of captcha"), - }; - - return (resp, captcha.chars_as_string()); -} - -lazy_static! { - /// Problematic characters that are easily mixed up by humans to "normalized" replacement. - /// I.e. the captcha will only contain a "replaced" character (values below in map) if the - /// character also appears as a "replacement" (keys below in map). All occurrences of - /// "replaced" characters in the user's challenge result will be replaced with the - /// "replacements". - /// Note: the captcha library already excludes the characters o, O and 0. - static ref CHAR_REPLACEMENTS: HashMap> = vec![ - ('c', vec!['c', 'C']), - ('i', vec!['1', 'i', 'l', 'I', 'j']), - ('s', vec!['s', 'S']), - ('x', vec!['x', 'X']), - ('y', vec!['y', 'Y']), - ('z', vec!['z', 'Z']), - ('p', vec!['p', 'P']), - ('w', vec!['w', 'W']), - ].into_iter().collect(); - - /// The font (glyphs) used when creating captchas - static ref CAPTCHA_FONT: DefaultFont = DefaultFont::new(); - - /// The character set used in CAPTCHA challenges (font charset with replacements) - static ref CHALLENGE_CHARSET: Vec = { - // To get the final charset: - // * Start with all chars supported by the font by default - // * Remove all the chars that will be "replaced" - // * Add (potentially re-add) replacement chars - let mut chars = CAPTCHA_FONT.chars(); - { - let dropped: HashSet = CHAR_REPLACEMENTS.values().flat_map(|x| x.clone()).collect(); - chars.retain(|c| !dropped.contains(c)); - } - - { - chars.append(&mut CHAR_REPLACEMENTS.keys().copied().collect()); - } - - chars - }; -} - -const CAPTCHA_LENGTH: usize = 5; -#[cfg(not(feature = "dummy_captcha"))] -fn create_captcha(rng: T) -> (Base64, String) { - let mut captcha = captcha::new_captcha_with(rng, CAPTCHA_FONT.clone()); - - let captcha = captcha - // Replace the default charset with our more readable charset - .set_charset(&CHALLENGE_CHARSET) - // add some characters - .add_chars(CAPTCHA_LENGTH as u32) - .apply_filter(Wave::new(2.0, 20.0).horizontal()) - .apply_filter(Wave::new(2.0, 20.0).vertical()) - .view(220, 120); - // if you ever change the size of the captcha, make sure to also change the - // CSS in the frontend to match the new size (.c-captcha-placeholder) - - let resp = match captcha.as_base64() { - Some(png_base64) => Base64(png_base64), - None => trap("Could not get base64 of captcha"), - }; - - (resp, captcha.chars_as_string()) -} - -// Check whether the CAPTCHA challenge was solved -fn check_challenge(res: ChallengeAttempt) -> Result<(), ()> { - // avoid processing too many characters - if res.chars.len() > CAPTCHA_LENGTH { - return Err(()); - } - // Normalize challenge attempts by replacing characters that are not in the captcha character set - // with the respective replacement from CHAR_REPLACEMENTS. - let normalized_challenge_res: String = res - .chars - .chars() - .map(|c| { - // Apply all replacements - *CHAR_REPLACEMENTS - .iter() - // For each key, see if the char matches any of the values (replaced chars) and if - // so replace with the key itself (replacement char) - .find_map(|(k, v)| if v.contains(&c) { Some(k) } else { None }) - .unwrap_or(&c) - }) - .collect(); - - state::inflight_challenges_mut(|inflight_challenges| { - prune_expired_challenges(inflight_challenges); - - match inflight_challenges.remove(&res.key) { - Some(challenge) => { - if normalized_challenge_res != challenge.chars { - return Err(()); - } - Ok(()) - } - None => Err(()), - } - }) -} - pub fn register( device_data: DeviceData, challenge_result: ChallengeAttempt, @@ -224,7 +65,7 @@ pub fn register( temp_key: Option, ) -> RegisterResponse { rate_limit::process_rate_limit(); - if let Err(()) = check_challenge(challenge_result) { + if let Err(()) = captcha::check_challenge(challenge_result) { return RegisterResponse::BadChallenge; } diff --git a/src/internet_identity/src/anchor_management/registration/captcha.rs b/src/internet_identity/src/anchor_management/registration/captcha.rs new file mode 100644 index 0000000000..2a902d1c96 --- /dev/null +++ b/src/internet_identity/src/anchor_management/registration/captcha.rs @@ -0,0 +1,167 @@ +use crate::state::ChallengeInfo; +use crate::{random_salt, secs_to_nanos, state}; +use captcha::fonts::Default as DefaultFont; +use captcha::fonts::Font; +use ic_cdk::api::time; +use ic_cdk::trap; +use internet_identity_interface::internet_identity::types::{ + Base64, ChallengeAttempt, ChallengeKey, +}; +use lazy_static::lazy_static; +use rand_core::{RngCore, SeedableRng}; +use std::collections::{HashMap, HashSet}; + +lazy_static! { + /// Problematic characters that are easily mixed up by humans to "normalized" replacement. + /// I.e. the captcha will only contain a "replaced" character (values below in map) if the + /// character also appears as a "replacement" (keys below in map). All occurrences of + /// "replaced" characters in the user's challenge result will be replaced with the + /// "replacements". + /// Note: the captcha library already excludes the characters o, O and 0. + static ref CHAR_REPLACEMENTS: HashMap> = vec![ + ('c', vec!['c', 'C']), + ('i', vec!['1', 'i', 'l', 'I', 'j']), + ('s', vec!['s', 'S']), + ('x', vec!['x', 'X']), + ('y', vec!['y', 'Y']), + ('z', vec!['z', 'Z']), + ('p', vec!['p', 'P']), + ('w', vec!['w', 'W']), + ].into_iter().collect(); + + /// The font (glyphs) used when creating captchas + static ref CAPTCHA_FONT: DefaultFont = DefaultFont::new(); + + /// The character set used in CAPTCHA challenges (font charset with replacements) + static ref CHALLENGE_CHARSET: Vec = { + // To get the final charset: + // * Start with all chars supported by the font by default + // * Remove all the chars that will be "replaced" + // * Add (potentially re-add) replacement chars + let mut chars = CAPTCHA_FONT.chars(); + { + let dropped: HashSet = CHAR_REPLACEMENTS.values().flat_map(|x| x.clone()).collect(); + chars.retain(|c| !dropped.contains(c)); + } + + { + chars.append(&mut CHAR_REPLACEMENTS.keys().copied().collect()); + } + + chars + }; +} + +/// Remove challenges older than CAPTCHA_CHALLENGE_LIFETIME from the inflight challenges map +pub fn prune_expired_challenges(inflight_challenges: &mut HashMap) { + // 5 mins + const CAPTCHA_CHALLENGE_LIFETIME_NS: u64 = secs_to_nanos(300); + + inflight_challenges.retain(|_, v| v.created > time() - CAPTCHA_CHALLENGE_LIFETIME_NS); +} + +// Get a random number generator based on 'raw_rand' +pub async fn make_rng() -> rand_chacha::ChaCha20Rng { + let seed = random_salt().await; + rand_chacha::ChaCha20Rng::from_seed(seed) +} + +// Generate an n-char long string of random characters. The characters are sampled from the rang +// a-z. +// +// NOTE: The 'rand' crate (currently) does not build on wasm32-unknown-unknown so we have to +// make-do with the RngCore trait (as opposed to Rng), therefore we have to implement this +// ourselves as opposed to using one of rand's distributions. +pub fn random_string(rng: &mut T, n: usize) -> String { + let mut chars: Vec = vec![]; + + // The range + let a: u8 = b'a'; + let z: u8 = b'z'; + + // n times, get a random number as u32, then shrink to u8, and finally shrink to the size of + // our range. Finally, offset by the start of our range. + for _ in 0..n { + let next: u8 = rng.next_u32() as u8 % (z - a) + a; + chars.push(next); + } + + return String::from_utf8_lossy(&chars).to_string(); +} + +#[cfg(feature = "dummy_captcha")] +pub fn create_captcha(rng: T) -> (Base64, String) { + let mut captcha = captcha::new_captcha_with(rng, CAPTCHA_FONT.clone()); + let captcha = captcha.set_charset(&vec!['a']).add_chars(1).view(96, 48); + + let resp = match captcha.as_base64() { + Some(png_base64) => Base64(png_base64), + None => trap("Could not get base64 of captcha"), + }; + + return (resp, captcha.chars_as_string()); +} + +const CAPTCHA_LENGTH: usize = 5; + +#[cfg(not(feature = "dummy_captcha"))] +pub fn create_captcha(rng: T) -> (Base64, String) { + use captcha::filters::Wave; + + let mut captcha = captcha::new_captcha_with(rng, CAPTCHA_FONT.clone()); + + let captcha = captcha + // Replace the default charset with our more readable charset + .set_charset(&CHALLENGE_CHARSET) + // add some characters + .add_chars(CAPTCHA_LENGTH as u32) + .apply_filter(Wave::new(2.0, 20.0).horizontal()) + .apply_filter(Wave::new(2.0, 20.0).vertical()) + .view(220, 120); + // if you ever change the size of the captcha, make sure to also change the + // CSS in the frontend to match the new size (.c-captcha-placeholder) + + let resp = match captcha.as_base64() { + Some(png_base64) => Base64(png_base64), + None => trap("Could not get base64 of captcha"), + }; + + (resp, captcha.chars_as_string()) +} + +// Check whether the CAPTCHA challenge was solved +pub fn check_challenge(res: ChallengeAttempt) -> Result<(), ()> { + // avoid processing too many characters + if res.chars.len() > CAPTCHA_LENGTH { + return Err(()); + } + // Normalize challenge attempts by replacing characters that are not in the captcha character set + // with the respective replacement from CHAR_REPLACEMENTS. + let normalized_challenge_res: String = res + .chars + .chars() + .map(|c| { + // Apply all replacements + *CHAR_REPLACEMENTS + .iter() + // For each key, see if the char matches any of the values (replaced chars) and if + // so replace with the key itself (replacement char) + .find_map(|(k, v)| if v.contains(&c) { Some(k) } else { None }) + .unwrap_or(&c) + }) + .collect(); + + state::inflight_challenges_mut(|inflight_challenges| { + prune_expired_challenges(inflight_challenges); + + match inflight_challenges.remove(&res.key) { + Some(challenge) => { + if normalized_challenge_res != challenge.chars { + return Err(()); + } + Ok(()) + } + None => Err(()), + } + }) +}