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:
parent
1bc6650041
commit
26e82fcf8d
11 changed files with 511 additions and 97 deletions
|
|
@ -89,6 +89,7 @@ model ParticipantSnapshot {
|
|||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
eligibleParticipants Json // Canonical JSON array of FilteredParticipant
|
||||
filterRulesSnapshot Json // Canonical JSON snapshot of FilterRules
|
||||
participantCount Int
|
||||
participantsSnapshotHash String
|
||||
conditionsHash String
|
||||
|
|
@ -102,6 +103,7 @@ model ParticipantSnapshot {
|
|||
|
||||
model DrawResult {
|
||||
id String @id @default(cuid())
|
||||
drawId String // Original domain drawId (e.g. draw_8f9102ab...)
|
||||
giveawayId String @unique
|
||||
giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade)
|
||||
snapshotId String @unique
|
||||
|
|
|
|||
|
|
@ -21,13 +21,14 @@ export async function GET(
|
|||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Find the snapshot associated with this draw
|
||||
const snapshot = giveaway.snapshots.find(s => s.id === drawResult.snapshotId)
|
||||
|| giveaway.latestSnapshot;
|
||||
// Strict snapshot lookup: DO NOT fallback to latestSnapshot
|
||||
const snapshot = giveaway.snapshots.find(s => s.id === drawResult.snapshotId);
|
||||
|
||||
if (!snapshot) {
|
||||
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 });
|
||||
}
|
||||
|
||||
|
|
@ -35,29 +36,38 @@ export async function GET(
|
|||
const claimedReserveCount = drawResult.reserveWinners.length;
|
||||
|
||||
// Run independent cryptographic replay verification
|
||||
const verification = verifyDrawResult(
|
||||
const verification = verifyDrawResult({
|
||||
giveawayId: id,
|
||||
drawId: drawResult.drawId,
|
||||
drawnAt: drawResult.drawnAt,
|
||||
snapshot,
|
||||
drawResult.seedUsed,
|
||||
seed: drawResult.seedUsed,
|
||||
claimedWinnersCount,
|
||||
claimedReserveCount,
|
||||
drawResult.winnerIds,
|
||||
drawResult.deterministicProofHash,
|
||||
drawResult.algorithmVersion
|
||||
);
|
||||
claimedWinnerIds: drawResult.winnerIds,
|
||||
claimedReserveWinnerIds: drawResult.reserveWinnerIds,
|
||||
claimedDeterministicProofHash: drawResult.deterministicProofHash,
|
||||
claimedAuditEventHash: drawResult.auditEventHash,
|
||||
algorithmVersion: drawResult.algorithmVersion,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
verified: verification.verified,
|
||||
giveawayId: id,
|
||||
drawId: drawResult.drawId,
|
||||
snapshotId: snapshot.id,
|
||||
algorithmVersion: verification.algorithmVersion,
|
||||
algorithmSupported: verification.algorithmSupported,
|
||||
participantsSnapshotIntegrity: verification.participantsSnapshotIntegrity,
|
||||
conditionsIntegrity: verification.conditionsIntegrity,
|
||||
winnersMatch: verification.winnersMatch,
|
||||
snapshotHashMatch: verification.snapshotHashMatch,
|
||||
conditionsHashMatch: verification.conditionsHashMatch,
|
||||
reserveWinnersMatch: verification.reserveWinnersMatch,
|
||||
deterministicProofHashMatch: verification.deterministicProofHashMatch,
|
||||
auditEventHashMatch: verification.auditEventHashMatch,
|
||||
expectedWinnerIds: verification.expectedWinnerIds,
|
||||
expectedReserveWinnerIds: verification.expectedReserveWinnerIds,
|
||||
deterministicProofHash: verification.expectedDeterministicProofHash,
|
||||
auditEventHash: drawResult.auditEventHash,
|
||||
auditEventHash: verification.expectedAuditEventHash,
|
||||
drawnAt: drawResult.drawnAt,
|
||||
});
|
||||
} catch (error: any) {
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@ export default function GiveawayDetailPage() {
|
|||
onClick={() => {
|
||||
navigator.clipboard.writeText(JSON.stringify({
|
||||
giveawayId: giveaway.id,
|
||||
drawId: drawResult.drawId,
|
||||
snapshotId: drawResult.snapshotId,
|
||||
algorithmVersion: drawResult.algorithmVersion,
|
||||
seed: drawResult.seedUsed,
|
||||
|
|
@ -243,16 +244,22 @@ export default function GiveawayDetailPage() {
|
|||
</>
|
||||
)}
|
||||
</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.snapshotHashMatch ? 'ДА ✓' : 'НЕТ ✗'}</div>
|
||||
<div>Хеш условий совпал: {verificationResult.conditionsHashMatch ? 'ДА ✓' : 'НЕТ ✗'}</div>
|
||||
<div>Резерв совпал: {verificationResult.reserveWinnersMatch ? 'ДА ✓' : 'НЕТ ✗'}</div>
|
||||
<div>Proof Hash совпал: {verificationResult.deterministicProofHashMatch ? 'ДА ✓' : 'НЕТ ✗'}</div>
|
||||
<div>Event Hash совпал: {verificationResult.auditEventHashMatch ? 'ДА ✓' : 'НЕТ ✗'}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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">
|
||||
<span className="text-slate-400">Snapshot ID:</span>
|
||||
<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>
|
||||
<p className="font-mono text-emerald-400 break-all">{drawResult.participantsSnapshotHash}</p>
|
||||
</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">
|
||||
<span className="text-slate-400">deterministicProofHash (воспроизводимый):</span>
|
||||
<p className="font-mono text-indigo-300 break-all">{drawResult.deterministicProofHash}</p>
|
||||
|
|
|
|||
|
|
@ -5,10 +5,16 @@ import {
|
|||
DrawExecutionResult,
|
||||
ALGORITHM_HMAC_SHA256_FY_V1,
|
||||
ParticipantSnapshotData,
|
||||
VerificationParams,
|
||||
VerificationResult
|
||||
} from '../types/audit';
|
||||
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'
|
||||
|
||||
|
|
@ -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(
|
||||
snapshot: ParticipantSnapshotData,
|
||||
seed: string,
|
||||
claimedWinnersCount: number,
|
||||
claimedReserveCount: number,
|
||||
claimedWinnerIds?: string[],
|
||||
claimedDeterministicProofHash?: string,
|
||||
algorithmVersion: string = ALGORITHM_VERSION_V1
|
||||
): VerificationResult {
|
||||
if (algorithmVersion !== ALGORITHM_VERSION_V1) {
|
||||
throw new Error(`Unsupported algorithm version for replay: ${algorithmVersion}`);
|
||||
export function verifyDrawResult(params: VerificationParams): VerificationResult {
|
||||
const {
|
||||
giveawayId,
|
||||
drawId,
|
||||
drawnAt,
|
||||
snapshot,
|
||||
seed,
|
||||
claimedWinnersCount,
|
||||
claimedReserveCount,
|
||||
claimedWinnerIds = [],
|
||||
claimedReserveWinnerIds = [],
|
||||
claimedDeterministicProofHash = '',
|
||||
claimedAuditEventHash = '',
|
||||
algorithmVersion = ALGORITHM_VERSION_V1,
|
||||
} = params;
|
||||
|
||||
const algorithmSupported = (algorithmVersion === ALGORITHM_VERSION_V1);
|
||||
|
||||
// 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,
|
||||
totalLoadedCount: snapshot.participantCount,
|
||||
winnersCount: claimedWinnersCount,
|
||||
reserveWinnersCount: claimedReserveCount,
|
||||
seed,
|
||||
});
|
||||
} catch {
|
||||
replayError = true;
|
||||
}
|
||||
|
||||
const replayed = executeDeterministicDrawV1({
|
||||
giveawayId: snapshot.giveawayId,
|
||||
snapshot,
|
||||
totalLoadedCount: snapshot.participantCount,
|
||||
winnersCount: claimedWinnersCount,
|
||||
reserveWinnersCount: claimedReserveCount,
|
||||
seed,
|
||||
});
|
||||
|
||||
const winnersMatch = claimedWinnerIds
|
||||
const winnersMatch = !replayError && replayed !== null
|
||||
? 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
|
||||
: true;
|
||||
: false;
|
||||
|
||||
const snapshotHashMatch = replayed.participantsSnapshotHash === snapshot.participantsSnapshotHash;
|
||||
const conditionsHashMatch = replayed.conditionsHash === snapshot.conditionsHash;
|
||||
// 4. Check Audit Event Hash: recomputed from giveawayId, drawId, drawnAt, and proof hash
|
||||
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 {
|
||||
verified,
|
||||
algorithmVersion: ALGORITHM_VERSION_V1,
|
||||
algorithmVersion,
|
||||
algorithmSupported,
|
||||
participantsSnapshotIntegrity,
|
||||
conditionsIntegrity,
|
||||
winnersMatch,
|
||||
snapshotHashMatch,
|
||||
conditionsHashMatch,
|
||||
reserveWinnersMatch,
|
||||
deterministicProofHashMatch,
|
||||
expectedWinners: replayed.winners,
|
||||
expectedReserveWinners: replayed.reserveWinners,
|
||||
expectedWinnerIds: replayed.winnerIds,
|
||||
expectedReserveWinnerIds: replayed.reserveWinnerIds,
|
||||
expectedDeterministicProofHash: replayed.deterministicProofHash,
|
||||
actualDeterministicProofHash: claimedDeterministicProofHash || replayed.deterministicProofHash,
|
||||
auditEventHashMatch,
|
||||
expectedWinners: replayed?.winners || [],
|
||||
expectedReserveWinners: replayed?.reserveWinners || [],
|
||||
expectedWinnerIds: replayed?.winnerIds || [],
|
||||
expectedReserveWinnerIds: replayed?.reserveWinnerIds || [],
|
||||
expectedDeterministicProofHash: replayed?.deterministicProofHash || '',
|
||||
expectedAuditEventHash,
|
||||
actualDeterministicProofHash: claimedDeterministicProofHash,
|
||||
actualAuditEventHash: claimedAuditEventHash,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export interface ParticipantSnapshotData {
|
|||
version: number;
|
||||
createdAt: string;
|
||||
eligibleParticipants: FilteredParticipant[];
|
||||
filterRulesSnapshot: FilterRules;
|
||||
participantCount: number;
|
||||
participantsSnapshotHash: string;
|
||||
conditionsHash: string;
|
||||
|
|
@ -62,17 +63,37 @@ export interface AuditRecordData {
|
|||
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 {
|
||||
verified: boolean;
|
||||
algorithmVersion: string;
|
||||
algorithmSupported: boolean;
|
||||
participantsSnapshotIntegrity: boolean;
|
||||
conditionsIntegrity: boolean;
|
||||
winnersMatch: boolean;
|
||||
snapshotHashMatch: boolean;
|
||||
conditionsHashMatch: boolean;
|
||||
reserveWinnersMatch: boolean;
|
||||
deterministicProofHashMatch: boolean;
|
||||
auditEventHashMatch: boolean;
|
||||
expectedWinners: Winner[];
|
||||
expectedReserveWinners: Winner[];
|
||||
expectedWinnerIds: string[];
|
||||
expectedReserveWinnerIds: string[];
|
||||
expectedDeterministicProofHash: string;
|
||||
expectedAuditEventHash: string;
|
||||
actualDeterministicProofHash: string;
|
||||
actualAuditEventHash: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ export class MemoryGiveawayRepository implements IGiveawayRepository {
|
|||
version: newVersion,
|
||||
createdAt: new Date().toISOString(),
|
||||
eligibleParticipants: [...eligibleParticipants],
|
||||
filterRulesSnapshot: { ...rules },
|
||||
participantCount: eligibleParticipants.length,
|
||||
participantsSnapshotHash,
|
||||
conditionsHash,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
|
|||
version: s.version,
|
||||
createdAt: s.createdAt.toISOString(),
|
||||
eligibleParticipants: s.eligibleParticipants as FilteredParticipant[],
|
||||
filterRulesSnapshot: s.filterRulesSnapshot as FilterRules,
|
||||
participantCount: s.participantCount,
|
||||
participantsSnapshotHash: s.participantsSnapshotHash,
|
||||
conditionsHash: s.conditionsHash,
|
||||
|
|
@ -54,7 +55,7 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
|
|||
: snapshots.find(s => s.id === raw.drawResult.snapshotId) || latestSnapshot;
|
||||
|
||||
drawResult = {
|
||||
drawId: raw.drawResult.id,
|
||||
drawId: raw.drawResult.drawId || raw.drawResult.id,
|
||||
giveawayId: raw.drawResult.giveawayId,
|
||||
snapshotId: raw.drawResult.snapshotId,
|
||||
winners: raw.drawResult.winners as any,
|
||||
|
|
@ -256,6 +257,7 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
|
|||
giveawayId: id,
|
||||
version: newVersion,
|
||||
eligibleParticipants: eligibleParticipants as any,
|
||||
filterRulesSnapshot: rules as any,
|
||||
participantCount: eligibleParticipants.length,
|
||||
participantsSnapshotHash,
|
||||
conditionsHash,
|
||||
|
|
@ -276,6 +278,7 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
|
|||
version: snapshot.version,
|
||||
createdAt: snapshot.createdAt.toISOString(),
|
||||
eligibleParticipants: eligibleParticipants,
|
||||
filterRulesSnapshot: rules,
|
||||
participantCount: snapshot.participantCount,
|
||||
participantsSnapshotHash: snapshot.participantsSnapshotHash,
|
||||
conditionsHash: snapshot.conditionsHash,
|
||||
|
|
@ -296,6 +299,7 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
|
|||
version: snap.version,
|
||||
createdAt: snap.createdAt.toISOString(),
|
||||
eligibleParticipants: snap.eligibleParticipants as any,
|
||||
filterRulesSnapshot: snap.filterRulesSnapshot as any,
|
||||
participantCount: snap.participantCount,
|
||||
participantsSnapshotHash: snap.participantsSnapshotHash,
|
||||
conditionsHash: snap.conditionsHash,
|
||||
|
|
@ -313,9 +317,10 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
|
|||
GiveawayFSM.assertCanDraw(current.status);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// 1. Create DrawResult
|
||||
// 1. Create DrawResult with original drawId
|
||||
await tx.drawResult.create({
|
||||
data: {
|
||||
drawId: result.drawId,
|
||||
giveawayId: id,
|
||||
snapshotId: snapshotId,
|
||||
winners: result.winners as any,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { executeDeterministicDrawV1, verifyDrawResult } from '../src/core/randomizer/deterministic';
|
||||
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 { ParticipantSnapshotData } from '../src/core/types/audit';
|
||||
|
||||
|
|
@ -54,6 +54,7 @@ describe('DeterministicProofHash & AuditEventHash Separation', () => {
|
|||
version: 1,
|
||||
createdAt: '2026-08-17T12:00:00.000Z',
|
||||
eligibleParticipants: participants,
|
||||
filterRulesSnapshot: { ...DEFAULT_FILTER_RULES },
|
||||
participantCount: 3,
|
||||
participantsSnapshotHash: computeParticipantsSnapshotHash(participants),
|
||||
conditionsHash: computeConditionsHash(DEFAULT_FILTER_RULES),
|
||||
|
|
@ -120,20 +121,28 @@ describe('DeterministicProofHash & AuditEventHash Separation', () => {
|
|||
seed,
|
||||
});
|
||||
|
||||
const verification = verifyDrawResult(
|
||||
const verification = verifyDrawResult({
|
||||
giveawayId: 'gw-1',
|
||||
drawId: originalDraw.drawId,
|
||||
drawnAt: originalDraw.drawnAt,
|
||||
snapshot,
|
||||
seed,
|
||||
1,
|
||||
1,
|
||||
originalDraw.winnerIds,
|
||||
originalDraw.deterministicProofHash
|
||||
);
|
||||
claimedWinnersCount: 1,
|
||||
claimedReserveCount: 1,
|
||||
claimedWinnerIds: originalDraw.winnerIds,
|
||||
claimedReserveWinnerIds: originalDraw.reserveWinnerIds,
|
||||
claimedDeterministicProofHash: originalDraw.deterministicProofHash,
|
||||
claimedAuditEventHash: originalDraw.auditEventHash,
|
||||
algorithmVersion: originalDraw.algorithmVersion,
|
||||
});
|
||||
|
||||
expect(verification.verified).toBe(true);
|
||||
expect(verification.winnersMatch).toBe(true);
|
||||
expect(verification.reserveWinnersMatch).toBe(true);
|
||||
expect(verification.deterministicProofHashMatch).toBe(true);
|
||||
expect(verification.snapshotHashMatch).toBe(true);
|
||||
expect(verification.conditionsHashMatch).toBe(true);
|
||||
expect(verification.auditEventHashMatch).toBe(true);
|
||||
expect(verification.participantsSnapshotIntegrity).toBe(true);
|
||||
expect(verification.conditionsIntegrity).toBe(true);
|
||||
expect(verification.expectedDeterministicProofHash).toBe(originalDraw.deterministicProofHash);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ function createMockSnapshot(count: number): ParticipantSnapshotData {
|
|||
version: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
eligibleParticipants: eligible,
|
||||
filterRulesSnapshot: { ...DEFAULT_FILTER_RULES },
|
||||
participantCount: count,
|
||||
participantsSnapshotHash: computeParticipantsSnapshotHash(eligible),
|
||||
conditionsHash: computeConditionsHash(DEFAULT_FILTER_RULES),
|
||||
|
|
@ -162,15 +163,20 @@ describe('Deterministic Randomizer V1 (HMAC_SHA256_FY_V1)', () => {
|
|||
filterRules: DEFAULT_FILTER_RULES,
|
||||
});
|
||||
|
||||
const verification = verifyDrawResult(
|
||||
const verification = verifyDrawResult({
|
||||
giveawayId: 'gw-audit',
|
||||
drawId: originalDraw.drawId,
|
||||
drawnAt: originalDraw.drawnAt,
|
||||
snapshot,
|
||||
seed,
|
||||
2,
|
||||
2,
|
||||
originalDraw.winnerIds,
|
||||
originalDraw.deterministicProofHash,
|
||||
ALGORITHM_VERSION_V1
|
||||
);
|
||||
claimedWinnersCount: 2,
|
||||
claimedReserveCount: 2,
|
||||
claimedWinnerIds: originalDraw.winnerIds,
|
||||
claimedReserveWinnerIds: originalDraw.reserveWinnerIds,
|
||||
claimedDeterministicProofHash: originalDraw.deterministicProofHash,
|
||||
claimedAuditEventHash: originalDraw.auditEventHash,
|
||||
algorithmVersion: ALGORITHM_VERSION_V1,
|
||||
});
|
||||
|
||||
expect(verification.verified).toBe(true);
|
||||
expect(verification.expectedWinnerIds).toEqual(originalDraw.winnerIds);
|
||||
|
|
|
|||
278
tests/tampering-verification.test.ts
Normal file
278
tests/tampering-verification.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -54,6 +54,7 @@ describe('Verification API Replay Engine', () => {
|
|||
version: 1,
|
||||
createdAt: '2026-08-17T12:00:00.000Z',
|
||||
eligibleParticipants: participants,
|
||||
filterRulesSnapshot: { ...DEFAULT_FILTER_RULES },
|
||||
participantCount: 3,
|
||||
participantsSnapshotHash: computeParticipantsSnapshotHash(participants),
|
||||
conditionsHash: computeConditionsHash(DEFAULT_FILTER_RULES),
|
||||
|
|
@ -71,21 +72,28 @@ describe('Verification API Replay Engine', () => {
|
|||
seed,
|
||||
});
|
||||
|
||||
const result = verifyDrawResult(
|
||||
const result = verifyDrawResult({
|
||||
giveawayId: 'gw-verif-1',
|
||||
drawId: draw.drawId,
|
||||
drawnAt: draw.drawnAt,
|
||||
snapshot,
|
||||
seed,
|
||||
1,
|
||||
1,
|
||||
draw.winnerIds,
|
||||
draw.deterministicProofHash,
|
||||
draw.algorithmVersion
|
||||
);
|
||||
claimedWinnersCount: 1,
|
||||
claimedReserveCount: 1,
|
||||
claimedWinnerIds: draw.winnerIds,
|
||||
claimedReserveWinnerIds: draw.reserveWinnerIds,
|
||||
claimedDeterministicProofHash: draw.deterministicProofHash,
|
||||
claimedAuditEventHash: draw.auditEventHash,
|
||||
algorithmVersion: draw.algorithmVersion,
|
||||
});
|
||||
|
||||
expect(result.verified).toBe(true);
|
||||
expect(result.winnersMatch).toBe(true);
|
||||
expect(result.reserveWinnersMatch).toBe(true);
|
||||
expect(result.deterministicProofHashMatch).toBe(true);
|
||||
expect(result.snapshotHashMatch).toBe(true);
|
||||
expect(result.conditionsHashMatch).toBe(true);
|
||||
expect(result.auditEventHashMatch).toBe(true);
|
||||
expect(result.participantsSnapshotIntegrity).toBe(true);
|
||||
expect(result.conditionsIntegrity).toBe(true);
|
||||
expect(result.expectedWinnerIds).toEqual(draw.winnerIds);
|
||||
});
|
||||
|
||||
|
|
@ -101,15 +109,20 @@ describe('Verification API Replay Engine', () => {
|
|||
|
||||
const fakeWinnerIds = ['9999']; // Tampered winners
|
||||
|
||||
const result = verifyDrawResult(
|
||||
const result = verifyDrawResult({
|
||||
giveawayId: 'gw-verif-1',
|
||||
drawId: draw.drawId,
|
||||
drawnAt: draw.drawnAt,
|
||||
snapshot,
|
||||
seed,
|
||||
1,
|
||||
1,
|
||||
fakeWinnerIds,
|
||||
draw.deterministicProofHash,
|
||||
draw.algorithmVersion
|
||||
);
|
||||
claimedWinnersCount: 1,
|
||||
claimedReserveCount: 1,
|
||||
claimedWinnerIds: fakeWinnerIds,
|
||||
claimedReserveWinnerIds: draw.reserveWinnerIds,
|
||||
claimedDeterministicProofHash: draw.deterministicProofHash,
|
||||
claimedAuditEventHash: draw.auditEventHash,
|
||||
algorithmVersion: draw.algorithmVersion,
|
||||
});
|
||||
|
||||
expect(result.verified).toBe(false);
|
||||
expect(result.winnersMatch).toBe(false);
|
||||
|
|
@ -127,15 +140,20 @@ describe('Verification API Replay Engine', () => {
|
|||
|
||||
const fakeProofHash = '0000000000000000000000000000000000000000000000000000000000000000';
|
||||
|
||||
const result = verifyDrawResult(
|
||||
const result = verifyDrawResult({
|
||||
giveawayId: 'gw-verif-1',
|
||||
drawId: draw.drawId,
|
||||
drawnAt: draw.drawnAt,
|
||||
snapshot,
|
||||
seed,
|
||||
1,
|
||||
1,
|
||||
draw.winnerIds,
|
||||
fakeProofHash,
|
||||
draw.algorithmVersion
|
||||
);
|
||||
claimedWinnersCount: 1,
|
||||
claimedReserveCount: 1,
|
||||
claimedWinnerIds: draw.winnerIds,
|
||||
claimedReserveWinnerIds: draw.reserveWinnerIds,
|
||||
claimedDeterministicProofHash: fakeProofHash,
|
||||
claimedAuditEventHash: draw.auditEventHash,
|
||||
algorithmVersion: draw.algorithmVersion,
|
||||
});
|
||||
|
||||
expect(result.verified).toBe(false);
|
||||
expect(result.deterministicProofHashMatch).toBe(false);
|
||||
|
|
|
|||
Loading…
Reference in a new issue