diff --git a/fluxer_admin/openapi-admin.json b/fluxer_admin/openapi-admin.json index 3fa13a51d..f42d98c91 100644 --- a/fluxer_admin/openapi-admin.json +++ b/fluxer_admin/openapi-admin.json @@ -10526,6 +10526,7 @@ "voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigResponse"}, "push_service_delivery": {"$ref": "#/components/schemas/PushServiceDeliveryConfigResponse"}, "domain_migration": {"$ref": "#/components/schemas/DomainMigrationConfigResponse"}, + "altcha_captcha": {"$ref": "#/components/schemas/AltchaCaptchaConfigResponse"}, "experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"}, "registration": { "type": "object", @@ -10955,6 +10956,7 @@ "voice_noise_suppression", "push_service_delivery", "domain_migration", + "altcha_captcha", "experiment_delivery", "registration", "self_hosted", @@ -11097,6 +11099,10 @@ "nullable": true, "allOf": [{"$ref": "#/components/schemas/DomainMigrationConfigUpdateRequest"}] }, + "altcha_captcha": { + "nullable": true, + "allOf": [{"$ref": "#/components/schemas/AltchaCaptchaConfigUpdateRequest"}] + }, "experiment_delivery": { "nullable": true, "allOf": [{"$ref": "#/components/schemas/ExperimentDeliveryConfigUpdateRequest"}] @@ -15190,6 +15196,27 @@ "poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50} } }, + "AltchaCaptchaConfigUpdateRequest": { + "type": "object", + "properties": { + "enabled": {"type": "boolean"}, + "rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000}, + "rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"}, + "included_user_ids": { + "maxItems": 1000, + "type": "array", + "items": {"type": "string", "pattern": "^\\d{1,20}$"} + }, + "excluded_user_ids": { + "maxItems": 1000, + "type": "array", + "items": {"type": "string", "pattern": "^\\d{1,20}$"} + }, + "anonymous_enabled": {"type": "boolean"}, + "cost": {"type": "integer", "minimum": 1000, "maximum": 100000}, + "max_counter": {"type": "integer", "minimum": 100, "maximum": 1000000} + } + }, "DomainMigrationConfigUpdateRequest": { "type": "object", "properties": { @@ -15294,6 +15321,48 @@ "required": ["poll_interval_seconds", "poll_jitter_percent"], "additionalProperties": false }, + "AltchaCaptchaConfigResponse": { + "type": "object", + "properties": { + "enabled": {"default": false, "type": "boolean"}, + "config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991}, + "rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000}, + "rollout_salt": { + "default": "altcha-captcha-v1", + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[\\x20-\\x7e]+$" + }, + "included_user_ids": { + "default": [], + "maxItems": 1000, + "type": "array", + "items": {"type": "string", "pattern": "^\\d{1,20}$"} + }, + "excluded_user_ids": { + "default": [], + "maxItems": 1000, + "type": "array", + "items": {"type": "string", "pattern": "^\\d{1,20}$"} + }, + "anonymous_enabled": {"default": false, "type": "boolean"}, + "cost": {"default": 5000, "type": "integer", "minimum": 1000, "maximum": 100000}, + "max_counter": {"default": 10000, "type": "integer", "minimum": 100, "maximum": 1000000} + }, + "required": [ + "enabled", + "config_version", + "rollout_basis_points", + "rollout_salt", + "included_user_ids", + "excluded_user_ids", + "anonymous_enabled", + "cost", + "max_counter" + ], + "additionalProperties": false + }, "DomainMigrationConfigResponse": { "type": "object", "properties": { diff --git a/fluxer_admin/src/api/types/instance_config.rs b/fluxer_admin/src/api/types/instance_config.rs index f62487814..4c65feab6 100644 --- a/fluxer_admin/src/api/types/instance_config.rs +++ b/fluxer_admin/src/api/types/instance_config.rs @@ -27,6 +27,8 @@ pub struct InstanceConfigResponse { #[serde(default)] pub domain_migration: DomainMigrationConfigResponse, #[serde(default)] + pub altcha_captcha: AltchaCaptchaConfigResponse, + #[serde(default)] pub experiment_delivery: ExperimentDeliveryConfigResponse, } @@ -453,6 +455,9 @@ impl VoiceE2eeScope { pub const EXPERIMENT_MAX_TARGETED_USERS: usize = 1_000; pub const PUSH_SERVICE_DELIVERY_DEFAULT_SALT: &str = "push-service-delivery-v1"; pub const DOMAIN_MIGRATION_DEFAULT_SALT: &str = "domain-migration-v1"; +pub const ALTCHA_CAPTCHA_DEFAULT_SALT: &str = "altcha-captcha-v1"; +pub const ALTCHA_CAPTCHA_COST_RANGE: std::ops::RangeInclusive = 1_000..=100_000; +pub const ALTCHA_CAPTCHA_MAX_COUNTER_RANGE: std::ops::RangeInclusive = 100..=1_000_000; pub const VOICE_NS_MAX_GUILD_OVERRIDES: usize = 200; impl NoiseSuppressionBackend { @@ -635,6 +640,56 @@ pub struct DomainMigrationConfigUpdateRequest { pub standalone_forwarding: Option, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(default)] +pub struct AltchaCaptchaConfigResponse { + pub enabled: bool, + pub config_version: u64, + pub rollout_basis_points: u32, + pub rollout_salt: String, + pub included_user_ids: Vec, + pub excluded_user_ids: Vec, + pub anonymous_enabled: bool, + pub cost: u32, + pub max_counter: u32, +} + +impl Default for AltchaCaptchaConfigResponse { + fn default() -> Self { + Self { + enabled: false, + config_version: 0, + rollout_basis_points: 0, + rollout_salt: ALTCHA_CAPTCHA_DEFAULT_SALT.to_owned(), + included_user_ids: Vec::new(), + excluded_user_ids: Vec::new(), + anonymous_enabled: false, + cost: 5_000, + max_counter: 10_000, + } + } +} + +#[derive(Clone, Debug, Default, Serialize)] +pub struct AltchaCaptchaConfigUpdateRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub rollout_basis_points: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub rollout_salt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub included_user_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub excluded_user_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub anonymous_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cost: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_counter: Option, +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(default)] pub struct ExperimentDeliveryConfigResponse { @@ -755,6 +810,8 @@ pub struct InstanceConfigUpdateRequest { #[serde(skip_serializing_if = "Option::is_none")] pub domain_migration: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub altcha_captcha: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub experiment_delivery: Option, } @@ -1094,17 +1151,24 @@ mod tests { .expect("default noise config"); let domain_migration = serde_json::from_value::(json!({})) .expect("default domain migration config"); + let altcha_captcha = serde_json::from_value::(json!({})) + .expect("default altcha captcha config"); let delivery = serde_json::from_value::(json!({})) .expect("default delivery config"); let noise = serde_json::to_value(noise).expect("serializable noise config"); let domain_migration = serde_json::to_value(domain_migration).expect("serializable domain migration config"); + let altcha_captcha = + serde_json::to_value(altcha_captcha).expect("serializable altcha captcha config"); let delivery = serde_json::to_value(delivery).expect("serializable delivery config"); let generated_noise: generated_types::VoiceNoiseSuppressionConfigResponse = serde_json::from_value(noise.clone()).expect("generated noise config contract"); let generated_domain_migration: generated_types::DomainMigrationConfigResponse = serde_json::from_value(domain_migration.clone()) .expect("generated domain migration config contract"); + let generated_altcha_captcha: generated_types::AltchaCaptchaConfigResponse = + serde_json::from_value(altcha_captcha.clone()) + .expect("generated altcha captcha config contract"); let generated_delivery: generated_types::ExperimentDeliveryConfigResponse = serde_json::from_value(delivery.clone()).expect("generated delivery config contract"); assert_eq!( @@ -1116,6 +1180,11 @@ mod tests { .expect("serializable generated domain migration config"), domain_migration ); + assert_eq!( + serde_json::to_value(generated_altcha_captcha) + .expect("serializable generated altcha captcha config"), + altcha_captcha + ); assert_eq!( serde_json::to_value(generated_delivery) .expect("serializable generated delivery config"), @@ -1124,6 +1193,7 @@ mod tests { for (name, value) in [ ("VoiceNoiseSuppressionConfigResponse", noise), ("DomainMigrationConfigResponse", domain_migration), + ("AltchaCaptchaConfigResponse", altcha_captcha), ("ExperimentDeliveryConfigResponse", delivery), ] { for (field, value) in value.as_object().expect("config object") { diff --git a/fluxer_admin/src/routes/system_actions.rs b/fluxer_admin/src/routes/system_actions.rs index 95b102ee7..0138e9547 100644 --- a/fluxer_admin/src/routes/system_actions.rs +++ b/fluxer_admin/src/routes/system_actions.rs @@ -4,24 +4,25 @@ use crate::{ api::{ client::AdminApiClient, types::{ - AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest, - AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest, - AppSetupConfigUpdateRequest, CreateRegistrationUrlRequest, - DeferredPhoneGateUpdateRequest, DomainMigrationConfigUpdateRequest, - EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigUpdateRequest, - GatewayRolloutConfigUpdateRequest, GatewayRolloutMode, - InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest, - InstanceBlueskyKeyIntegrationUpdateRequest, InstanceCaptchaIntegrationUpdateRequest, - InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest, - InstanceEmailSmtpIntegrationUpdateRequest, InstanceEmailSmtpTestRequest, - InstanceGifIntegrationUpdateRequest, InstanceIntegrationsUpdateRequest, - InstanceMediaUpdateRequest, InstancePolicyUpdateRequest, - InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest, - InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule, - LimitRuleFilters, NoiseSuppressionBackend, PremiumMode, - PushServiceDeliveryConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest, - VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope, VoiceNoiseSuppressionConfigUpdateRequest, - VoiceNoiseSuppressionGuildOverride, + ALTCHA_CAPTCHA_COST_RANGE, ALTCHA_CAPTCHA_MAX_COUNTER_RANGE, + AltchaCaptchaConfigUpdateRequest, AppBrandingConfigUpdateRequest, + AppLegalConfigUpdateRequest, AppPublicConfigUpdateRequest, + AppRegistrationConfigUpdateRequest, AppSetupConfigUpdateRequest, + CreateRegistrationUrlRequest, DeferredPhoneGateUpdateRequest, + DomainMigrationConfigUpdateRequest, EXPERIMENT_MAX_TARGETED_USERS, + ExperimentDeliveryConfigUpdateRequest, GatewayRolloutConfigUpdateRequest, + GatewayRolloutMode, InstanceAttachmentDecayUpdateRequest, + InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest, + InstanceCaptchaIntegrationUpdateRequest, InstanceConfigUpdateRequest, + InstanceEmailIntegrationUpdateRequest, InstanceEmailSmtpIntegrationUpdateRequest, + InstanceEmailSmtpTestRequest, InstanceGifIntegrationUpdateRequest, + InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest, + InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest, + InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest, + LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, NoiseSuppressionBackend, + PremiumMode, PushServiceDeliveryConfigUpdateRequest, RegistrationMode, + SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope, + VoiceNoiseSuppressionConfigUpdateRequest, VoiceNoiseSuppressionGuildOverride, }, }, config::AdminConfig, @@ -216,6 +217,10 @@ pub async fn instance_config_post( Ok(update) => instance_config_result(client.update_instance_config(&update).await), Err(message) => FlashData::error(message), }, + "update_altcha_captcha" => match build_altcha_captcha_update(&form) { + Ok(update) => instance_config_result(client.update_instance_config(&update).await), + Err(message) => FlashData::error(message), + }, "update_experiment_delivery" => match build_experiment_delivery_update(&form) { Ok(update) => instance_config_result(client.update_instance_config(&update).await), Err(message) => FlashData::error(message), @@ -728,6 +733,50 @@ fn build_domain_migration_update( }) } +fn build_altcha_captcha_update( + form: &MultiValueForm, +) -> Result { + Ok(InstanceConfigUpdateRequest { + altcha_captcha: Some(AltchaCaptchaConfigUpdateRequest { + enabled: Some(form.bool_value("altcha_captcha_enabled")), + rollout_basis_points: parse_form_number( + form, + "altcha_captcha_rollout_basis_points", + "Rollout basis points", + 0, + EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX, + )?, + rollout_salt: parse_ascii_experiment_rollout_salt(form, "altcha_captcha_rollout_salt")?, + included_user_ids: Some(parse_experiment_user_ids( + form.first("altcha_captcha_included_user_ids") + .unwrap_or_default(), + "Included user IDs", + )?), + excluded_user_ids: Some(parse_experiment_user_ids( + form.first("altcha_captcha_excluded_user_ids") + .unwrap_or_default(), + "Excluded user IDs", + )?), + anonymous_enabled: Some(form.bool_value("altcha_captcha_anonymous_enabled")), + cost: parse_form_number( + form, + "altcha_captcha_cost", + "Cost", + *ALTCHA_CAPTCHA_COST_RANGE.start(), + *ALTCHA_CAPTCHA_COST_RANGE.end(), + )?, + max_counter: parse_form_number( + form, + "altcha_captcha_max_counter", + "Maximum counter", + *ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.start(), + *ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.end(), + )?, + }), + ..Default::default() + }) +} + fn build_experiment_delivery_update( form: &MultiValueForm, ) -> Result { @@ -1758,6 +1807,70 @@ mod tests { ); } + #[test] + fn build_altcha_captcha_update_reads_the_rollout_and_difficulty_fields() { + let form = MultiValueForm::parse( + b"altcha_captcha_enabled=true&altcha_captcha_rollout_basis_points=%20500%20&altcha_captcha_rollout_salt=%20altcha-captcha-v2%20&altcha_captcha_included_user_ids=1500000000000000001&altcha_captcha_excluded_user_ids=1500000000000000002&altcha_captcha_anonymous_enabled=true&altcha_captcha_cost=2000&altcha_captcha_max_counter=%20400%20", + ); + let update = build_altcha_captcha_update(&form) + .expect("valid form") + .altcha_captcha + .expect("altcha captcha update"); + assert_eq!(update.enabled, Some(true)); + assert_eq!(update.rollout_basis_points, Some(500)); + assert_eq!(update.rollout_salt, Some("altcha-captcha-v2".to_owned())); + assert_eq!( + update.included_user_ids, + Some(vec!["1500000000000000001".to_owned()]) + ); + assert_eq!( + update.excluded_user_ids, + Some(vec!["1500000000000000002".to_owned()]) + ); + assert_eq!(update.anonymous_enabled, Some(true)); + assert_eq!(update.cost, Some(2000)); + assert_eq!(update.max_counter, Some(400)); + } + + #[test] + fn build_altcha_captcha_update_leaves_the_feature_inert_when_nothing_is_submitted() { + let form = MultiValueForm::parse(b"_csrf=token"); + let request = build_altcha_captcha_update(&form).expect("valid form"); + assert_eq!( + serde_json::to_value(request).expect("serializable update"), + serde_json::json!({"altcha_captcha": { + "enabled": false, + "included_user_ids": [], + "excluded_user_ids": [], + "anonymous_enabled": false, + }}) + ); + } + + #[test] + fn build_altcha_captcha_update_rejects_difficulty_outside_the_supported_range() { + for (form, message) in [ + ( + "altcha_captcha_cost=999", + "Cost must be a whole number between 1000 and 100000", + ), + ( + "altcha_captcha_max_counter=1000001", + "Maximum counter must be a whole number between 100 and 1000000", + ), + ( + "altcha_captcha_rollout_basis_points=10001", + "Rollout basis points must be a whole number between 0 and 10000", + ), + ] { + let form = MultiValueForm::parse(form.as_bytes()); + assert_eq!( + build_altcha_captcha_update(&form).expect_err("invalid field"), + message + ); + } + } + #[test] fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() { let form = MultiValueForm::parse(b"_csrf=token"); diff --git a/fluxer_admin/src/templates/pages/instance_config.rs b/fluxer_admin/src/templates/pages/instance_config.rs index eec16e183..bc5fdfc01 100644 --- a/fluxer_admin/src/templates/pages/instance_config.rs +++ b/fluxer_admin/src/templates/pages/instance_config.rs @@ -2,13 +2,15 @@ use crate::{ api::types::{ - AppPublicConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse, - EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse, - GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse, - InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse, - LimitConfigResponse, NoiseSuppressionBackend, PUSH_SERVICE_DELIVERY_DEFAULT_SALT, - PendingRegistrationResponse, PushServiceDeliveryConfigResponse, RegistrationUrlResponse, - SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES, VoiceNoiseSuppressionConfigResponse, + ALTCHA_CAPTCHA_COST_RANGE, ALTCHA_CAPTCHA_DEFAULT_SALT, ALTCHA_CAPTCHA_MAX_COUNTER_RANGE, + AltchaCaptchaConfigResponse, AppPublicConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT, + DomainMigrationConfigResponse, EXPERIMENT_MAX_TARGETED_USERS, + ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse, + InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse, + InstanceRegistrationResponse, LimitConfigResponse, NoiseSuppressionBackend, + PUSH_SERVICE_DELIVERY_DEFAULT_SALT, PendingRegistrationResponse, + PushServiceDeliveryConfigResponse, RegistrationUrlResponse, SsoConfigResponse, + VOICE_NS_MAX_GUILD_OVERRIDES, VoiceNoiseSuppressionConfigResponse, }, config::AdminConfig, middleware::auth::AuthContext, @@ -151,6 +153,7 @@ pub fn instance_config_page( (voice_noise_suppression_section(base, csrf_token, &instance_config.voice_noise_suppression)) (push_service_delivery_section(base, csrf_token, &instance_config.push_service_delivery)) (domain_migration_section(base, csrf_token, &instance_config.domain_migration)) + (altcha_captcha_section(base, csrf_token, &instance_config.altcha_captcha)) (experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery)) @if let Some(limit_config) = limit_config { (limit_config_section(base, limit_config)) @@ -1451,6 +1454,145 @@ fn domain_migration_section( ) } +fn altcha_captcha_section( + base: &str, + csrf_token: &str, + altcha_captcha: &AltchaCaptchaConfigResponse, +) -> Markup { + let status = if altcha_captcha.enabled { + ("Live", BadgeVariant::Success) + } else { + ("Inert", BadgeVariant::Default) + }; + let included_user_ids = altcha_captcha.included_user_ids.join("\n"); + let excluded_user_ids = altcha_captcha.excluded_user_ids.join("\n"); + section_card_with_description( + "ALTCHA Captcha", + "Replaces the configured captcha provider with an ALTCHA proof-of-work check for the \ + selected requesters. The API issues and verifies every challenge itself, so no third \ + party is involved. Requests only need a captcha where one is already required, so this \ + does nothing while captcha is off for the instance.", + html! { + form method="post" action={(base) "/instance-config?action=update_altcha_captcha"} { + (csrf_input(csrf_token)) + div class="space-y-6" { + div class="flex flex-wrap items-center gap-2" { + h3 class="text-sm font-semibold text-neutral-900" { "Master switch" } + (badge(status.0, status.1)) + span class="text-xs text-neutral-500" { + "Config version " (altcha_captcha.config_version) + } + } + (checkbox( + "altcha_captcha_enabled", + "true", + "Serve ALTCHA to the selected requesters", + altcha_captcha.enabled, + true, + )) + p class="text-xs text-neutral-500" { + "Off is the safe state and the kill switch. With this unchecked every \ + requester gets the configured provider and ALTCHA answers are rejected." + } + + h3 class="text-sm font-semibold text-neutral-900" { "Logged-out requests" } + (checkbox( + "altcha_captcha_anonymous_enabled", + "true", + "Serve ALTCHA to logged-out requests", + altcha_captcha.anonymous_enabled, + true, + )) + p class="text-xs text-neutral-500" { + "Covers registration, login and password reset. These requests have no \ + account to bucket, so this switch applies to all of them at once." + } + + h3 class="text-sm font-semibold text-neutral-900" { "Rollout" } + (number_field( + "altcha_captcha_rollout_basis_points", + "Rollout (basis points)", + &altcha_captcha.rollout_basis_points.to_string(), + Some(0), Some(10000), "1", + Some("Share of logged-in users bucketed into ALTCHA, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."), + )) + div class="flex flex-col gap-2" { + (text_input( + "altcha_captcha_rollout_salt", + "Rollout Salt", + &altcha_captcha.rollout_salt, + ALTCHA_CAPTCHA_DEFAULT_SALT, + )) + p class="text-xs text-neutral-500" { + "Seeds the bucketing hash. Changing it reshuffles which users fall \ + inside the percentage above." + } + } + div class="flex flex-col gap-2" { + (textarea_input( + "altcha_captcha_included_user_ids", + "Always-on User IDs", + "1500000000000000001\n1500000000000000002", + &included_user_ids, + 4, + false, + )) + (entry_count_hint( + altcha_captcha.included_user_ids.len(), + EXPERIMENT_MAX_TARGETED_USERS, + )) + p class="text-xs text-neutral-500" { + "One snowflake per line, or comma separated. These users get ALTCHA \ + regardless of the percentage above. Invalid entries prevent the save." + } + } + div class="flex flex-col gap-2" { + (textarea_input( + "altcha_captcha_excluded_user_ids", + "Never-on User IDs", + "1500000000000000003\n1500000000000000004", + &excluded_user_ids, + 4, + false, + )) + (entry_count_hint( + altcha_captcha.excluded_user_ids.len(), + EXPERIMENT_MAX_TARGETED_USERS, + )) + p class="text-xs text-neutral-500" { + "Same format. Exclusion wins over both the always-on list and the percentage." + } + } + + h3 class="text-sm font-semibold text-neutral-900" { "Difficulty" } + (number_field( + "altcha_captcha_cost", + "Cost (PBKDF2 iterations per attempt)", + &altcha_captcha.cost.to_string(), + Some(*ALTCHA_CAPTCHA_COST_RANGE.start()), + Some(*ALTCHA_CAPTCHA_COST_RANGE.end()), + "1", + Some("The API spends one attempt at this cost to issue each challenge."), + )) + (number_field( + "altcha_captcha_max_counter", + "Maximum counter", + &altcha_captcha.max_counter.to_string(), + Some(*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.start()), + Some(*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.end()), + "1", + Some("Each challenge hides its answer between half this value and this value. The client tries counters from 0 until it finds it, so solve time grows with cost times this value. At the defaults a recent laptop takes about 3 seconds."), + )) + + (form_actions(html! { + (submit_button("Save ALTCHA Configuration")) + })) + } + } + }, + ) +} + fn experiment_delivery_section( base: &str, csrf_token: &str, diff --git a/fluxer_admin/tests/api_deserialization.rs b/fluxer_admin/tests/api_deserialization.rs index be3289052..26bc4be44 100644 --- a/fluxer_admin/tests/api_deserialization.rs +++ b/fluxer_admin/tests/api_deserialization.rs @@ -431,6 +431,18 @@ fn deserialize_instance_config_response_with_unknown_keys() { "anonymous_rollout_basis_points": 100, "standalone_forwarding": true }, + "altcha_captcha": { + "enabled": true, + "config_version": 3, + "rollout_basis_points": 500, + "rollout_salt": "altcha-captcha-v1", + "included_user_ids": [], + "excluded_user_ids": ["1500000000000000003"], + "anonymous_enabled": true, + "cost": 5000, + "max_counter": 10000, + "future_altcha_knob": "argon2id" + }, "experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15}, "registration": { "mode": "open", @@ -568,6 +580,11 @@ fn deserialize_instance_config_response_with_unknown_keys() { assert_eq!(resp.domain_migration.anonymous_rollout_basis_points, 100); assert!(resp.domain_migration.standalone_forwarding); assert!(resp.push_service_delivery.relay_consent_accepted); + assert!(resp.altcha_captcha.enabled); + assert_eq!(resp.altcha_captcha.config_version, 3); + assert!(resp.altcha_captcha.anonymous_enabled); + assert_eq!(resp.altcha_captcha.excluded_user_ids.len(), 1); + assert_eq!(resp.altcha_captcha.max_counter, 10000); assert_eq!(resp.experiment_delivery.poll_interval_seconds, 300); assert!(resp.policy.single_community_guild_id.is_none()); assert_eq!(resp.policy.services.gif_enabled, Some(true)); diff --git a/fluxer_api/package.json b/fluxer_api/package.json index 62d146e80..2536f9e23 100644 --- a/fluxer_api/package.json +++ b/fluxer_api/package.json @@ -56,6 +56,7 @@ "@simplewebauthn/server": "catalog:", "@types/node": "catalog:", "@vvo/tzdb": "catalog:", + "altcha-lib": "catalog:", "archiver": "catalog:", "argon2": "catalog:", "bowser": "catalog:", diff --git a/fluxer_api/pkgs/captcha/package.json b/fluxer_api/pkgs/captcha/package.json index 7dbd367dd..b568570e1 100644 --- a/fluxer_api/pkgs/captcha/package.json +++ b/fluxer_api/pkgs/captcha/package.json @@ -11,7 +11,9 @@ }, "dependencies": { "@fluxer/logger": "workspace:*", - "itty-time": "catalog:" + "altcha-lib": "catalog:", + "itty-time": "catalog:", + "zod": "catalog:" }, "devDependencies": { "@types/node": "catalog:", diff --git a/fluxer_api/pkgs/captcha/src/ICaptchaProvider.ts b/fluxer_api/pkgs/captcha/src/ICaptchaProvider.ts index 3f2fbe7fd..f083b8f18 100644 --- a/fluxer_api/pkgs/captcha/src/ICaptchaProvider.ts +++ b/fluxer_api/pkgs/captcha/src/ICaptchaProvider.ts @@ -5,7 +5,7 @@ export interface VerifyCaptchaParams { remoteIp?: string; } -export type CaptchaProviderType = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'test' | 'unavailable'; +export type CaptchaProviderType = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'altcha' | 'test' | 'unavailable'; export interface ICaptchaProvider { readonly type: CaptchaProviderType; diff --git a/fluxer_api/pkgs/captcha/src/providers/AltchaProvider.ts b/fluxer_api/pkgs/captcha/src/providers/AltchaProvider.ts new file mode 100644 index 000000000..edbca8d88 --- /dev/null +++ b/fluxer_api/pkgs/captcha/src/providers/AltchaProvider.ts @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface'; +import type {CaptchaProviderType, ICaptchaProvider, VerifyCaptchaParams} from '@pkgs/captcha/src/ICaptchaProvider'; +import {createChallenge, randomInt, verifySolution} from 'altcha-lib'; +import {deriveKey} from 'altcha-lib/algorithms/pbkdf2'; +import type {Challenge} from 'altcha-lib/types'; +import {ms} from 'itty-time'; +import {z} from 'zod'; + +export const ALTCHA_ALGORITHM = 'PBKDF2/SHA-256'; +const ALTCHA_CHALLENGE_TTL_MS = ms('10 minutes'); +const ALTCHA_MAX_TOKEN_LENGTH = 4096; +const HEX_PATTERN = /^[0-9a-f]+$/u; + +const AltchaPayloadSchema = z.object({ + challenge: z.object({ + parameters: z.looseObject({ + algorithm: z.literal(ALTCHA_ALGORITHM), + nonce: z.string().regex(HEX_PATTERN), + salt: z.string().regex(HEX_PATTERN), + cost: z.number().int().positive(), + keyLength: z.number().int().positive(), + keyPrefix: z.string().regex(HEX_PATTERN), + keySignature: z.string().regex(HEX_PATTERN), + expiresAt: z.number().int().positive(), + }), + signature: z.string().regex(HEX_PATTERN), + }), + solution: z.object({ + counter: z.number().int().min(0), + derivedKey: z.string().regex(HEX_PATTERN), + time: z.number().optional(), + }), +}); + +type AltchaPayload = z.infer; + +export interface AltchaProviderOptions { + hmacSignatureSecret: string; + hmacKeySignatureSecret: string; + cost: number; + maxCounter: number; + claimChallenge: (signature: string, ttlSeconds: number) => Promise; + logger?: LoggerInterface; + now?: () => number; +} + +function decodePayload(token: string): AltchaPayload | null { + if (token.length > ALTCHA_MAX_TOKEN_LENGTH) return null; + try { + const parsed = AltchaPayloadSchema.safeParse(JSON.parse(Buffer.from(token, 'base64').toString('utf8'))); + return parsed.success ? parsed.data : null; + } catch { + return null; + } +} + +export class AltchaProvider implements ICaptchaProvider { + readonly type: CaptchaProviderType = 'altcha'; + private readonly options: AltchaProviderOptions; + private readonly now: () => number; + + constructor(options: AltchaProviderOptions) { + this.options = options; + this.now = options.now ?? Date.now; + } + + async createChallenge(): Promise { + const {cost, maxCounter, hmacSignatureSecret, hmacKeySignatureSecret} = this.options; + return await createChallenge({ + algorithm: ALTCHA_ALGORITHM, + cost, + counter: randomInt(maxCounter, Math.ceil(maxCounter / 2)), + deriveKey, + expiresAt: new Date(this.now() + ALTCHA_CHALLENGE_TTL_MS), + hmacSignatureSecret, + hmacKeySignatureSecret, + }); + } + + async verify({token}: VerifyCaptchaParams): Promise { + const payload = decodePayload(token); + if (!payload) return false; + try { + const result = await verifySolution({ + challenge: payload.challenge, + solution: payload.solution, + deriveKey, + hmacSignatureSecret: this.options.hmacSignatureSecret, + hmacKeySignatureSecret: this.options.hmacKeySignatureSecret, + }); + if (!result.verified) { + this.options.logger?.warn( + {expired: result.expired, invalidSignature: result.invalidSignature, invalidSolution: result.invalidSolution}, + 'ALTCHA verification failed', + ); + return false; + } + } catch (error) { + this.options.logger?.error({error}, 'Error verifying ALTCHA payload'); + return false; + } + const ttlSeconds = Math.max(1, payload.challenge.parameters.expiresAt - Math.floor(this.now() / 1000)); + return await this.options.claimChallenge(payload.challenge.signature, ttlSeconds); + } +} diff --git a/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts b/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts index 7fdf50df0..0512ea1d2 100644 --- a/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts @@ -34,6 +34,7 @@ import { PendingRegistrationActionRequest, RegistrationUrlIdParam, } from '@fluxer/schema/src/domains/admin/AdminSchemas'; +import {AltchaCaptchaConfigSchema} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas'; import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas'; import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas'; import { @@ -71,6 +72,7 @@ async function buildInstanceConfigResponse(): Promise { voiceNoiseSuppression, pushServiceDelivery, domainMigration, + altchaCaptcha, experimentDelivery, registrationConfig, registrationUrls, @@ -81,6 +83,7 @@ async function buildInstanceConfigResponse(): Promise { instanceConfigRepository.getVoiceNoiseSuppressionConfig(), instanceConfigRepository.getPushServiceDeliveryConfig(), instanceConfigRepository.getDomainMigrationConfig(), + instanceConfigRepository.getAltchaCaptchaConfig(), instanceConfigRepository.getExperimentDeliveryConfig(), instanceConfigRepository.getRegistrationConfig(), instanceConfigRepository.getRegistrationUrlsForAdmin(), @@ -114,6 +117,7 @@ async function buildInstanceConfigResponse(): Promise { voice_noise_suppression: voiceNoiseSuppression, push_service_delivery: pushServiceDelivery, domain_migration: domainMigration, + altcha_captcha: altchaCaptcha, experiment_delivery: experimentDelivery, registration: { ...registrationConfig, @@ -318,6 +322,18 @@ export function InstanceConfigAdminController(app: HonoApp) { ); } } + if (data.altcha_captcha) { + const patch = omitUndefinedFields(data.altcha_captcha); + if (Object.keys(patch).length > 0) { + await instanceConfigRepository.updateAltchaCaptchaConfig((current) => + AltchaCaptchaConfigSchema.parse({ + ...current, + ...patch, + config_version: current.config_version + 1, + }), + ); + } + } if (data.experiment_delivery) { const patch = data.experiment_delivery; await instanceConfigRepository.updateExperimentDeliveryConfig((current) => diff --git a/fluxer_api/src/api/auth/tests/AltchaCaptcha.test.ts b/fluxer_api/src/api/auth/tests/AltchaCaptcha.test.ts new file mode 100644 index 000000000..6a912af42 --- /dev/null +++ b/fluxer_api/src/api/auth/tests/AltchaCaptcha.test.ts @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {Config} from '@app/api/Config'; +import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons'; +import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {HTTP_STATUS} from '@app/api/test/TestConstants'; +import {createBuilder, createBuilderWithoutAuth, type TestRequestBuilder} from '@app/api/test/TestRequestBuilder'; +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import { + type AltchaCaptchaConfig, + DEFAULT_ALTCHA_CAPTCHA_CONFIG, +} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas'; +import {solveChallenge} from 'altcha-lib'; +import {deriveKey} from 'altcha-lib/algorithms/pbkdf2'; +import type {Challenge} from 'altcha-lib/types'; +import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest'; + +interface CaptchaErrorBody { + code: string; + captcha_provider?: string; + altcha_challenge?: Challenge; +} + +const FORGOT_PATH = '/auth/forgot'; +const FORGOT_BODY = {email: 'altcha-nobody@example.com'}; + +async function setAltchaConfig(overrides: Partial): Promise { + await getInstanceConfigRepository().setAltchaCaptchaConfig({ + ...DEFAULT_ALTCHA_CAPTCHA_CONFIG, + enabled: true, + cost: 1000, + max_counter: 100, + ...overrides, + }); +} + +async function solve(challenge: Challenge): Promise { + const solution = await solveChallenge({challenge, deriveKey, timeout: 0}); + if (!solution) throw new Error('ALTCHA challenge was not solved'); + return Buffer.from(JSON.stringify({challenge, solution}), 'utf8').toString('base64'); +} + +async function rejectWith(builder: TestRequestBuilder, code: string): Promise { + const {json} = await builder.expect(HTTP_STATUS.BAD_REQUEST, code).executeWithResponse(); + expect(json.code).toBe(code); + return json; +} + +function forgot(harness: ApiTestHarness): TestRequestBuilder { + return createBuilderWithoutAuth(harness).post(FORGOT_PATH).body(FORGOT_BODY); +} + +describe('ALTCHA captcha experiment', () => { + let harness: ApiTestHarness; + let previousCaptchaEnabled: boolean; + let previousTestModeEnabled: boolean; + + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + + beforeEach(async () => { + await harness.reset(); + previousCaptchaEnabled = Config.captcha.enabled; + previousTestModeEnabled = Config.dev.testModeEnabled; + Config.captcha.enabled = true; + Config.dev.testModeEnabled = true; + }); + + afterEach(() => { + Config.captcha.enabled = previousCaptchaEnabled; + Config.dev.testModeEnabled = previousTestModeEnabled; + }); + + afterAll(async () => { + await harness.shutdown(); + }); + + it('keeps the configured provider while the experiment is off', async () => { + const body = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED); + expect(body).not.toHaveProperty('captcha_provider'); + expect(body).not.toHaveProperty('altcha_challenge'); + }); + + it('leaves anonymous requests on the configured provider unless anonymous_enabled is set', async () => { + await setAltchaConfig({rollout_basis_points: 10000}); + const body = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED); + expect(body).not.toHaveProperty('altcha_challenge'); + }); + + it('serves anonymous requests a challenge and accepts the solved payload once', async () => { + await setAltchaConfig({anonymous_enabled: true}); + const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED); + expect(required.captcha_provider).toBe('altcha'); + expect(required.altcha_challenge?.parameters).toMatchObject({algorithm: 'PBKDF2/SHA-256', cost: 1000}); + const token = await solve(required.altcha_challenge as Challenge); + + await forgot(harness) + .header('X-Captcha-Token', token) + .header('X-Captcha-Type', 'altcha') + .expect(HTTP_STATUS.NO_CONTENT) + .execute(); + + const replayed = await rejectWith( + forgot(harness).header('X-Captcha-Token', token).header('X-Captcha-Type', 'altcha'), + APIErrorCodes.INVALID_CAPTCHA, + ); + expect(replayed.captcha_provider).toBe('altcha'); + expect(replayed.altcha_challenge?.signature).not.toBe(required.altcha_challenge?.signature); + }); + + it('rejects a payload whose derived key does not match the challenge', async () => { + await setAltchaConfig({anonymous_enabled: true}); + const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED); + const challenge = required.altcha_challenge as Challenge; + const forged = Buffer.from( + JSON.stringify({challenge, solution: {counter: 1, derivedKey: '00'.repeat(32)}}), + 'utf8', + ).toString('base64'); + + await rejectWith( + forgot(harness).header('X-Captcha-Token', forged).header('X-Captcha-Type', 'altcha'), + APIErrorCodes.INVALID_CAPTCHA, + ); + }); + + it('rejects an ALTCHA payload from a requester outside the experiment', async () => { + await setAltchaConfig({anonymous_enabled: true}); + const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED); + const token = await solve(required.altcha_challenge as Challenge); + await setAltchaConfig({anonymous_enabled: false}); + + const rejected = await rejectWith( + forgot(harness).header('X-Captcha-Token', token).header('X-Captcha-Type', 'altcha'), + APIErrorCodes.INVALID_CAPTCHA, + ); + expect(rejected).not.toHaveProperty('altcha_challenge'); + }); + + it('buckets signed-in users by their own rollout and still accepts the configured provider', async () => { + Config.captcha.enabled = false; + const included = await createTestAccount(harness); + const excluded = await createTestAccount(harness); + Config.captcha.enabled = true; + await setAltchaConfig({ + anonymous_enabled: true, + included_user_ids: [included.userId], + excluded_user_ids: [excluded.userId], + }); + const redeemPath = '/gifts/altcha-gift-code/redeem'; + + const excludedBody = await rejectWith( + createBuilder(harness, excluded.token).post(redeemPath), + APIErrorCodes.CAPTCHA_REQUIRED, + ); + expect(excludedBody).not.toHaveProperty('altcha_challenge'); + + const includedBody = await rejectWith( + createBuilder(harness, included.token).post(redeemPath), + APIErrorCodes.CAPTCHA_REQUIRED, + ); + const token = await solve(includedBody.altcha_challenge as Challenge); + const solved = await createBuilder(harness, included.token) + .post(redeemPath) + .header('X-Captcha-Token', token) + .header('X-Captcha-Type', 'altcha') + .executeRaw(); + expect([APIErrorCodes.CAPTCHA_REQUIRED, APIErrorCodes.INVALID_CAPTCHA]).not.toContain(solved.json?.code); + + const classic = await createBuilder(harness, included.token) + .post(redeemPath) + .header('X-Captcha-Token', 'hcaptcha-token') + .header('X-Captcha-Type', 'hcaptcha') + .executeRaw(); + expect([APIErrorCodes.CAPTCHA_REQUIRED, APIErrorCodes.INVALID_CAPTCHA]).not.toContain(classic.json?.code); + }); +}); diff --git a/fluxer_api/src/api/experiment/ExperimentController.ts b/fluxer_api/src/api/experiment/ExperimentController.ts index 7faa1f26e..9bd573ffd 100644 --- a/fluxer_api/src/api/experiment/ExperimentController.ts +++ b/fluxer_api/src/api/experiment/ExperimentController.ts @@ -8,6 +8,7 @@ import {RateLimitConfigs} from '@app/api/RateLimitConfig'; import type {HonoApp} from '@app/api/types/HonoEnv'; import {entityTagMatches} from '@app/api/utils/EntityTag'; import {Headers as HttpHeaders} from '@fluxer/constants/src/Headers'; +import {resolveAltchaCaptchaAssignment} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas'; import {resolveDomainMigrationAssignment} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas'; import {resolveVoiceNoiseSuppressionAssignment} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas'; import {ExperimentAssignmentsResponse} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas'; @@ -29,10 +30,11 @@ export function ExperimentController(app: HonoApp) { }), async (ctx) => { const instanceConfigRepository = ctx.get('instanceConfigRepository'); - const [delivery, voiceConfig, domainMigrationConfig] = await Promise.all([ + const [delivery, voiceConfig, domainMigrationConfig, altchaCaptchaConfig] = await Promise.all([ instanceConfigRepository.getExperimentDeliveryConfig(), instanceConfigRepository.getVoiceNoiseSuppressionConfig(), instanceConfigRepository.getDomainMigrationConfig(), + instanceConfigRepository.getAltchaCaptchaConfig(), ]); const userId = ctx.get('user').id.toString(); const body: ExperimentAssignmentsResponse = { @@ -41,6 +43,7 @@ export function ExperimentController(app: HonoApp) { assignments: { voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, userId), domain_migration: resolveDomainMigrationAssignment(domainMigrationConfig, userId), + altcha_captcha: resolveAltchaCaptchaAssignment(altchaCaptchaConfig, userId), }, }; const etag = `"${createHash('sha256').update(JSON.stringify(body)).digest('hex')}"`; diff --git a/fluxer_api/src/api/experiment/tests/ExperimentController.test.ts b/fluxer_api/src/api/experiment/tests/ExperimentController.test.ts index 41c505a2e..3db541785 100644 --- a/fluxer_api/src/api/experiment/tests/ExperimentController.test.ts +++ b/fluxer_api/src/api/experiment/tests/ExperimentController.test.ts @@ -6,6 +6,10 @@ import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHa import {HTTP_STATUS} from '@app/api/test/TestConstants'; import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder'; import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; +import { + DEFAULT_ALTCHA_CAPTCHA_CONFIG, + INERT_ALTCHA_CAPTCHA_ASSIGNMENT, +} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas'; import { DEFAULT_DOMAIN_MIGRATION_CONFIG, INERT_DOMAIN_MIGRATION_ASSIGNMENT, @@ -57,6 +61,7 @@ describe('GET /experiments', () => { assignments: { voice_noise_suppression: INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT, domain_migration: INERT_DOMAIN_MIGRATION_ASSIGNMENT, + altcha_captcha: INERT_ALTCHA_CAPTCHA_ASSIGNMENT, }, }); }); @@ -134,6 +139,62 @@ describe('GET /experiments', () => { expect(body.assignments.domain_migration).toEqual({enabled: false}); }); + it('resolves the altcha captcha caller through the allowlist and the exclusion list', async () => { + const targeted = await createTestAccount(harness); + const excluded = await createTestAccount(harness); + await getInstanceConfigRepository().setAltchaCaptchaConfig({ + ...DEFAULT_ALTCHA_CAPTCHA_CONFIG, + enabled: true, + rollout_basis_points: 10000, + anonymous_enabled: true, + included_user_ids: [targeted.userId], + excluded_user_ids: [excluded.userId], + }); + + const targetedBody = await createBuilder(harness, targeted.token) + .get(ENDPOINT) + .execute(); + expect(targetedBody.assignments.altcha_captcha).toEqual({enabled: true}); + + const excludedBody = await createBuilder(harness, excluded.token) + .get(ENDPOINT) + .execute(); + expect(excludedBody.assignments.altcha_captcha).toEqual({enabled: false}); + }); + + it('bumps the altcha captcha config version on every admin update without the client sending one', async () => { + const admin = await setUserACLs(harness, await createTestAccount(harness), [ + AdminACLs.AUTHENTICATE, + AdminACLs.INSTANCE_CONFIG_VIEW, + AdminACLs.INSTANCE_CONFIG_UPDATE, + ]); + + const afterFirst = await createBuilder<{altcha_captcha: {config_version: number; enabled: boolean}}>( + harness, + admin.token, + ) + .patch('/admin/instance/config') + .body({altcha_captcha: {enabled: true, included_user_ids: [admin.userId]}}) + .execute(); + expect(afterFirst.altcha_captcha).toMatchObject({config_version: 1, enabled: true}); + + const afterSecond = await createBuilder<{ + altcha_captcha: {config_version: number; anonymous_enabled: boolean; cost: number; max_counter: number}; + }>(harness, admin.token) + .patch('/admin/instance/config') + .body({altcha_captcha: {anonymous_enabled: true, cost: 2000, max_counter: 400}}) + .execute(); + expect(afterSecond.altcha_captcha).toMatchObject({ + config_version: 2, + anonymous_enabled: true, + cost: 2000, + max_counter: 400, + }); + + const body = await createBuilder(harness, admin.token).get(ENDPOINT).execute(); + expect(body.assignments.altcha_captcha).toEqual({enabled: true}); + }); + it('serves the delivery cadence from the delivery config and not from the voice config', async () => { const account = await createTestAccount(harness); await getInstanceConfigRepository().setExperimentDeliveryConfig({ diff --git a/fluxer_api/src/api/instance/InstanceConfigRepository.ts b/fluxer_api/src/api/instance/InstanceConfigRepository.ts index 35614b317..4e48a4076 100644 --- a/fluxer_api/src/api/instance/InstanceConfigRepository.ts +++ b/fluxer_api/src/api/instance/InstanceConfigRepository.ts @@ -28,6 +28,10 @@ import { type PendingRegistrationResponse, type RegistrationUrlResponse, } from '@fluxer/schema/src/domains/admin/AdminSchemas'; +import { + type AltchaCaptchaConfig, + AltchaCaptchaConfigSchema, +} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas'; import { type DomainMigrationConfig, DomainMigrationConfigSchema, @@ -68,6 +72,7 @@ const GATEWAY_ROLLOUT_CONFIG_KEY = 'gateway_rollout_config'; const VOICE_NOISE_SUPPRESSION_CONFIG_KEY = 'voice_noise_suppression_config'; const PUSH_SERVICE_DELIVERY_CONFIG_KEY = 'push_service_delivery_config'; const DOMAIN_MIGRATION_CONFIG_KEY = 'domain_migration_config'; +const ALTCHA_CAPTCHA_CONFIG_KEY = 'altcha_captcha_config'; const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config'; const REGISTRATION_CONFIG_KEY = 'registration_config'; const REGISTRATION_URLS_KEY = 'registration_urls'; @@ -376,6 +381,7 @@ type StoredConfigSection = | 'voice noise suppression' | 'push service delivery' | 'domain migration' + | 'altcha captcha' | 'experiment delivery' | 'instance policy' | 'integrations' @@ -522,6 +528,10 @@ function parseStoredDomainMigrationConfig(raw: string | null): DomainMigrationCo return parseStoredConfigOrDefault(DomainMigrationConfigSchema, raw, 'domain migration'); } +function parseStoredAltchaCaptchaConfig(raw: string | null): AltchaCaptchaConfig { + return parseStoredConfigOrDefault(AltchaCaptchaConfigSchema, raw, 'altcha captcha'); +} + function parseStoredExperimentDeliveryConfig(raw: string | null): ExperimentDeliveryConfig { return parseStoredConfigOrDefault(ExperimentDeliveryConfigSchema, raw, 'experiment delivery'); } @@ -1171,6 +1181,7 @@ export class InstanceConfigRepository { parseStoredVoiceNoiseSuppressionConfig(snapshot.get(VOICE_NOISE_SUPPRESSION_CONFIG_KEY) ?? null); parseStoredPushServiceDeliveryConfig(snapshot.get(PUSH_SERVICE_DELIVERY_CONFIG_KEY) ?? null); parseStoredDomainMigrationConfig(snapshot.get(DOMAIN_MIGRATION_CONFIG_KEY) ?? null); + parseStoredAltchaCaptchaConfig(snapshot.get(ALTCHA_CAPTCHA_CONFIG_KEY) ?? null); parseStoredExperimentDeliveryConfig(snapshot.get(EXPERIMENT_DELIVERY_CONFIG_KEY) ?? null); const policy = parseStoredInstancePolicyConfig(snapshot.get(INSTANCE_POLICY_CONFIG_KEY) ?? null); checkStoredConfig('registration', () => @@ -1305,6 +1316,23 @@ export class InstanceConfigRepository { ); } + async getAltchaCaptchaConfig(): Promise { + const raw = await this.getConfig(ALTCHA_CAPTCHA_CONFIG_KEY); + return parseStoredAltchaCaptchaConfig(raw); + } + + async setAltchaCaptchaConfig(config: AltchaCaptchaConfig): Promise { + await this.updateAltchaCaptchaConfig(() => config); + } + + updateAltchaCaptchaConfig( + update: (current: AltchaCaptchaConfig) => AltchaCaptchaConfig, + ): Promise { + return this.updateStoredConfig(ALTCHA_CAPTCHA_CONFIG_KEY, (raw) => + validateStoredConfig(AltchaCaptchaConfigSchema, update(parseStoredAltchaCaptchaConfig(raw)), 'altcha captcha'), + ); + } + async getExperimentDeliveryConfig(): Promise { const raw = await this.getConfig(EXPERIMENT_DELIVERY_CONFIG_KEY); return parseStoredExperimentDeliveryConfig(raw); diff --git a/fluxer_api/src/api/middleware/CaptchaMiddleware.ts b/fluxer_api/src/api/middleware/CaptchaMiddleware.ts index 7a6429528..3958224a9 100644 --- a/fluxer_api/src/api/middleware/CaptchaMiddleware.ts +++ b/fluxer_api/src/api/middleware/CaptchaMiddleware.ts @@ -1,7 +1,10 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {createHmac} from 'node:crypto'; import {Config} from '@app/api/Config'; import type {InstanceCaptchaEffectiveConfig} from '@app/api/instance/InstanceConfigRepository'; +import {Logger} from '@app/api/Logger'; +import {getKVClient} from '@app/api/middleware/ServiceRegistry'; import type {User} from '@app/api/models/User'; import {accountPolicyContactHasCapability} from '@app/api/risk/AccountPolicyService'; import type {HonoEnv} from '@app/api/types/HonoEnv'; @@ -9,12 +12,43 @@ import {Headers} from '@fluxer/constants/src/Headers'; import {UserFlags} from '@fluxer/constants/src/UserConstants'; import {CaptchaRequiredError, InvalidCaptchaError} from '@fluxer/errors/src/CaptchaErrors'; import {extractClientIp} from '@fluxer/ip_utils/src/ClientIp'; +import {type AltchaCaptchaConfig, altchaCaptchaAppliesTo} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas'; import type {InstanceCaptchaProvider} from '@fluxer/schema/src/domains/instance/InstanceSchemas'; import {createCaptchaProvider} from '@pkgs/captcha/src/CaptchaProviderFactory'; import type {ICaptchaProvider} from '@pkgs/captcha/src/ICaptchaProvider'; +import {AltchaProvider} from '@pkgs/captcha/src/providers/AltchaProvider'; import type {Context} from 'hono'; import {createMiddleware} from 'hono/factory'; +const ALTCHA_SPENT_CHALLENGE_KEY_PREFIX = 'captcha:altcha:spent:'; + +function deriveAltchaSecret(label: string): string { + return createHmac('sha256', Config.auth.sudoModeSecret).update(label).digest('hex'); +} + +function createAltchaProvider(config: AltchaCaptchaConfig): AltchaProvider { + return new AltchaProvider({ + hmacSignatureSecret: deriveAltchaSecret('fluxer-altcha-challenge-signature-v1'), + hmacKeySignatureSecret: deriveAltchaSecret('fluxer-altcha-key-signature-v1'), + cost: config.cost, + maxCounter: config.max_counter, + claimChallenge: (signature, ttlSeconds) => + getKVClient().setnx(`${ALTCHA_SPENT_CHALLENGE_KEY_PREFIX}${signature}`, '1', ttlSeconds), + logger: Logger, + }); +} + +async function altchaChallengeData(altcha: AltchaProvider | null): Promise | undefined> { + if (!altcha) return undefined; + return {captcha_provider: 'altcha', altcha_challenge: await altcha.createChallenge()}; +} + +async function resolveAltchaProvider(ctx: Context, user: User | undefined): Promise { + const config = await ctx.get('instanceConfigRepository').getAltchaCaptchaConfig(); + if (!altchaCaptchaAppliesTo(config, user ? user.id.toString() : null)) return null; + return createAltchaProvider(config); +} + function resolveProviderSecret( config: InstanceCaptchaEffectiveConfig, provider: InstanceCaptchaProvider, @@ -58,11 +92,19 @@ export async function verifyCaptchaToken(ctx: Context): Promise { if (accountPolicyContactHasCapability(user?.email, 'captcha_exempt')) return; if (userHasCaptchaExemptFlag(user)) return; if (await requestUserHasCaptchaExemptFlag(ctx)) return; + const altcha = await resolveAltchaProvider(ctx, user); const token = ctx.req.header(Headers.X_CAPTCHA_TOKEN); if (!token) { - throw new CaptchaRequiredError(); + throw new CaptchaRequiredError(await altchaChallengeData(altcha)); } - const provider = resolveCaptchaProvider(captchaConfig, ctx.req.header(Headers.X_CAPTCHA_TYPE)); + const requestedType = ctx.req.header(Headers.X_CAPTCHA_TYPE); + if (requestedType === 'altcha') { + if (!altcha || !(await altcha.verify({token}))) { + throw new InvalidCaptchaError(await altchaChallengeData(altcha)); + } + return; + } + const provider = resolveCaptchaProvider(captchaConfig, requestedType); const isValid = await provider.verify({ token, remoteIp: @@ -72,7 +114,7 @@ export async function verifyCaptchaToken(ctx: Context): Promise { }) ?? undefined, }); if (!isValid) { - throw new InvalidCaptchaError(); + throw new InvalidCaptchaError(await altchaChallengeData(altcha)); } } diff --git a/fluxer_api/src/api/middleware/tests/CaptchaProviderHeader.test.ts b/fluxer_api/src/api/middleware/tests/CaptchaProviderHeader.test.ts index cd194df02..7460a6cf9 100644 --- a/fluxer_api/src/api/middleware/tests/CaptchaProviderHeader.test.ts +++ b/fluxer_api/src/api/middleware/tests/CaptchaProviderHeader.test.ts @@ -8,6 +8,7 @@ import type { import {CaptchaMiddleware} from '@app/api/middleware/CaptchaMiddleware'; import type {HonoEnv} from '@app/api/types/HonoEnv'; import {AppErrorHandler} from '@fluxer/errors/src/domains/core/ErrorHandlers'; +import {DEFAULT_ALTCHA_CAPTCHA_CONFIG} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas'; import {Hono} from 'hono'; import {afterEach, beforeEach, describe, expect, it} from 'vitest'; @@ -25,6 +26,7 @@ function createHarness( ): (headers: Record) => Promise { const repository = { getEffectiveCaptchaConfig: async () => captcha, + getAltchaCaptchaConfig: async () => DEFAULT_ALTCHA_CAPTCHA_CONFIG, } as unknown as InstanceConfigRepository; const app = new Hono(); app.use(async (ctx, next) => { diff --git a/fluxer_api/src/api/openapi/openapi.json b/fluxer_api/src/api/openapi/openapi.json index d4cd46179..e114b0f07 100644 --- a/fluxer_api/src/api/openapi/openapi.json +++ b/fluxer_api/src/api/openapi/openapi.json @@ -27953,7 +27953,8 @@ "type": "object", "properties": { "voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionAssignmentResponse"}, - "domain_migration": {"$ref": "#/components/schemas/DomainMigrationAssignmentResponse"} + "domain_migration": {"$ref": "#/components/schemas/DomainMigrationAssignmentResponse"}, + "altcha_captcha": {"$ref": "#/components/schemas/AltchaCaptchaAssignmentResponse"} }, "additionalProperties": false } @@ -31629,6 +31630,12 @@ "additionalProperties": false }, "DonationCurrency": {"type": "string", "enum": ["usd", "eur", "brl", "inr", "pln", "try", "sek", "dkk", "nok"]}, + "AltchaCaptchaAssignmentResponse": { + "type": "object", + "properties": {"enabled": {"type": "boolean"}}, + "required": ["enabled"], + "additionalProperties": false + }, "DomainMigrationAssignmentResponse": { "type": "object", "properties": {"enabled": {"type": "boolean"}}, diff --git a/fluxer_app/package.json b/fluxer_app/package.json index ce8ed3fb7..07af24745 100644 --- a/fluxer_app/package.json +++ b/fluxer_app/package.json @@ -201,6 +201,7 @@ "@sapphi-red/web-noise-suppressor": "catalog:", "@simplewebauthn/browser": "catalog:", "@tanstack/react-virtual": "^3.14.13", + "altcha-lib": "catalog:", "animejs": "4.5.0", "bowser": "catalog:", "clsx": "catalog:", diff --git a/fluxer_app/src/features/auth/altcha/AltchaSolver.ts b/fluxer_app/src/features/auth/altcha/AltchaSolver.ts new file mode 100644 index 000000000..c50407f86 --- /dev/null +++ b/fluxer_app/src/features/auth/altcha/AltchaSolver.ts @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {solveChallengeWorkers} from 'altcha-lib'; +import type {Challenge} from 'altcha-lib/types'; + +export type AltchaChallenge = Challenge; + +const MAX_SOLVER_WORKERS = 8; +const SOLVE_TIMEOUT_MS = 120_000; + +function createSolverWorker(): Worker { + return new Worker( + new URL(/* webpackChunkName: "altcha-solver.worker" */ './AltchaSolverWorker.ts', import.meta.url), + { + type: 'module', + }, + ); +} + +export function readAltchaChallenge(body: unknown): AltchaChallenge | null { + if (typeof body !== 'object' || body === null) return null; + const {captcha_provider: provider, altcha_challenge: challenge} = body as Record; + if (provider !== 'altcha' || typeof challenge !== 'object' || challenge === null) return null; + const {parameters, signature} = challenge as Record; + if (typeof parameters !== 'object' || parameters === null || typeof signature !== 'string') return null; + return challenge as AltchaChallenge; +} + +export async function solveAltchaChallenge( + challenge: AltchaChallenge, + controller: AbortController, +): Promise { + const solution = await solveChallengeWorkers({ + challenge, + concurrency: Math.min(MAX_SOLVER_WORKERS, navigator.hardwareConcurrency || 2), + controller, + createWorker: createSolverWorker, + timeout: SOLVE_TIMEOUT_MS, + }); + if (!solution) return null; + return btoa( + JSON.stringify({challenge: {parameters: challenge.parameters, signature: challenge.signature}, solution}), + ); +} diff --git a/fluxer_app/src/features/auth/altcha/AltchaSolverWorker.ts b/fluxer_app/src/features/auth/altcha/AltchaSolverWorker.ts new file mode 100644 index 000000000..ffa69b2b3 --- /dev/null +++ b/fluxer_app/src/features/auth/altcha/AltchaSolverWorker.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {deriveKey} from 'altcha-lib/algorithms/web/pbkdf2'; +import {handler} from 'altcha-lib/workers/shared'; + +handler({deriveKey}); diff --git a/fluxer_app/src/features/auth/components/AltchaVerification.module.css b/fluxer_app/src/features/auth/components/AltchaVerification.module.css new file mode 100644 index 000000000..ee4360231 --- /dev/null +++ b/fluxer_app/src/features/auth/components/AltchaVerification.module.css @@ -0,0 +1,16 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ + +.container { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + padding: 1rem 0; +} + +.text { + font-size: 0.875rem; + line-height: 1.25rem; + text-align: center; + color: var(--text-secondary); +} diff --git a/fluxer_app/src/features/auth/components/AltchaVerification.tsx b/fluxer_app/src/features/auth/components/AltchaVerification.tsx new file mode 100644 index 000000000..3a546e3a8 --- /dev/null +++ b/fluxer_app/src/features/auth/components/AltchaVerification.tsx @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {type AltchaChallenge, solveAltchaChallenge} from '@app/features/auth/altcha/AltchaSolver'; +import styles from '@app/features/auth/components/AltchaVerification.module.css'; +import {Logger} from '@app/features/platform/utils/AppLogger'; +import {Button} from '@app/features/ui/button/Button'; +import {Spinner} from '@app/features/ui/components/Spinner'; +import {Trans} from '@lingui/react/macro'; +import {useCallback, useEffect, useRef, useState} from 'react'; + +const logger = new Logger('AltchaVerification'); + +interface AltchaVerificationProps { + challenge: AltchaChallenge; + onVerify: (token: string) => void; +} + +export function AltchaVerification({challenge, onVerify}: AltchaVerificationProps) { + const onVerifyRef = useRef(onVerify); + const [attempt, setAttempt] = useState(0); + const [failed, setFailed] = useState(false); + useEffect(() => { + onVerifyRef.current = onVerify; + }, [onVerify]); + useEffect(() => { + const controller = new AbortController(); + setFailed(false); + solveAltchaChallenge(challenge, controller).then( + (token) => { + if (controller.signal.aborted) return; + if (token) { + onVerifyRef.current(token); + } else { + setFailed(true); + } + }, + (error: unknown) => { + if (controller.signal.aborted) return; + logger.error('ALTCHA solve failed:', error); + setFailed(true); + }, + ); + return () => controller.abort(); + }, [challenge, attempt]); + const handleRetry = useCallback(() => setAttempt((value) => value + 1), []); + if (failed) { + return ( +
+

+ Your browser couldn't finish the check. +

+ +
+ ); + } + return ( +
+ +

+ Checking your browser. This takes a few seconds. +

+
+ ); +} diff --git a/fluxer_app/src/features/auth/components/CaptchaInterceptor.tsx b/fluxer_app/src/features/auth/components/CaptchaInterceptor.tsx index 40c054c6c..323a6800d 100644 --- a/fluxer_app/src/features/auth/components/CaptchaInterceptor.tsx +++ b/fluxer_app/src/features/auth/components/CaptchaInterceptor.tsx @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {type AltchaChallenge, readAltchaChallenge} from '@app/features/auth/altcha/AltchaSolver'; import {CaptchaModal, type CaptchaType} from '@app/features/auth/components/modals/CaptchaModal'; import {http} from '@app/features/platform/transport/RestTransport'; import type {RestResponse} from '@app/features/platform/types/TransportTypes'; @@ -69,7 +70,7 @@ class CaptchaInterceptorState { return code === 'CAPTCHA_REQUIRED' || code === 'INVALID_CAPTCHA'; } - private showCaptchaModal(): Promise { + private showCaptchaModal(altchaChallenge: AltchaChallenge | null): Promise { if (this.pendingPromise) { this.pendingPromise.reject(new Error('Captcha cancelled')); this.pendingPromise = null; @@ -95,6 +96,7 @@ class CaptchaInterceptorState { }; const CaptchaModalWrapper = observer(() => ( { this.state.setError(null); this.state.setIsVerifying(false); diff --git a/fluxer_app/src/features/auth/components/modals/CaptchaModal.tsx b/fluxer_app/src/features/auth/components/modals/CaptchaModal.tsx index c5d8c23d3..b3cc9042b 100644 --- a/fluxer_app/src/features/auth/components/modals/CaptchaModal.tsx +++ b/fluxer_app/src/features/auth/components/modals/CaptchaModal.tsx @@ -2,6 +2,8 @@ import * as Modal from '@app/features/app/components/dialogs/Modal'; import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; +import type {AltchaChallenge} from '@app/features/auth/altcha/AltchaSolver'; +import {AltchaVerification} from '@app/features/auth/components/AltchaVerification'; import styles from '@app/features/auth/components/modals/CaptchaModal.module.css'; import {TurnstileWidget} from '@app/features/auth/components/TurnstileWidget'; import {Logger} from '@app/features/platform/utils/AppLogger'; @@ -18,7 +20,7 @@ const VERIFY_YOU_RE_HUMAN_DESCRIPTOR = msg({ }); const logger = new Logger('CaptchaModal'); -export type CaptchaType = 'turnstile' | 'hcaptcha'; +export type CaptchaType = 'turnstile' | 'hcaptcha' | 'altcha'; interface HCaptchaComponentProps { sitekey: string; @@ -35,16 +37,26 @@ interface CaptchaModalProps { onVerify: (token: string, captchaType: CaptchaType) => void; onCancel?: () => void; preferredType?: CaptchaType; + altchaChallenge?: AltchaChallenge | null; error?: string | null; isVerifying?: boolean; closeOnVerify?: boolean; } export const CaptchaModal = observer( - ({onVerify, onCancel, preferredType, error, isVerifying, closeOnVerify = true}: CaptchaModalProps) => { + ({ + onVerify, + onCancel, + preferredType, + altchaChallenge, + error, + isVerifying, + closeOnVerify = true, + }: CaptchaModalProps) => { const {i18n} = useLingui(); const hcaptchaRef = useRef(null); const [captchaType, setCaptchaType] = useState(() => { + if (altchaChallenge) return 'altcha'; if (preferredType) return preferredType; if (RuntimeConfig.captchaProvider === 'turnstile' && RuntimeConfig.turnstileSiteKey) { return 'turnstile'; @@ -123,7 +135,13 @@ export const CaptchaModal = observer( )}
- {captchaType === 'turnstile' ? ( + {captchaType === 'altcha' && altchaChallenge ? ( + + ) : captchaType === 'turnstile' ? (