Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extract captcha logic into new module #2619

Merged
merged 1 commit into from
Sep 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 8 additions & 167 deletions src/internet_identity/src/anchor_management/registration.rs
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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(
Expand All @@ -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<ChallengeKey, ChallengeInfo>) {
// 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<T: RngCore>(rng: &mut T, n: usize) -> String {
let mut chars: Vec<u8> = 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<T: RngCore>(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<char, Vec<char>> = 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<char> = {
// 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> = 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<T: RngCore>(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,
Expand All @@ -224,7 +65,7 @@ pub fn register(
temp_key: Option<Principal>,
) -> RegisterResponse {
rate_limit::process_rate_limit();
if let Err(()) = check_challenge(challenge_result) {
if let Err(()) = captcha::check_challenge(challenge_result) {
return RegisterResponse::BadChallenge;
}

Expand Down
167 changes: 167 additions & 0 deletions src/internet_identity/src/anchor_management/registration/captcha.rs
Original file line number Diff line number Diff line change
@@ -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<char, Vec<char>> = 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<char> = {
// 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> = 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<ChallengeKey, ChallengeInfo>) {
// 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<T: RngCore>(rng: &mut T, n: usize) -> String {
let mut chars: Vec<u8> = 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<T: RngCore>(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<T: RngCore>(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(()),
}
})
}