altcha

April 7, 2026 · View on GitHub

A Rust implementation of the ALTCHA Proof-of-Work v2 protocol.

ALTCHA is a privacy-friendly, self-hosted CAPTCHA alternative that uses proof-of-work challenges to block bots.

Installation

[dependencies]
altcha = "0"

To enable optional KDF algorithms:

[dependencies]
altcha = { version = "0", features = ["argon2", "scrypt"] }

Examples

Features

FeatureEnablesDefault
argon2Argon2id algorithmno
scryptscrypt algorithmno

PBKDF2 and iterative SHA algorithms are always available.

Quick start

use altcha::{
    create_challenge, solve_challenge, verify_solution,
    CreateChallengeOptions, SolveChallengeOptions, VerifySolutionOptions,
};

// ── Server: create a challenge ───────────────────────────────────────────────
let challenge = create_challenge(CreateChallengeOptions {
    algorithm: "PBKDF2/SHA-256".to_string(),
    // PBKDF2 iterations
    cost: 5_000,
    // Random counter enables deterministic mode
    counter: Some(rand::thread_rng().gen_range(5_000..=10_000)),
    // Expire challenges after 10 minutes so they cannot be reused indefinitely.
    expires_at: Some(
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_secs()
            + 600,
    ),
    hmac_signature_secret: Some("my-hmac-secret".to_string()),
    // Required for deterministic mode: signs the derived key so verification
    // can skip re-deriving it (fast path).
    hmac_key_signature_secret: Some("my-key-secret".to_string()),
    ..Default::default()
})?;

// Serialize and send `challenge` to the client (e.g. as JSON).

// ── Client: solve the challenge ──────────────────────────────────────────────
let solution = solve_challenge(SolveChallengeOptions::new(&challenge))?
    .expect("no solution found within timeout");

// Send `solution` back to the server alongside `challenge`.

// ── Server: verify the solution ──────────────────────────────────────────────
let result = verify_solution(VerifySolutionOptions {
    hmac_key_signature_secret: Some("my-key-secret".to_string()),
    ..VerifySolutionOptions::new(&challenge, &solution, "my-hmac-secret")
})?;

assert!(result.verified);

API

create_challenge

pub fn create_challenge(options: CreateChallengeOptions) -> Result<Challenge>

Generates a new challenge with a random 16-byte nonce and salt. If hmac_signature_secret is set the challenge is signed with HMAC so that the server can detect tampering on verification.

CreateChallengeOptions fields (all optional except algorithm and cost):

FieldTypeDefaultDescription
algorithmStringKDF algorithm string (see Algorithms)
costu32Algorithm cost (iterations, time cost, N for scrypt)
counterOption<u32>NoneEnables deterministic mode; key prefix is derived from this counter
dataOption<BTreeMap<String, Value>>NoneArbitrary metadata embedded in the signed challenge
expires_atOption<u64>NoneUnix timestamp (seconds) after which the challenge is invalid
hmac_algorithmHmacAlgorithmSha256HMAC digest algorithm
hmac_signature_secretOption<String>NoneSecret for signing the challenge; if absent the challenge is unsigned
hmac_key_signature_secretOption<String>NoneSecret for signing the derived key (deterministic mode only)
key_lengthusize32Output key length in bytes
key_prefixString"00"Required hex prefix the derived key must start with
key_prefix_lengthOption<usize>key_length / 2Bytes used as prefix in deterministic mode
memory_costOption<u32>NoneMemory cost in KiB (Argon2id, scrypt r)
parallelismOption<u32>NoneParallelism factor (Argon2id, scrypt p; default 1)

solve_challenge

pub fn solve_challenge(options: SolveChallengeOptions<'_>) -> Result<Option<Solution>>

Iterates counter values from counter_start, incrementing by counter_step, until the derived key starts with the required prefix. Returns None when timeout_ms elapses.

SolveChallengeOptions fields:

FieldDefaultDescription
challengeReference to the challenge to solve
counter_start0First counter value to try
counter_step1Increment per attempt
timeout_ms90_000Maximum solve time in milliseconds

Use SolveChallengeOptions::new(&challenge) to get sensible defaults.


verify_solution

pub fn verify_solution(options: VerifySolutionOptions<'_>) -> Result<VerifySolutionResult>

Verifies a submitted solution in three steps:

  1. Expiration — rejects challenges whose expires_at has passed.
  2. Signature — recomputes HMAC(canonical_json(parameters), secret) and compares in constant time.
  3. Solution — either verifies the submitted key against a stored key signature (fast path, deterministic mode) or re-derives the key from the submitted counter (full path).

VerifySolutionOptions fields:

FieldDefaultDescription
challengeThe original challenge
solutionThe solution submitted by the client
hmac_signature_secretSecret used when the challenge was created
hmac_algorithmSha256HMAC digest algorithm
hmac_key_signature_secretNoneSecret for fast-path key signature verification

Use VerifySolutionOptions::new(&challenge, &solution, "secret") for defaults.

VerifySolutionResult:

pub struct VerifySolutionResult {
    pub verified: bool,
    pub expired: bool,
    pub invalid_signature: Option<bool>,  // None if expired before reaching this check
    pub invalid_solution: Option<bool>,   // None if signature check failed
    pub time: f64,                        // milliseconds to verify
}

sign_challenge

pub fn sign_challenge(
    algorithm: &HmacAlgorithm,
    parameters: &mut ChallengeParameters,
    derived_key: Option<&[u8]>,
    hmac_signature_secret: &str,
    hmac_key_signature_secret: Option<&str>,
) -> Result<Challenge>

Signs an existing set of challenge parameters. Useful when building challenges manually.


verify_server_signature

pub fn verify_server_signature(
    payload: &ServerSignaturePayload,
    hmac_secret: &str,
) -> Result<VerifyServerSignatureResult>

Verifies a payload issued by ALTCHA Sentinel. The payload is typically obtained from a form field (base64-encoded JSON) when using the ALTCHA Sentinel service for server-side bot scoring.

Verification steps:

  1. Compute HMAC(SHA(verificationData), hmac_secret) and compare with payload.signature in constant time.
  2. Parse verificationData (URL-encoded query string) into typed fields.
  3. Check that the expire timestamp has not passed.
  4. Check that both payload.verified and the parsed verified field are true.

ServerSignaturePayload fields:

FieldTypeDescription
algorithmStringHash and HMAC algorithm (e.g. "SHA-256")
api_keyOption<String>ALTCHA Sentinel API key (informational)
idOption<String>Submission ID
signatureStringHex-encoded HMAC(SHA(verificationData), secret)
verification_dataStringURL-encoded query string from ALTCHA Sentinel
verifiedboolWhether Sentinel considers the submission verified

VerifyServerSignatureResult:

pub struct VerifyServerSignatureResult {
    pub verified: bool,
    pub expired: bool,
    pub invalid_signature: bool,
    pub invalid_solution: bool,
    pub time: f64,
    pub verification_data: Option<ServerSignatureVerificationData>,
}

ServerSignatureVerificationData — parsed fields from verificationData:

FieldTypeDescription
classificationOption<String>"BAD", "GOOD", or "NEUTRAL"
emailOption<String>Submitter email (if provided)
expireOption<u64>Unix timestamp after which the payload expires
fieldsOption<Vec<String>>Form field names included in the fields hash
fields_hashOption<String>Hex hash of the selected form fields
idOption<String>Submission ID
ip_addressOption<String>Submitter IP address
reasonsOption<Vec<String>>Scoring reasons
scoreOption<f64>Bot probability score
timeOption<f64>Submission timestamp
verifiedOption<bool>Whether Sentinel verified the submission
extraBTreeMap<String, String>Any additional fields not listed above

Example:

use altcha::{verify_server_signature, ServerSignaturePayload};

// The ALTCHA widget puts a base64-encoded JSON ServerSignaturePayload
// into a hidden form field when connected to ALTCHA Sentinel.
let payload: ServerSignaturePayload = serde_json::from_slice(
    &base64::engine::general_purpose::STANDARD.decode(&form_field)?
)?;

let result = verify_server_signature(&payload, "my-hmac-secret")?;

if result.verified {
    // Safe to process the form submission.
    if let Some(data) = &result.verification_data {
        println!("score: {:?}, classification: {:?}", data.score, data.classification);
    }
}

verify_fields_hash

pub fn verify_fields_hash(
    form_data: &HashMap<String, String>,
    fields: &[String],
    fields_hash: &str,
    algorithm: Option<&HmacAlgorithm>,
) -> bool

Verifies that a hash of selected form field values matches an expected digest. Used to confirm that specific fields have not been tampered with after the ALTCHA Sentinel payload was signed.

Joins the values of fields (in the given order) with "\n", hashes with the specified algorithm (defaults to SHA-256), and compares with fields_hash in constant time.


parse_verification_data

pub fn parse_verification_data(data: &str) -> Option<ServerSignatureVerificationData>

Parses the URL-encoded verificationData string from a [ServerSignaturePayload] into a typed [ServerSignatureVerificationData] struct. Called automatically by verify_server_signature; exposed for cases where you need to inspect the data independently.

Algorithms

Algorithm stringKDFFeatureNotes
"PBKDF2/SHA-256"PBKDF2-HMAC-SHA-256Default; cost = iteration count
"PBKDF2/SHA-384"PBKDF2-HMAC-SHA-384cost = iteration count
"PBKDF2/SHA-512"PBKDF2-HMAC-SHA-512cost = iteration count
"SHA-256"Iterative SHA-256cost = iteration count
"SHA-384"Iterative SHA-384cost = iteration count
"SHA-512"Iterative SHA-512cost = iteration count
"SCRYPT"scryptscryptcost = N (must be power of 2), memory_cost = r (default 8), parallelism = p (default 1)
"ARGON2ID"Argon2idargon2cost = time cost, memory_cost = KiB (required), parallelism = p (default 1)

Serialization

Challenge, Solution, and ServerSignaturePayload implement serde::Serialize / serde::Deserialize and produce JSON compatible with the reference JavaScript library. Field names follow the camelCase convention used in the ALTCHA spec (keyLength, keyPrefix, expiresAt, derivedKey, verificationData, etc.).

let json = serde_json::to_string(&challenge)?;
let challenge: Challenge = serde_json::from_str(&json)?;

License

MIT