Verify ALTCHA before upload body parse with single-use replay store.

Run challenge TTL and replay tracking in middleware, remove the guard, and
wire ScheduleModule for pruning used solutions.
This commit is contained in:
2026-10-12 20:18:36 +02:00
parent 96383a7426
commit 7f99256863
15 changed files with 446 additions and 84 deletions
+2 -3
View File
@@ -1,11 +1,10 @@
import { Module } from '@nestjs/common';
import { AltchaController } from './controllers/AltchaController';
import { AltchaGuard } from './guards/AltchaGuard';
import { AltchaService } from './services/AltchaService';
import { UsedAltchaStoreService } from './services/UsedAltchaStoreService';
@Module({
controllers: [AltchaController],
providers: [AltchaService, AltchaGuard],
exports: [AltchaGuard]
providers: [AltchaService, UsedAltchaStoreService]
})
export class AltchaModule {}
@@ -1,7 +1,7 @@
import { Controller, Get, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Config } from '../../../types/config/Config';
import { AltchaService } from '../services/AltchaService';
import { Config } from '../../../types/Config';
@Controller('altcha')
export class AltchaController {
@@ -1,36 +0,0 @@
import { BadRequestException, CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
import { AltchaService } from '../services/AltchaService';
import { Config } from '../../../types/Config';
@Injectable()
export class AltchaGuard implements CanActivate {
constructor(
private readonly altchaService: AltchaService,
private readonly configService: ConfigService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const { enabled } = this.configService.get('altcha') as Config['altcha'];
if (!enabled) {
return true;
}
const request = context.switchToHttp().getRequest<Request>();
const altcha = request.body?.altcha;
if (typeof altcha !== 'string' || altcha.length === 0) {
throw new BadRequestException('ALTCHA verification required');
}
const verified = await this.altchaService.verifyPayload(altcha);
if (!verified) {
throw new BadRequestException('ALTCHA verification failed');
}
return true;
}
}
@@ -1,39 +1,138 @@
import { ConfigService } from '@nestjs/config';
import { createChallenge, solveChallenge } from 'altcha-lib/v1';
import type { Payload } from 'altcha-lib/v1/types';
import { createChallenge, extractParams, solveChallenge } from 'altcha-lib/v1';
import type { Challenge, Payload } from 'altcha-lib/v1/types';
import { AltchaVerificationStatus } from '../types/AltchaVerificationStatus';
import { AltchaService } from './AltchaService';
import { UsedAltchaStoreService } from './UsedAltchaStoreService';
const hmacKey = 'test-hmac-key';
const encodePayload = (payload: Payload): string =>
Buffer.from(JSON.stringify(payload), 'utf8').toString('base64');
const encodeSolvedChallenge = async (challenge: Challenge): Promise<string> => {
const { promise } = solveChallenge(challenge.challenge, challenge.salt, challenge.algorithm, 100_000);
const solution = await promise;
expect(solution).not.toBeNull();
return encodePayload({
algorithm: challenge.algorithm,
challenge: challenge.challenge,
number: solution!.number,
salt: challenge.salt,
signature: challenge.signature
});
};
const solveChallengePayload = async (): Promise<string> =>
encodeSolvedChallenge(await createChallenge({ hmacKey, number: 42, maxNumber: 100_000 }));
const createConfigService = (enabled: boolean): ConfigService =>
({
get: jest.fn((key: string) => {
if (key === 'altcha') {
return {
enabled,
hmacKey,
maxNumber: 100_000,
challengeTtlMs: 600_000
};
}
return undefined;
})
}) as unknown as ConfigService;
const createAltchaService = (enabled: boolean): AltchaService => {
const configService = createConfigService(enabled);
return new AltchaService(configService, new UsedAltchaStoreService(configService));
};
describe('AltchaService', () => {
const configService = {
get: jest.fn(() => ({ enabled: true, hmacKey, maxNumber: 100_000 }))
} as unknown as ConfigService;
it('verifies and consumes a valid ALTCHA payload', async () => {
const service = createAltchaService(true);
const altcha = await solveChallengePayload();
const service = new AltchaService(configService);
it('verifies a valid ALTCHA payload', async () => {
const challenge = await createChallenge({ hmacKey, number: 42, maxNumber: 100_000 });
const { promise } = solveChallenge(challenge.challenge, challenge.salt, challenge.algorithm, 100_000);
const solution = await promise;
expect(solution).not.toBeNull();
const payload: Payload = {
algorithm: challenge.algorithm,
challenge: challenge.challenge,
number: solution!.number,
salt: challenge.salt,
signature: challenge.signature
};
await expect(service.verifyPayload(encodePayload(payload))).resolves.toBe(true);
await expect(service.verifySolution(altcha)).resolves.toBe(AltchaVerificationStatus.Ok);
});
it('rejects an invalid ALTCHA payload', async () => {
await expect(service.verifyPayload('not-valid-base64-json')).resolves.toBe(false);
const service = createAltchaService(true);
await expect(service.verifySolution('not-valid-base64-json')).resolves.toBe(
AltchaVerificationStatus.Invalid
);
});
it('rejects an expired ALTCHA payload', async () => {
const service = createAltchaService(true);
const altcha = await encodeSolvedChallenge(
await createChallenge({
hmacKey,
number: 42,
maxNumber: 100_000,
expires: new Date(Date.now() - 60_000)
})
);
await expect(service.verifySolution(altcha)).resolves.toBe(AltchaVerificationStatus.Invalid);
});
it('rejects a payload signed with a different hmac key', async () => {
const service = createAltchaService(true);
const altcha = await encodeSolvedChallenge(
await createChallenge({ hmacKey: 'other-key', number: 42, maxNumber: 100_000 })
);
await expect(service.verifySolution(altcha)).resolves.toBe(AltchaVerificationStatus.Invalid);
});
it('rejects a missing ALTCHA payload when enabled', async () => {
const service = createAltchaService(true);
await expect(service.verifySolution(undefined)).resolves.toBe(AltchaVerificationStatus.Missing);
await expect(service.verifySolution('')).resolves.toBe(AltchaVerificationStatus.Missing);
});
it('rejects an immediate replay', async () => {
const service = createAltchaService(true);
const altcha = await solveChallengePayload();
await expect(service.verifySolution(altcha)).resolves.toBe(AltchaVerificationStatus.Ok);
await expect(service.verifySolution(altcha)).resolves.toBe(AltchaVerificationStatus.AlreadyUsed);
});
it('skips verification when ALTCHA is disabled', async () => {
const service = createAltchaService(false);
await expect(service.verifySolution(undefined)).resolves.toBe(AltchaVerificationStatus.Ok);
});
it('does not enforce replay protection when ALTCHA is disabled', async () => {
const service = createAltchaService(false);
const altcha = await solveChallengePayload();
await expect(service.verifySolution(altcha)).resolves.toBe(AltchaVerificationStatus.Ok);
await expect(service.verifySolution(altcha)).resolves.toBe(AltchaVerificationStatus.Ok);
});
describe('createChallenge', () => {
it('applies maxNumber and challenge TTL from config', async () => {
const service = createAltchaService(true);
const before = Date.now();
const challenge = await service.createChallenge();
expect(challenge.maxnumber).toBe(100_000);
expect(challenge.signature).toBeTruthy();
const { expires } = extractParams(challenge);
const expiresAtMs = Number(expires) * 1000;
expect(expiresAtMs).toBeGreaterThanOrEqual(before + 600_000 - 1000);
expect(expiresAtMs).toBeLessThanOrEqual(before + 600_000 + 5000);
});
});
});
@@ -1,25 +1,52 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createChallenge, verifySolution } from 'altcha-lib/v1';
import dayjs from 'dayjs';
import type { Challenge } from 'altcha-lib/v1/types';
import { Config } from '../../../types/Config';
import { Config } from '../../../types/config/Config';
import { AltchaVerificationStatus } from '../types/AltchaVerificationStatus';
import { UsedAltchaStoreService } from './UsedAltchaStoreService';
@Injectable()
export class AltchaService {
constructor(private readonly configService: ConfigService) {}
constructor(
private readonly configService: ConfigService,
private readonly usedAltchaStore: UsedAltchaStoreService
) {}
async createChallenge(): Promise<Challenge> {
const { hmacKey, maxNumber } = this.configService.get('altcha') as Config['altcha'];
const { hmacKey, maxNumber, challengeTtlMs } = this.configService.get('altcha') as Config['altcha'];
return createChallenge({
hmacKey,
maxNumber
maxNumber,
expires: dayjs().add(challengeTtlMs, 'millisecond').toDate()
});
}
async verifyPayload(altcha: string): Promise<boolean> {
const { hmacKey } = this.configService.get('altcha') as Config['altcha'];
async verifySolution(altcha: string | undefined): Promise<AltchaVerificationStatus> {
const { enabled, hmacKey } = this.configService.get('altcha') as Config['altcha'];
return verifySolution(altcha, hmacKey);
if (!enabled) {
return AltchaVerificationStatus.Ok;
}
if (!altcha) {
return AltchaVerificationStatus.Missing;
}
const verified = await verifySolution(altcha, hmacKey);
if (!verified) {
return AltchaVerificationStatus.Invalid;
}
const consumed = this.usedAltchaStore.tryConsume(altcha);
if (!consumed) {
return AltchaVerificationStatus.AlreadyUsed;
}
return AltchaVerificationStatus.Ok;
}
}
@@ -0,0 +1,88 @@
import { ConfigService } from '@nestjs/config';
import { UsedAltchaStoreService } from './UsedAltchaStoreService';
const createConfigService = (challengeTtlMs: number): ConfigService =>
({
get: jest.fn((key: string) => {
if (key === 'altcha') {
return { challengeTtlMs };
}
return undefined;
})
}) as unknown as ConfigService;
describe('UsedAltchaStoreService', () => {
it('allows the first consume and rejects an immediate replay', () => {
const store = new UsedAltchaStoreService(createConfigService(60_000));
const altcha = 'valid-solution-payload';
expect(store.tryConsume(altcha)).toBe(true);
expect(store.tryConsume(altcha)).toBe(false);
});
it('rejects replay until prune removes the entry after expiry', () => {
const store = new UsedAltchaStoreService(createConfigService(1));
const altcha = 'expires-quickly';
expect(store.tryConsume(altcha)).toBe(true);
return new Promise<void>(resolve => {
setTimeout(() => {
expect(store.tryConsume(altcha)).toBe(false);
store.pruneExpired();
expect(store.tryConsume(altcha)).toBe(true);
resolve();
}, 5);
});
});
it('pruneExpired removes expired entries', () => {
const store = new UsedAltchaStoreService(createConfigService(1));
const altcha = 'prune-me';
expect(store.tryConsume(altcha)).toBe(true);
return new Promise<void>(resolve => {
setTimeout(() => {
store.pruneExpired();
expect(store.tryConsume(altcha)).toBe(true);
resolve();
}, 5);
});
});
it('tracks different payloads independently', () => {
const store = new UsedAltchaStoreService(createConfigService(60_000));
expect(store.tryConsume('payload-a')).toBe(true);
expect(store.tryConsume('payload-b')).toBe(true);
expect(store.tryConsume('payload-a')).toBe(false);
expect(store.tryConsume('payload-b')).toBe(false);
});
it('pruneExpired does not remove entries before their expiry', () => {
const store = new UsedAltchaStoreService(createConfigService(60_000));
expect(store.tryConsume('still-active')).toBe(true);
store.pruneExpired();
expect(store.tryConsume('still-active')).toBe(false);
});
it('pruneExpired clears every expired entry in one pass', () => {
const store = new UsedAltchaStoreService(createConfigService(1));
expect(store.tryConsume('first')).toBe(true);
expect(store.tryConsume('second')).toBe(true);
return new Promise<void>(resolve => {
setTimeout(() => {
store.pruneExpired();
expect(store.tryConsume('first')).toBe(true);
expect(store.tryConsume('second')).toBe(true);
resolve();
}, 5);
});
});
});
@@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Cron, CronExpression } from '@nestjs/schedule';
import dayjs from 'dayjs';
import { createHash } from 'node:crypto';
import { Config } from '../../../types/config/Config';
@Injectable()
export class UsedAltchaStoreService {
private readonly entries = new Map<string, number>();
constructor(private readonly configService: ConfigService) {}
tryConsume(altcha: string): boolean {
const key = createHash('sha256').update(altcha, 'utf8').digest('hex');
if (this.entries.has(key)) {
return false;
}
const { challengeTtlMs } = this.configService.get('altcha') as Config['altcha'];
this.entries.set(key, dayjs().add(challengeTtlMs, 'millisecond').valueOf());
return true;
}
@Cron(CronExpression.EVERY_MINUTE)
pruneExpired(): void {
const now = dayjs();
for (const [key, expiresAt] of this.entries) {
if (now.isAfter(expiresAt)) {
this.entries.delete(key);
}
}
}
}
@@ -0,0 +1,6 @@
export enum AltchaVerificationStatus {
Ok = 'ok',
Missing = 'missing',
Invalid = 'invalid',
AlreadyUsed = 'already_used'
}