157 lines
4.8 KiB
TypeScript
157 lines
4.8 KiB
TypeScript
import { describe, expect, it, afterEach } from 'vitest';
|
|
import { createHash } from 'node:crypto';
|
|
|
|
import { AltchaService, type AltchaSolution } from '../src/policy/altcha.ts';
|
|
import { ManualClock } from '../src/policy/clock.ts';
|
|
|
|
/** Brute-force solve an Altcha challenge. Mirrors what a browser-
|
|
* side widget does. */
|
|
function solve(challenge: {
|
|
salt: string;
|
|
challenge: string;
|
|
signature: string;
|
|
maxnumber: number;
|
|
}): AltchaSolution {
|
|
for (let n = 0; n <= challenge.maxnumber; n++) {
|
|
const h = createHash('sha256')
|
|
.update(challenge.salt + n.toString())
|
|
.digest('hex');
|
|
if (h === challenge.challenge) {
|
|
return {
|
|
algorithm: 'SHA-256',
|
|
salt: challenge.salt,
|
|
challenge: challenge.challenge,
|
|
signature: challenge.signature,
|
|
number: n
|
|
};
|
|
}
|
|
}
|
|
throw new Error('altcha: no solution found within maxnumber');
|
|
}
|
|
|
|
describe('AltchaService', () => {
|
|
const services: AltchaService[] = [];
|
|
afterEach(() => {
|
|
for (const s of services) s.close();
|
|
services.length = 0;
|
|
});
|
|
|
|
function make(opts?: ConstructorParameters<typeof AltchaService>[0]) {
|
|
// Small maxnumber keeps test-solving fast. A bot couldn't
|
|
// care less — we're testing correctness, not PoW strength.
|
|
const svc = new AltchaService({ maxnumber: 1000, ...opts });
|
|
services.push(svc);
|
|
return svc;
|
|
}
|
|
|
|
it('issues a well-formed challenge', () => {
|
|
const svc = make();
|
|
const c = svc.issue();
|
|
expect(c.algorithm).toBe('SHA-256');
|
|
expect(c.challenge).toMatch(/^[0-9a-f]{64}$/);
|
|
expect(c.signature).toMatch(/^[0-9a-f]{64}$/);
|
|
expect(c.salt).toMatch(/^[0-9a-f]+\?expires=\d+$/);
|
|
expect(c.maxnumber).toBe(1000);
|
|
});
|
|
|
|
it('verify: accepts a valid solution', () => {
|
|
const svc = make();
|
|
const c = svc.issue();
|
|
const sol = solve(c);
|
|
const r = svc.verify(sol);
|
|
expect(r.ok).toBe(true);
|
|
});
|
|
|
|
it('verify: rejects with altcha_bad_solution when number does not hash to challenge', () => {
|
|
const svc = make();
|
|
const c = svc.issue();
|
|
const sol = solve(c);
|
|
// Tamper with the solved number.
|
|
const tampered: AltchaSolution = { ...sol, number: sol.number + 1 };
|
|
const r = svc.verify(tampered);
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.code).toBe('altcha_bad_solution');
|
|
});
|
|
|
|
it('verify: rejects with altcha_bad_signature when signature tampered', () => {
|
|
const svc = make();
|
|
const c = svc.issue();
|
|
const sol = solve(c);
|
|
// Flip the FIRST character of the signature.
|
|
//
|
|
// altcha currently encodes sig as hex (4-bit-per-digit, no
|
|
// padding-equivalent positions), so any single-char flip
|
|
// changes the decoded bytes — last-char flip works fine
|
|
// here. But Part 85 documented the broader anti-pattern
|
|
// for base64url HMACs, where last-char flips ~6% of the
|
|
// time decode to the same bytes. Using first-char flip
|
|
// here keeps the test resilient if altcha's encoding ever
|
|
// changes to base64url (no diff to the assertion's intent).
|
|
const sig = sol.signature;
|
|
const tamperedSig = (sig.at(0) === 'a' ? 'b' : 'a') + sig.slice(1);
|
|
const r = svc.verify({ ...sol, signature: tamperedSig });
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.code).toBe('altcha_bad_signature');
|
|
});
|
|
|
|
it('verify: single-use — second verify with same salt fails altcha_replayed', () => {
|
|
const svc = make();
|
|
const c = svc.issue();
|
|
const sol = solve(c);
|
|
|
|
const first = svc.verify(sol);
|
|
expect(first.ok).toBe(true);
|
|
|
|
const second = svc.verify(sol);
|
|
expect(second.ok).toBe(false);
|
|
if (!second.ok) expect(second.code).toBe('altcha_replayed');
|
|
});
|
|
|
|
it('verify: rejects altcha_expired for past-expiry challenges', () => {
|
|
// Item 6 / Audit Part 27: ManualClock makes this
|
|
// deterministic and instant. Previously: real
|
|
// setTimeout(150) on a 100ms TTL.
|
|
const clock = new ManualClock('2026-05-15T12:00:00Z');
|
|
const svc = make({ ttlMs: 100, clock });
|
|
const c = svc.issue();
|
|
const sol = solve(c);
|
|
// Advance past expiry and verify.
|
|
clock.advance(150);
|
|
const r = svc.verify(sol);
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.code).toBe('altcha_expired');
|
|
});
|
|
|
|
it('verify: rejects altcha_malformed on missing fields', () => {
|
|
const svc = make();
|
|
const badCases: unknown[] = [
|
|
{},
|
|
{ algorithm: 'SHA-256' },
|
|
{ algorithm: 'MD5', challenge: 'x', salt: 'x', signature: 'x', number: 0 },
|
|
{
|
|
algorithm: 'SHA-256',
|
|
challenge: 'x',
|
|
salt: 'x',
|
|
signature: 'not-hex!!!',
|
|
number: 0
|
|
}
|
|
];
|
|
for (const bad of badCases) {
|
|
const r = svc.verify(bad as AltchaSolution);
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) {
|
|
expect(['altcha_malformed', 'altcha_bad_signature', 'altcha_expired']).toContain(r.code);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("different service instances cannot verify each other's challenges", () => {
|
|
const s1 = make();
|
|
const s2 = make();
|
|
const c = s1.issue();
|
|
const sol = solve(c);
|
|
const r = s2.verify(sol);
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.code).toBe('altcha_bad_signature');
|
|
});
|
|
});
|