feat(captcha): add ALTCHA proof-of-work captcha experiment (#2986)

This commit is contained in:
Hampus
2026-09-27 21:02:55 +02:00
committed by GitHub
parent 33a118d12a
commit 7e1b934637
69 changed files with 1848 additions and 216 deletions
+1
View File
@@ -56,6 +56,7 @@
"@simplewebauthn/server": "catalog:",
"@types/node": "catalog:",
"@vvo/tzdb": "catalog:",
"altcha-lib": "catalog:",
"archiver": "catalog:",
"argon2": "catalog:",
"bowser": "catalog:",
+3 -1
View File
@@ -11,7 +11,9 @@
},
"dependencies": {
"@fluxer/logger": "workspace:*",
"itty-time": "catalog:"
"altcha-lib": "catalog:",
"itty-time": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
@@ -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;
@@ -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<typeof AltchaPayloadSchema>;
export interface AltchaProviderOptions {
hmacSignatureSecret: string;
hmacKeySignatureSecret: string;
cost: number;
maxCounter: number;
claimChallenge: (signature: string, ttlSeconds: number) => Promise<boolean>;
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<Challenge> {
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<boolean> {
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);
}
}
@@ -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<InstanceConfigResponse> {
voiceNoiseSuppression,
pushServiceDelivery,
domainMigration,
altchaCaptcha,
experimentDelivery,
registrationConfig,
registrationUrls,
@@ -81,6 +83,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
instanceConfigRepository.getPushServiceDeliveryConfig(),
instanceConfigRepository.getDomainMigrationConfig(),
instanceConfigRepository.getAltchaCaptchaConfig(),
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getRegistrationConfig(),
instanceConfigRepository.getRegistrationUrlsForAdmin(),
@@ -114,6 +117,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
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) =>
@@ -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: '[email protected]'};
async function setAltchaConfig(overrides: Partial<AltchaCaptchaConfig>): Promise<void> {
await getInstanceConfigRepository().setAltchaCaptchaConfig({
...DEFAULT_ALTCHA_CAPTCHA_CONFIG,
enabled: true,
cost: 1000,
max_counter: 100,
...overrides,
});
}
async function solve(challenge: Challenge): Promise<string> {
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<CaptchaErrorBody>, code: string): Promise<CaptchaErrorBody> {
const {json} = await builder.expect(HTTP_STATUS.BAD_REQUEST, code).executeWithResponse();
expect(json.code).toBe(code);
return json;
}
function forgot(harness: ApiTestHarness): TestRequestBuilder<CaptchaErrorBody> {
return createBuilderWithoutAuth<CaptchaErrorBody>(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<CaptchaErrorBody>(harness, excluded.token).post(redeemPath),
APIErrorCodes.CAPTCHA_REQUIRED,
);
expect(excludedBody).not.toHaveProperty('altcha_challenge');
const includedBody = await rejectWith(
createBuilder<CaptchaErrorBody>(harness, included.token).post(redeemPath),
APIErrorCodes.CAPTCHA_REQUIRED,
);
const token = await solve(includedBody.altcha_challenge as Challenge);
const solved = await createBuilder<CaptchaErrorBody>(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<CaptchaErrorBody>(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);
});
});
@@ -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')}"`;
@@ -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<ExperimentAssignmentsResponse>(harness, targeted.token)
.get(ENDPOINT)
.execute();
expect(targetedBody.assignments.altcha_captcha).toEqual({enabled: true});
const excludedBody = await createBuilder<ExperimentAssignmentsResponse>(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<ExperimentAssignmentsResponse>(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({
@@ -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<AltchaCaptchaConfig> {
const raw = await this.getConfig(ALTCHA_CAPTCHA_CONFIG_KEY);
return parseStoredAltchaCaptchaConfig(raw);
}
async setAltchaCaptchaConfig(config: AltchaCaptchaConfig): Promise<void> {
await this.updateAltchaCaptchaConfig(() => config);
}
updateAltchaCaptchaConfig(
update: (current: AltchaCaptchaConfig) => AltchaCaptchaConfig,
): Promise<AltchaCaptchaConfig> {
return this.updateStoredConfig(ALTCHA_CAPTCHA_CONFIG_KEY, (raw) =>
validateStoredConfig(AltchaCaptchaConfigSchema, update(parseStoredAltchaCaptchaConfig(raw)), 'altcha captcha'),
);
}
async getExperimentDeliveryConfig(): Promise<ExperimentDeliveryConfig> {
const raw = await this.getConfig(EXPERIMENT_DELIVERY_CONFIG_KEY);
return parseStoredExperimentDeliveryConfig(raw);
@@ -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<Record<string, unknown> | undefined> {
if (!altcha) return undefined;
return {captcha_provider: 'altcha', altcha_challenge: await altcha.createChallenge()};
}
async function resolveAltchaProvider(ctx: Context<HonoEnv>, user: User | undefined): Promise<AltchaProvider | null> {
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<HonoEnv>): Promise<void> {
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<HonoEnv>): Promise<void> {
}) ?? undefined,
});
if (!isValid) {
throw new InvalidCaptchaError();
throw new InvalidCaptchaError(await altchaChallengeData(altcha));
}
}
@@ -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<string, string>) => Promise<Response> {
const repository = {
getEffectiveCaptchaConfig: async () => captcha,
getAltchaCaptchaConfig: async () => DEFAULT_ALTCHA_CAPTCHA_CONFIG,
} as unknown as InstanceConfigRepository;
const app = new Hono<HonoEnv>();
app.use(async (ctx, next) => {
+8 -1
View File
@@ -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"}},