feat(core): Phase 1.3 Public Verification Integrity - drawId persistence, real participant snapshot and rules recalculation, reserve winners check, auditEventHash verification, strict snapshot lookup, and anti-tampering test suite

This commit is contained in:
Ochenstarik 2026-08-18 00:15:30 +07:00
parent 1bc6650041
commit 26e82fcf8d
11 changed files with 511 additions and 97 deletions

View file

@ -89,6 +89,7 @@ model ParticipantSnapshot {
version Int @default(1) version Int @default(1)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
eligibleParticipants Json // Canonical JSON array of FilteredParticipant eligibleParticipants Json // Canonical JSON array of FilteredParticipant
filterRulesSnapshot Json // Canonical JSON snapshot of FilterRules
participantCount Int participantCount Int
participantsSnapshotHash String participantsSnapshotHash String
conditionsHash String conditionsHash String
@ -102,6 +103,7 @@ model ParticipantSnapshot {
model DrawResult { model DrawResult {
id String @id @default(cuid()) id String @id @default(cuid())
drawId String // Original domain drawId (e.g. draw_8f9102ab...)
giveawayId String @unique giveawayId String @unique
giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade) giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade)
snapshotId String @unique snapshotId String @unique

View file

@ -21,13 +21,14 @@ export async function GET(
}, { status: 400 }); }, { status: 400 });
} }
// Find the snapshot associated with this draw // Strict snapshot lookup: DO NOT fallback to latestSnapshot
const snapshot = giveaway.snapshots.find(s => s.id === drawResult.snapshotId) const snapshot = giveaway.snapshots.find(s => s.id === drawResult.snapshotId);
|| giveaway.latestSnapshot;
if (!snapshot) { if (!snapshot) {
return NextResponse.json({ return NextResponse.json({
error: `Participant snapshot "${drawResult.snapshotId}" not found for this giveaway` error: `Integrity Error: Participant snapshot "${drawResult.snapshotId}" referenced by draw does not exist in storage`,
verified: false,
snapshotFound: false,
}, { status: 404 }); }, { status: 404 });
} }
@ -35,29 +36,38 @@ export async function GET(
const claimedReserveCount = drawResult.reserveWinners.length; const claimedReserveCount = drawResult.reserveWinners.length;
// Run independent cryptographic replay verification // Run independent cryptographic replay verification
const verification = verifyDrawResult( const verification = verifyDrawResult({
giveawayId: id,
drawId: drawResult.drawId,
drawnAt: drawResult.drawnAt,
snapshot, snapshot,
drawResult.seedUsed, seed: drawResult.seedUsed,
claimedWinnersCount, claimedWinnersCount,
claimedReserveCount, claimedReserveCount,
drawResult.winnerIds, claimedWinnerIds: drawResult.winnerIds,
drawResult.deterministicProofHash, claimedReserveWinnerIds: drawResult.reserveWinnerIds,
drawResult.algorithmVersion claimedDeterministicProofHash: drawResult.deterministicProofHash,
); claimedAuditEventHash: drawResult.auditEventHash,
algorithmVersion: drawResult.algorithmVersion,
});
return NextResponse.json({ return NextResponse.json({
verified: verification.verified, verified: verification.verified,
giveawayId: id, giveawayId: id,
drawId: drawResult.drawId,
snapshotId: snapshot.id, snapshotId: snapshot.id,
algorithmVersion: verification.algorithmVersion, algorithmVersion: verification.algorithmVersion,
algorithmSupported: verification.algorithmSupported,
participantsSnapshotIntegrity: verification.participantsSnapshotIntegrity,
conditionsIntegrity: verification.conditionsIntegrity,
winnersMatch: verification.winnersMatch, winnersMatch: verification.winnersMatch,
snapshotHashMatch: verification.snapshotHashMatch, reserveWinnersMatch: verification.reserveWinnersMatch,
conditionsHashMatch: verification.conditionsHashMatch,
deterministicProofHashMatch: verification.deterministicProofHashMatch, deterministicProofHashMatch: verification.deterministicProofHashMatch,
auditEventHashMatch: verification.auditEventHashMatch,
expectedWinnerIds: verification.expectedWinnerIds, expectedWinnerIds: verification.expectedWinnerIds,
expectedReserveWinnerIds: verification.expectedReserveWinnerIds, expectedReserveWinnerIds: verification.expectedReserveWinnerIds,
deterministicProofHash: verification.expectedDeterministicProofHash, deterministicProofHash: verification.expectedDeterministicProofHash,
auditEventHash: drawResult.auditEventHash, auditEventHash: verification.expectedAuditEventHash,
drawnAt: drawResult.drawnAt, drawnAt: drawResult.drawnAt,
}); });
} catch (error: any) { } catch (error: any) {

View file

@ -201,6 +201,7 @@ export default function GiveawayDetailPage() {
onClick={() => { onClick={() => {
navigator.clipboard.writeText(JSON.stringify({ navigator.clipboard.writeText(JSON.stringify({
giveawayId: giveaway.id, giveawayId: giveaway.id,
drawId: drawResult.drawId,
snapshotId: drawResult.snapshotId, snapshotId: drawResult.snapshotId,
algorithmVersion: drawResult.algorithmVersion, algorithmVersion: drawResult.algorithmVersion,
seed: drawResult.seedUsed, seed: drawResult.seedUsed,
@ -243,16 +244,22 @@ export default function GiveawayDetailPage() {
</> </>
)} )}
</div> </div>
<div className="grid grid-cols-2 gap-2 pt-1 font-mono text-[11px]"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-2 pt-1 font-mono text-[11px]">
<div>Целостность участников: {verificationResult.participantsSnapshotIntegrity ? 'ДА ✓' : 'НЕТ ✗'}</div>
<div>Целостность условий: {verificationResult.conditionsIntegrity ? 'ДА ✓' : 'НЕТ ✗'}</div>
<div>Победители совпали: {verificationResult.winnersMatch ? 'ДА ✓' : 'НЕТ ✗'}</div> <div>Победители совпали: {verificationResult.winnersMatch ? 'ДА ✓' : 'НЕТ ✗'}</div>
<div>Хеш слепка совпал: {verificationResult.snapshotHashMatch ? 'ДА ✓' : 'НЕТ ✗'}</div> <div>Резерв совпал: {verificationResult.reserveWinnersMatch ? 'ДА ✓' : 'НЕТ ✗'}</div>
<div>Хеш условий совпал: {verificationResult.conditionsHashMatch ? 'ДА ✓' : 'НЕТ ✗'}</div>
<div>Proof Hash совпал: {verificationResult.deterministicProofHashMatch ? 'ДА ✓' : 'НЕТ ✗'}</div> <div>Proof Hash совпал: {verificationResult.deterministicProofHashMatch ? 'ДА ✓' : 'НЕТ ✗'}</div>
<div>Event Hash совпал: {verificationResult.auditEventHashMatch ? 'ДА ✓' : 'НЕТ ✗'}</div>
</div> </div>
</div> </div>
)} )}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800">
<span className="text-slate-400">Draw ID:</span>
<p className="font-mono text-amber-300 break-all">{drawResult.drawId}</p>
</div>
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800"> <div className="p-3 bg-slate-950 rounded-xl border border-slate-800">
<span className="text-slate-400">Snapshot ID:</span> <span className="text-slate-400">Snapshot ID:</span>
<p className="font-mono text-slate-300 break-all">{drawResult.snapshotId}</p> <p className="font-mono text-slate-300 break-all">{drawResult.snapshotId}</p>
@ -269,6 +276,10 @@ export default function GiveawayDetailPage() {
<span className="text-slate-400">Snapshot Hash:</span> <span className="text-slate-400">Snapshot Hash:</span>
<p className="font-mono text-emerald-400 break-all">{drawResult.participantsSnapshotHash}</p> <p className="font-mono text-emerald-400 break-all">{drawResult.participantsSnapshotHash}</p>
</div> </div>
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800">
<span className="text-slate-400">Conditions Hash:</span>
<p className="font-mono text-purple-400 break-all">{drawResult.conditionsHash}</p>
</div>
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800 sm:col-span-2"> <div className="p-3 bg-slate-950 rounded-xl border border-slate-800 sm:col-span-2">
<span className="text-slate-400">deterministicProofHash (воспроизводимый):</span> <span className="text-slate-400">deterministicProofHash (воспроизводимый):</span>
<p className="font-mono text-indigo-300 break-all">{drawResult.deterministicProofHash}</p> <p className="font-mono text-indigo-300 break-all">{drawResult.deterministicProofHash}</p>

View file

@ -5,10 +5,16 @@ import {
DrawExecutionResult, DrawExecutionResult,
ALGORITHM_HMAC_SHA256_FY_V1, ALGORITHM_HMAC_SHA256_FY_V1,
ParticipantSnapshotData, ParticipantSnapshotData,
VerificationParams,
VerificationResult VerificationResult
} from '../types/audit'; } from '../types/audit';
import { DeterministicHmacStream } from './unbiased-sampler'; import { DeterministicHmacStream } from './unbiased-sampler';
import { computeDeterministicProofHash, computeAuditEventHash } from './canonical'; import {
computeDeterministicProofHash,
computeAuditEventHash,
computeParticipantsSnapshotHash,
computeConditionsHash
} from './canonical';
export const ALGORITHM_VERSION_V1 = ALGORITHM_HMAC_SHA256_FY_V1; // 'HMAC_SHA256_FY_V1' export const ALGORITHM_VERSION_V1 = ALGORITHM_HMAC_SHA256_FY_V1; // 'HMAC_SHA256_FY_V1'
@ -161,55 +167,102 @@ export function executeDeterministicDraw(params: DrawExecutionParams): DrawExecu
} }
/** /**
* Re-runs draw algorithm on a snapshot to verify identical outcome and hashes * Re-runs draw algorithm on a snapshot to verify identical outcome and all cryptographic integrity hashes
*/ */
export function verifyDrawResult( export function verifyDrawResult(params: VerificationParams): VerificationResult {
snapshot: ParticipantSnapshotData, const {
seed: string, giveawayId,
claimedWinnersCount: number, drawId,
claimedReserveCount: number, drawnAt,
claimedWinnerIds?: string[], snapshot,
claimedDeterministicProofHash?: string, seed,
algorithmVersion: string = ALGORITHM_VERSION_V1 claimedWinnersCount,
): VerificationResult { claimedReserveCount,
if (algorithmVersion !== ALGORITHM_VERSION_V1) { claimedWinnerIds = [],
throw new Error(`Unsupported algorithm version for replay: ${algorithmVersion}`); claimedReserveWinnerIds = [],
} claimedDeterministicProofHash = '',
claimedAuditEventHash = '',
algorithmVersion = ALGORITHM_VERSION_V1,
} = params;
const replayed = executeDeterministicDrawV1({ const algorithmSupported = (algorithmVersion === ALGORITHM_VERSION_V1);
giveawayId: snapshot.giveawayId,
// 1. Check Participant Snapshot Integrity: real recalculation from array
const computedSnapshotHash = computeParticipantsSnapshotHash(snapshot.eligibleParticipants || []);
const participantsSnapshotIntegrity = (computedSnapshotHash === snapshot.participantsSnapshotHash);
// 2. Check Conditions Integrity: real recalculation from filter rules snapshot
const computedConditionsHash = computeConditionsHash(snapshot.filterRulesSnapshot || {} as any);
const conditionsIntegrity = (computedConditionsHash === snapshot.conditionsHash);
// 3. Replay randomizer
let replayed: DrawExecutionResult | null = null;
let replayError = false;
try {
replayed = executeDeterministicDrawV1({
giveawayId,
snapshot, snapshot,
totalLoadedCount: snapshot.participantCount, totalLoadedCount: snapshot.participantCount,
winnersCount: claimedWinnersCount, winnersCount: claimedWinnersCount,
reserveWinnersCount: claimedReserveCount, reserveWinnersCount: claimedReserveCount,
seed, seed,
}); });
} catch {
replayError = true;
}
const winnersMatch = claimedWinnerIds const winnersMatch = !replayError && replayed !== null
? JSON.stringify(replayed.winnerIds) === JSON.stringify(claimedWinnerIds) ? JSON.stringify(replayed.winnerIds) === JSON.stringify(claimedWinnerIds)
: true; : false;
const deterministicProofHashMatch = claimedDeterministicProofHash const reserveWinnersMatch = !replayError && replayed !== null
? JSON.stringify(replayed.reserveWinnerIds) === JSON.stringify(claimedReserveWinnerIds)
: false;
const deterministicProofHashMatch = !replayError && replayed !== null
? replayed.deterministicProofHash === claimedDeterministicProofHash ? replayed.deterministicProofHash === claimedDeterministicProofHash
: true; : false;
const snapshotHashMatch = replayed.participantsSnapshotHash === snapshot.participantsSnapshotHash; // 4. Check Audit Event Hash: recomputed from giveawayId, drawId, drawnAt, and proof hash
const conditionsHashMatch = replayed.conditionsHash === snapshot.conditionsHash; const expectedAuditEventHash = (!replayError && replayed !== null)
? computeAuditEventHash({
giveawayId,
drawId,
drawnAt,
deterministicProofHash: replayed.deterministicProofHash,
})
: '';
const verified = winnersMatch && deterministicProofHashMatch && snapshotHashMatch && conditionsHashMatch; const auditEventHashMatch = (expectedAuditEventHash === claimedAuditEventHash);
const verified = (
algorithmSupported &&
participantsSnapshotIntegrity &&
conditionsIntegrity &&
winnersMatch &&
reserveWinnersMatch &&
deterministicProofHashMatch &&
auditEventHashMatch
);
return { return {
verified, verified,
algorithmVersion: ALGORITHM_VERSION_V1, algorithmVersion,
algorithmSupported,
participantsSnapshotIntegrity,
conditionsIntegrity,
winnersMatch, winnersMatch,
snapshotHashMatch, reserveWinnersMatch,
conditionsHashMatch,
deterministicProofHashMatch, deterministicProofHashMatch,
expectedWinners: replayed.winners, auditEventHashMatch,
expectedReserveWinners: replayed.reserveWinners, expectedWinners: replayed?.winners || [],
expectedWinnerIds: replayed.winnerIds, expectedReserveWinners: replayed?.reserveWinners || [],
expectedReserveWinnerIds: replayed.reserveWinnerIds, expectedWinnerIds: replayed?.winnerIds || [],
expectedDeterministicProofHash: replayed.deterministicProofHash, expectedReserveWinnerIds: replayed?.reserveWinnerIds || [],
actualDeterministicProofHash: claimedDeterministicProofHash || replayed.deterministicProofHash, expectedDeterministicProofHash: replayed?.deterministicProofHash || '',
expectedAuditEventHash,
actualDeterministicProofHash: claimedDeterministicProofHash,
actualAuditEventHash: claimedAuditEventHash,
}; };
} }

View file

@ -10,6 +10,7 @@ export interface ParticipantSnapshotData {
version: number; version: number;
createdAt: string; createdAt: string;
eligibleParticipants: FilteredParticipant[]; eligibleParticipants: FilteredParticipant[];
filterRulesSnapshot: FilterRules;
participantCount: number; participantCount: number;
participantsSnapshotHash: string; participantsSnapshotHash: string;
conditionsHash: string; conditionsHash: string;
@ -62,17 +63,37 @@ export interface AuditRecordData {
verifiedAt: string; verifiedAt: string;
} }
export interface VerificationParams {
giveawayId: string;
drawId: string;
drawnAt: string;
snapshot: ParticipantSnapshotData;
seed: string;
claimedWinnersCount: number;
claimedReserveCount: number;
claimedWinnerIds: string[];
claimedReserveWinnerIds: string[];
claimedDeterministicProofHash: string;
claimedAuditEventHash: string;
algorithmVersion?: string;
}
export interface VerificationResult { export interface VerificationResult {
verified: boolean; verified: boolean;
algorithmVersion: string; algorithmVersion: string;
algorithmSupported: boolean;
participantsSnapshotIntegrity: boolean;
conditionsIntegrity: boolean;
winnersMatch: boolean; winnersMatch: boolean;
snapshotHashMatch: boolean; reserveWinnersMatch: boolean;
conditionsHashMatch: boolean;
deterministicProofHashMatch: boolean; deterministicProofHashMatch: boolean;
auditEventHashMatch: boolean;
expectedWinners: Winner[]; expectedWinners: Winner[];
expectedReserveWinners: Winner[]; expectedReserveWinners: Winner[];
expectedWinnerIds: string[]; expectedWinnerIds: string[];
expectedReserveWinnerIds: string[]; expectedReserveWinnerIds: string[];
expectedDeterministicProofHash: string; expectedDeterministicProofHash: string;
expectedAuditEventHash: string;
actualDeterministicProofHash: string; actualDeterministicProofHash: string;
actualAuditEventHash: string;
} }

View file

@ -131,6 +131,7 @@ export class MemoryGiveawayRepository implements IGiveawayRepository {
version: newVersion, version: newVersion,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
eligibleParticipants: [...eligibleParticipants], eligibleParticipants: [...eligibleParticipants],
filterRulesSnapshot: { ...rules },
participantCount: eligibleParticipants.length, participantCount: eligibleParticipants.length,
participantsSnapshotHash, participantsSnapshotHash,
conditionsHash, conditionsHash,

View file

@ -34,6 +34,7 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
version: s.version, version: s.version,
createdAt: s.createdAt.toISOString(), createdAt: s.createdAt.toISOString(),
eligibleParticipants: s.eligibleParticipants as FilteredParticipant[], eligibleParticipants: s.eligibleParticipants as FilteredParticipant[],
filterRulesSnapshot: s.filterRulesSnapshot as FilterRules,
participantCount: s.participantCount, participantCount: s.participantCount,
participantsSnapshotHash: s.participantsSnapshotHash, participantsSnapshotHash: s.participantsSnapshotHash,
conditionsHash: s.conditionsHash, conditionsHash: s.conditionsHash,
@ -54,7 +55,7 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
: snapshots.find(s => s.id === raw.drawResult.snapshotId) || latestSnapshot; : snapshots.find(s => s.id === raw.drawResult.snapshotId) || latestSnapshot;
drawResult = { drawResult = {
drawId: raw.drawResult.id, drawId: raw.drawResult.drawId || raw.drawResult.id,
giveawayId: raw.drawResult.giveawayId, giveawayId: raw.drawResult.giveawayId,
snapshotId: raw.drawResult.snapshotId, snapshotId: raw.drawResult.snapshotId,
winners: raw.drawResult.winners as any, winners: raw.drawResult.winners as any,
@ -256,6 +257,7 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
giveawayId: id, giveawayId: id,
version: newVersion, version: newVersion,
eligibleParticipants: eligibleParticipants as any, eligibleParticipants: eligibleParticipants as any,
filterRulesSnapshot: rules as any,
participantCount: eligibleParticipants.length, participantCount: eligibleParticipants.length,
participantsSnapshotHash, participantsSnapshotHash,
conditionsHash, conditionsHash,
@ -276,6 +278,7 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
version: snapshot.version, version: snapshot.version,
createdAt: snapshot.createdAt.toISOString(), createdAt: snapshot.createdAt.toISOString(),
eligibleParticipants: eligibleParticipants, eligibleParticipants: eligibleParticipants,
filterRulesSnapshot: rules,
participantCount: snapshot.participantCount, participantCount: snapshot.participantCount,
participantsSnapshotHash: snapshot.participantsSnapshotHash, participantsSnapshotHash: snapshot.participantsSnapshotHash,
conditionsHash: snapshot.conditionsHash, conditionsHash: snapshot.conditionsHash,
@ -296,6 +299,7 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
version: snap.version, version: snap.version,
createdAt: snap.createdAt.toISOString(), createdAt: snap.createdAt.toISOString(),
eligibleParticipants: snap.eligibleParticipants as any, eligibleParticipants: snap.eligibleParticipants as any,
filterRulesSnapshot: snap.filterRulesSnapshot as any,
participantCount: snap.participantCount, participantCount: snap.participantCount,
participantsSnapshotHash: snap.participantsSnapshotHash, participantsSnapshotHash: snap.participantsSnapshotHash,
conditionsHash: snap.conditionsHash, conditionsHash: snap.conditionsHash,
@ -313,9 +317,10 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
GiveawayFSM.assertCanDraw(current.status); GiveawayFSM.assertCanDraw(current.status);
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
// 1. Create DrawResult // 1. Create DrawResult with original drawId
await tx.drawResult.create({ await tx.drawResult.create({
data: { data: {
drawId: result.drawId,
giveawayId: id, giveawayId: id,
snapshotId: snapshotId, snapshotId: snapshotId,
winners: result.winners as any, winners: result.winners as any,

View file

@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { executeDeterministicDrawV1, verifyDrawResult } from '../src/core/randomizer/deterministic'; import { executeDeterministicDrawV1, verifyDrawResult } from '../src/core/randomizer/deterministic';
import { computeParticipantsSnapshotHash, computeConditionsHash } from '../src/core/randomizer/canonical'; import { computeParticipantsSnapshotHash, computeConditionsHash } from '../src/core/randomizer/canonical';
import { FilterRules, DEFAULT_FILTER_RULES } from '../src/core/types/giveaway'; import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway';
import { FilteredParticipant } from '../src/core/types/participant'; import { FilteredParticipant } from '../src/core/types/participant';
import { ParticipantSnapshotData } from '../src/core/types/audit'; import { ParticipantSnapshotData } from '../src/core/types/audit';
@ -54,6 +54,7 @@ describe('DeterministicProofHash & AuditEventHash Separation', () => {
version: 1, version: 1,
createdAt: '2026-08-17T12:00:00.000Z', createdAt: '2026-08-17T12:00:00.000Z',
eligibleParticipants: participants, eligibleParticipants: participants,
filterRulesSnapshot: { ...DEFAULT_FILTER_RULES },
participantCount: 3, participantCount: 3,
participantsSnapshotHash: computeParticipantsSnapshotHash(participants), participantsSnapshotHash: computeParticipantsSnapshotHash(participants),
conditionsHash: computeConditionsHash(DEFAULT_FILTER_RULES), conditionsHash: computeConditionsHash(DEFAULT_FILTER_RULES),
@ -120,20 +121,28 @@ describe('DeterministicProofHash & AuditEventHash Separation', () => {
seed, seed,
}); });
const verification = verifyDrawResult( const verification = verifyDrawResult({
giveawayId: 'gw-1',
drawId: originalDraw.drawId,
drawnAt: originalDraw.drawnAt,
snapshot, snapshot,
seed, seed,
1, claimedWinnersCount: 1,
1, claimedReserveCount: 1,
originalDraw.winnerIds, claimedWinnerIds: originalDraw.winnerIds,
originalDraw.deterministicProofHash claimedReserveWinnerIds: originalDraw.reserveWinnerIds,
); claimedDeterministicProofHash: originalDraw.deterministicProofHash,
claimedAuditEventHash: originalDraw.auditEventHash,
algorithmVersion: originalDraw.algorithmVersion,
});
expect(verification.verified).toBe(true); expect(verification.verified).toBe(true);
expect(verification.winnersMatch).toBe(true); expect(verification.winnersMatch).toBe(true);
expect(verification.reserveWinnersMatch).toBe(true);
expect(verification.deterministicProofHashMatch).toBe(true); expect(verification.deterministicProofHashMatch).toBe(true);
expect(verification.snapshotHashMatch).toBe(true); expect(verification.auditEventHashMatch).toBe(true);
expect(verification.conditionsHashMatch).toBe(true); expect(verification.participantsSnapshotIntegrity).toBe(true);
expect(verification.conditionsIntegrity).toBe(true);
expect(verification.expectedDeterministicProofHash).toBe(originalDraw.deterministicProofHash); expect(verification.expectedDeterministicProofHash).toBe(originalDraw.deterministicProofHash);
}); });
}); });

View file

@ -29,6 +29,7 @@ function createMockSnapshot(count: number): ParticipantSnapshotData {
version: 1, version: 1,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
eligibleParticipants: eligible, eligibleParticipants: eligible,
filterRulesSnapshot: { ...DEFAULT_FILTER_RULES },
participantCount: count, participantCount: count,
participantsSnapshotHash: computeParticipantsSnapshotHash(eligible), participantsSnapshotHash: computeParticipantsSnapshotHash(eligible),
conditionsHash: computeConditionsHash(DEFAULT_FILTER_RULES), conditionsHash: computeConditionsHash(DEFAULT_FILTER_RULES),
@ -162,15 +163,20 @@ describe('Deterministic Randomizer V1 (HMAC_SHA256_FY_V1)', () => {
filterRules: DEFAULT_FILTER_RULES, filterRules: DEFAULT_FILTER_RULES,
}); });
const verification = verifyDrawResult( const verification = verifyDrawResult({
giveawayId: 'gw-audit',
drawId: originalDraw.drawId,
drawnAt: originalDraw.drawnAt,
snapshot, snapshot,
seed, seed,
2, claimedWinnersCount: 2,
2, claimedReserveCount: 2,
originalDraw.winnerIds, claimedWinnerIds: originalDraw.winnerIds,
originalDraw.deterministicProofHash, claimedReserveWinnerIds: originalDraw.reserveWinnerIds,
ALGORITHM_VERSION_V1 claimedDeterministicProofHash: originalDraw.deterministicProofHash,
); claimedAuditEventHash: originalDraw.auditEventHash,
algorithmVersion: ALGORITHM_VERSION_V1,
});
expect(verification.verified).toBe(true); expect(verification.verified).toBe(true);
expect(verification.expectedWinnerIds).toEqual(originalDraw.winnerIds); expect(verification.expectedWinnerIds).toEqual(originalDraw.winnerIds);

View file

@ -0,0 +1,278 @@
import { describe, it, expect } from 'vitest';
import { executeDeterministicDrawV1, verifyDrawResult } from '../src/core/randomizer/deterministic';
import { computeParticipantsSnapshotHash, computeConditionsHash } from '../src/core/randomizer/canonical';
import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway';
import { FilteredParticipant } from '../src/core/types/participant';
import { ParticipantSnapshotData } from '../src/core/types/audit';
describe('Public Verification Integrity & Anti-Tampering Test Suite', () => {
const originalParticipants: FilteredParticipant[] = [
{
platformUserId: '101',
firstName: 'Иван',
lastName: 'Петров',
source: 'LIKES',
liked: true,
commented: false,
commentsCount: 0,
reposted: false,
subscribed: true,
eligible: true,
exclusionReason: null,
},
{
platformUserId: '102',
firstName: 'Анна',
lastName: 'Сидорова',
source: 'LIKES',
liked: true,
commented: false,
commentsCount: 0,
reposted: false,
subscribed: true,
eligible: true,
exclusionReason: null,
},
{
platformUserId: '103',
firstName: 'Сергей',
lastName: 'Смирнов',
source: 'LIKES',
liked: true,
commented: false,
commentsCount: 0,
reposted: false,
subscribed: true,
eligible: true,
exclusionReason: null,
},
];
const validSnapshot: ParticipantSnapshotData = {
id: 'snap-tamper-baseline',
giveawayId: 'gw-tamper-1',
version: 1,
createdAt: '2026-08-18T00:00:00.000Z',
eligibleParticipants: JSON.parse(JSON.stringify(originalParticipants)),
filterRulesSnapshot: { ...DEFAULT_FILTER_RULES },
participantCount: 3,
participantsSnapshotHash: computeParticipantsSnapshotHash(originalParticipants),
conditionsHash: computeConditionsHash(DEFAULT_FILTER_RULES),
};
const seed = 'anti-tampering-master-seed-2026';
const baselineDraw = executeDeterministicDrawV1({
giveawayId: 'gw-tamper-1',
snapshot: validSnapshot,
totalLoadedCount: 3,
winnersCount: 1,
reserveWinnersCount: 1,
seed,
});
it('1. Baseline check: authentic draw result must pass 100% verification', () => {
const result = verifyDrawResult({
giveawayId: 'gw-tamper-1',
drawId: baselineDraw.drawId,
drawnAt: baselineDraw.drawnAt,
snapshot: validSnapshot,
seed,
claimedWinnersCount: 1,
claimedReserveCount: 1,
claimedWinnerIds: baselineDraw.winnerIds,
claimedReserveWinnerIds: baselineDraw.reserveWinnerIds,
claimedDeterministicProofHash: baselineDraw.deterministicProofHash,
claimedAuditEventHash: baselineDraw.auditEventHash,
algorithmVersion: baselineDraw.algorithmVersion,
});
expect(result.verified).toBe(true);
expect(result.participantsSnapshotIntegrity).toBe(true);
expect(result.conditionsIntegrity).toBe(true);
expect(result.winnersMatch).toBe(true);
expect(result.reserveWinnersMatch).toBe(true);
expect(result.deterministicProofHashMatch).toBe(true);
expect(result.auditEventHashMatch).toBe(true);
});
it('2. Tampering test: modifying a participant name/ID in snapshot must fail participantsSnapshotIntegrity', () => {
const tamperedSnapshot: ParticipantSnapshotData = {
...validSnapshot,
eligibleParticipants: [
{
...originalParticipants[0],
firstName: 'Хакер', // Tampered name!
},
originalParticipants[1],
originalParticipants[2],
],
};
const result = verifyDrawResult({
giveawayId: 'gw-tamper-1',
drawId: baselineDraw.drawId,
drawnAt: baselineDraw.drawnAt,
snapshot: tamperedSnapshot,
seed,
claimedWinnersCount: 1,
claimedReserveCount: 1,
claimedWinnerIds: baselineDraw.winnerIds,
claimedReserveWinnerIds: baselineDraw.reserveWinnerIds,
claimedDeterministicProofHash: baselineDraw.deterministicProofHash,
claimedAuditEventHash: baselineDraw.auditEventHash,
algorithmVersion: baselineDraw.algorithmVersion,
});
expect(result.verified).toBe(false);
expect(result.participantsSnapshotIntegrity).toBe(false);
});
it('3. Tampering test: modifying a filter rule in snapshot must fail conditionsIntegrity', () => {
const tamperedSnapshot: ParticipantSnapshotData = {
...validSnapshot,
filterRulesSnapshot: {
...DEFAULT_FILTER_RULES,
requireComment: true, // Tampered rule!
},
};
const result = verifyDrawResult({
giveawayId: 'gw-tamper-1',
drawId: baselineDraw.drawId,
drawnAt: baselineDraw.drawnAt,
snapshot: tamperedSnapshot,
seed,
claimedWinnersCount: 1,
claimedReserveCount: 1,
claimedWinnerIds: baselineDraw.winnerIds,
claimedReserveWinnerIds: baselineDraw.reserveWinnerIds,
claimedDeterministicProofHash: baselineDraw.deterministicProofHash,
claimedAuditEventHash: baselineDraw.auditEventHash,
algorithmVersion: baselineDraw.algorithmVersion,
});
expect(result.verified).toBe(false);
expect(result.conditionsIntegrity).toBe(false);
});
it('4. Tampering test: modifying winnerIds must fail winnersMatch', () => {
const result = verifyDrawResult({
giveawayId: 'gw-tamper-1',
drawId: baselineDraw.drawId,
drawnAt: baselineDraw.drawnAt,
snapshot: validSnapshot,
seed,
claimedWinnersCount: 1,
claimedReserveCount: 1,
claimedWinnerIds: ['fake-winner-id-999'], // Tampered winner!
claimedReserveWinnerIds: baselineDraw.reserveWinnerIds,
claimedDeterministicProofHash: baselineDraw.deterministicProofHash,
claimedAuditEventHash: baselineDraw.auditEventHash,
algorithmVersion: baselineDraw.algorithmVersion,
});
expect(result.verified).toBe(false);
expect(result.winnersMatch).toBe(false);
});
it('5. Tampering test: modifying reserveWinnerIds must fail reserveWinnersMatch', () => {
const result = verifyDrawResult({
giveawayId: 'gw-tamper-1',
drawId: baselineDraw.drawId,
drawnAt: baselineDraw.drawnAt,
snapshot: validSnapshot,
seed,
claimedWinnersCount: 1,
claimedReserveCount: 1,
claimedWinnerIds: baselineDraw.winnerIds,
claimedReserveWinnerIds: ['fake-reserve-id-777'], // Tampered reserve winner!
claimedDeterministicProofHash: baselineDraw.deterministicProofHash,
claimedAuditEventHash: baselineDraw.auditEventHash,
algorithmVersion: baselineDraw.algorithmVersion,
});
expect(result.verified).toBe(false);
expect(result.reserveWinnersMatch).toBe(false);
});
it('6. Tampering test: modifying seed must fail replay and deterministicProofHashMatch', () => {
const result = verifyDrawResult({
giveawayId: 'gw-tamper-1',
drawId: baselineDraw.drawId,
drawnAt: baselineDraw.drawnAt,
snapshot: validSnapshot,
seed: 'tampered-seed-999',
claimedWinnersCount: 1,
claimedReserveCount: 1,
claimedWinnerIds: baselineDraw.winnerIds,
claimedReserveWinnerIds: baselineDraw.reserveWinnerIds,
claimedDeterministicProofHash: baselineDraw.deterministicProofHash,
claimedAuditEventHash: baselineDraw.auditEventHash,
algorithmVersion: baselineDraw.algorithmVersion,
});
expect(result.verified).toBe(false);
expect(result.deterministicProofHashMatch).toBe(false);
});
it('7. Tampering test: modifying drawId must fail auditEventHashMatch', () => {
const result = verifyDrawResult({
giveawayId: 'gw-tamper-1',
drawId: 'tampered-draw-id-xyz', // Tampered drawId!
drawnAt: baselineDraw.drawnAt,
snapshot: validSnapshot,
seed,
claimedWinnersCount: 1,
claimedReserveCount: 1,
claimedWinnerIds: baselineDraw.winnerIds,
claimedReserveWinnerIds: baselineDraw.reserveWinnerIds,
claimedDeterministicProofHash: baselineDraw.deterministicProofHash,
claimedAuditEventHash: baselineDraw.auditEventHash,
algorithmVersion: baselineDraw.algorithmVersion,
});
expect(result.verified).toBe(false);
expect(result.auditEventHashMatch).toBe(false);
});
it('8. Tampering test: modifying drawnAt timestamp must fail auditEventHashMatch', () => {
const result = verifyDrawResult({
giveawayId: 'gw-tamper-1',
drawId: baselineDraw.drawId,
drawnAt: '2026-08-19T00:00:00.000Z', // Tampered timestamp!
snapshot: validSnapshot,
seed,
claimedWinnersCount: 1,
claimedReserveCount: 1,
claimedWinnerIds: baselineDraw.winnerIds,
claimedReserveWinnerIds: baselineDraw.reserveWinnerIds,
claimedDeterministicProofHash: baselineDraw.deterministicProofHash,
claimedAuditEventHash: baselineDraw.auditEventHash,
algorithmVersion: baselineDraw.algorithmVersion,
});
expect(result.verified).toBe(false);
expect(result.auditEventHashMatch).toBe(false);
});
it('9. Tampering test: modifying deterministicProofHash directly must fail deterministicProofHashMatch', () => {
const result = verifyDrawResult({
giveawayId: 'gw-tamper-1',
drawId: baselineDraw.drawId,
drawnAt: baselineDraw.drawnAt,
snapshot: validSnapshot,
seed,
claimedWinnersCount: 1,
claimedReserveCount: 1,
claimedWinnerIds: baselineDraw.winnerIds,
claimedReserveWinnerIds: baselineDraw.reserveWinnerIds,
claimedDeterministicProofHash: '1111111111111111111111111111111111111111111111111111111111111111',
claimedAuditEventHash: baselineDraw.auditEventHash,
algorithmVersion: baselineDraw.algorithmVersion,
});
expect(result.verified).toBe(false);
expect(result.deterministicProofHashMatch).toBe(false);
});
});

View file

@ -54,6 +54,7 @@ describe('Verification API Replay Engine', () => {
version: 1, version: 1,
createdAt: '2026-08-17T12:00:00.000Z', createdAt: '2026-08-17T12:00:00.000Z',
eligibleParticipants: participants, eligibleParticipants: participants,
filterRulesSnapshot: { ...DEFAULT_FILTER_RULES },
participantCount: 3, participantCount: 3,
participantsSnapshotHash: computeParticipantsSnapshotHash(participants), participantsSnapshotHash: computeParticipantsSnapshotHash(participants),
conditionsHash: computeConditionsHash(DEFAULT_FILTER_RULES), conditionsHash: computeConditionsHash(DEFAULT_FILTER_RULES),
@ -71,21 +72,28 @@ describe('Verification API Replay Engine', () => {
seed, seed,
}); });
const result = verifyDrawResult( const result = verifyDrawResult({
giveawayId: 'gw-verif-1',
drawId: draw.drawId,
drawnAt: draw.drawnAt,
snapshot, snapshot,
seed, seed,
1, claimedWinnersCount: 1,
1, claimedReserveCount: 1,
draw.winnerIds, claimedWinnerIds: draw.winnerIds,
draw.deterministicProofHash, claimedReserveWinnerIds: draw.reserveWinnerIds,
draw.algorithmVersion claimedDeterministicProofHash: draw.deterministicProofHash,
); claimedAuditEventHash: draw.auditEventHash,
algorithmVersion: draw.algorithmVersion,
});
expect(result.verified).toBe(true); expect(result.verified).toBe(true);
expect(result.winnersMatch).toBe(true); expect(result.winnersMatch).toBe(true);
expect(result.reserveWinnersMatch).toBe(true);
expect(result.deterministicProofHashMatch).toBe(true); expect(result.deterministicProofHashMatch).toBe(true);
expect(result.snapshotHashMatch).toBe(true); expect(result.auditEventHashMatch).toBe(true);
expect(result.conditionsHashMatch).toBe(true); expect(result.participantsSnapshotIntegrity).toBe(true);
expect(result.conditionsIntegrity).toBe(true);
expect(result.expectedWinnerIds).toEqual(draw.winnerIds); expect(result.expectedWinnerIds).toEqual(draw.winnerIds);
}); });
@ -101,15 +109,20 @@ describe('Verification API Replay Engine', () => {
const fakeWinnerIds = ['9999']; // Tampered winners const fakeWinnerIds = ['9999']; // Tampered winners
const result = verifyDrawResult( const result = verifyDrawResult({
giveawayId: 'gw-verif-1',
drawId: draw.drawId,
drawnAt: draw.drawnAt,
snapshot, snapshot,
seed, seed,
1, claimedWinnersCount: 1,
1, claimedReserveCount: 1,
fakeWinnerIds, claimedWinnerIds: fakeWinnerIds,
draw.deterministicProofHash, claimedReserveWinnerIds: draw.reserveWinnerIds,
draw.algorithmVersion claimedDeterministicProofHash: draw.deterministicProofHash,
); claimedAuditEventHash: draw.auditEventHash,
algorithmVersion: draw.algorithmVersion,
});
expect(result.verified).toBe(false); expect(result.verified).toBe(false);
expect(result.winnersMatch).toBe(false); expect(result.winnersMatch).toBe(false);
@ -127,15 +140,20 @@ describe('Verification API Replay Engine', () => {
const fakeProofHash = '0000000000000000000000000000000000000000000000000000000000000000'; const fakeProofHash = '0000000000000000000000000000000000000000000000000000000000000000';
const result = verifyDrawResult( const result = verifyDrawResult({
giveawayId: 'gw-verif-1',
drawId: draw.drawId,
drawnAt: draw.drawnAt,
snapshot, snapshot,
seed, seed,
1, claimedWinnersCount: 1,
1, claimedReserveCount: 1,
draw.winnerIds, claimedWinnerIds: draw.winnerIds,
fakeProofHash, claimedReserveWinnerIds: draw.reserveWinnerIds,
draw.algorithmVersion claimedDeterministicProofHash: fakeProofHash,
); claimedAuditEventHash: draw.auditEventHash,
algorithmVersion: draw.algorithmVersion,
});
expect(result.verified).toBe(false); expect(result.verified).toBe(false);
expect(result.deterministicProofHashMatch).toBe(false); expect(result.deterministicProofHashMatch).toBe(false);