While studying the development documentation of IC, I carefully read about the security best practices, which mentioned “For expensive calls, consider using captchas or proof of work”.
Our project, ICPanda, Upcoming ICPanda DAO Launch SNS, needs to use CAPTCHAs for its airdrop feature.
After some research, I found that existing Rust CAPTCHA crates all internally depend on random number generator, making them unsuitable for direct use in IC’s canisters. This includes the nmattia/captcha
used by the Internet Identity project, which involved some complex hacks.
Therefore, I decided to write my own CAPTCHA library that can accept externally provided random numbers, allowing it to be used in canisters. It is the ic-captcha
crate: GitHub - ldclabs/ic-captcha: Generating CAPTCHAs with given random bytes for the Internet Computer.
Usage example:
use ic_captcha::CaptchaBuilder;
let builder = CaptchaBuilder::new();
let captcha = builder.generate(b"random seed 0", None);
println!("text: {}", captcha.text());
println!("base_img: {}", captcha.to_base64(0));
The ICPanda project integrates the ic-captcha crate to generate CAPTCHAs, preventing bots from calling the airdrop interface. The complete implementation can be seen at ic-panda/src/ic_panda_luckypool/src/api_update.rs at main · ldclabs/ic-panda · GitHub.
Unlike the CAPTCHA implementation by Internet Identity, ICPanda does not temporarily save the CAPTCHA challenge state in the canister. Instead, it signs it and returns it to the caller, who must then pass back the CAPTCHA code and the challenge state in subsequent requests. This challenge state also includes a check for expiration time.
The core code is as follows:
#[ic_cdk::update(guard = "is_authenticated")]
async fn captcha() -> Result<types::CaptchaOutput, String> {
let rr = ic_cdk::api::management_canister::main::raw_rand()
.await
.map_err(|_err| "failed to get random bytes".to_string())?;
let captcha = CAPTCHA_BUILDER.generate(&rr.0, None);
let now = ic_cdk::api::time();
let challenge = types::ChallengeCode {
code: captcha.text().to_lowercase(),
};
let challenge =
store::captcha::with_secret(|secret| challenge.sign_to_base64(secret, now / SECOND));
Ok(types::CaptchaOutput {
img_base64: captcha.to_base64(0),
challenge,
})
}
#[ic_cdk::update(guard = "is_authenticated")]
async fn airdrop(args: types::AirdropClaimInput) -> Result<Nat, String> {
let now = ic_cdk::api::time() / SECOND;
let expire_at = now - CAPTCHA_EXPIRE_SEC;
let challenge = types::ChallengeCode {
code: args.code.to_lowercase(),
};
store::captcha::with_secret(|secret| {
challenge.verify_from_base64(secret, expire_at, &args.challenge)
})?;
let user = ic_cdk::caller();
...
}