diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e934c8d..0aec025 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/app/api/giveaways/[id]/verify/route.ts b/src/app/api/giveaways/[id]/verify/route.ts index 205f12d..14494a5 100644 --- a/src/app/api/giveaways/[id]/verify/route.ts +++ b/src/app/api/giveaways/[id]/verify/route.ts @@ -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) { diff --git a/src/app/giveaways/[id]/page.tsx b/src/app/giveaways/[id]/page.tsx index 71fbf8c..7e995f2 100644 --- a/src/app/giveaways/[id]/page.tsx +++ b/src/app/giveaways/[id]/page.tsx @@ -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() { > )} -
{drawResult.drawId}
+{drawResult.snapshotId}
@@ -269,6 +276,10 @@ export default function GiveawayDetailPage() { Snapshot Hash:{drawResult.participantsSnapshotHash}
{drawResult.conditionsHash}
+{drawResult.deterministicProofHash}
diff --git a/src/core/randomizer/deterministic.ts b/src/core/randomizer/deterministic.ts index e0b0aa7..611c3ec 100644 --- a/src/core/randomizer/deterministic.ts +++ b/src/core/randomizer/deterministic.ts @@ -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, }; } diff --git a/src/core/types/audit.ts b/src/core/types/audit.ts index 364156c..aa897d2 100644 --- a/src/core/types/audit.ts +++ b/src/core/types/audit.ts @@ -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; } diff --git a/src/lib/repository/memory-repository.ts b/src/lib/repository/memory-repository.ts index 1829126..0d0e549 100644 --- a/src/lib/repository/memory-repository.ts +++ b/src/lib/repository/memory-repository.ts @@ -131,6 +131,7 @@ export class MemoryGiveawayRepository implements IGiveawayRepository { version: newVersion, createdAt: new Date().toISOString(), eligibleParticipants: [...eligibleParticipants], + filterRulesSnapshot: { ...rules }, participantCount: eligibleParticipants.length, participantsSnapshotHash, conditionsHash, diff --git a/src/lib/repository/prisma-repository.ts b/src/lib/repository/prisma-repository.ts index 9bf2228..37dbf66 100644 --- a/src/lib/repository/prisma-repository.ts +++ b/src/lib/repository/prisma-repository.ts @@ -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, diff --git a/tests/proof-separation.test.ts b/tests/proof-separation.test.ts index db4eec1..b7591b0 100644 --- a/tests/proof-separation.test.ts +++ b/tests/proof-separation.test.ts @@ -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); }); }); diff --git a/tests/randomizer.test.ts b/tests/randomizer.test.ts index 6d4480c..f480eb0 100644 --- a/tests/randomizer.test.ts +++ b/tests/randomizer.test.ts @@ -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); diff --git a/tests/tampering-verification.test.ts b/tests/tampering-verification.test.ts new file mode 100644 index 0000000..4ab46fc --- /dev/null +++ b/tests/tampering-verification.test.ts @@ -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); + }); +}); diff --git a/tests/verification-api.test.ts b/tests/verification-api.test.ts index 731875f..04982c6 100644 --- a/tests/verification-api.test.ts +++ b/tests/verification-api.test.ts @@ -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);