diff --git a/README.md b/README.md index ff07b8a..c0c9296 100644 --- a/README.md +++ b/README.md @@ -16,14 +16,16 @@ - Черный список ID и логинов. - **Детерминированный Randomizer (Provably Fair)**: - Исключен непрозрачный `Math.random()`. - - Выборка на основе HMAC-SHA256 и перетасовки Фишера-Йетса. - - Snapshot Hash (SHA-256) канонического списка участников + Seed = 100% повторяемость и верифицируемость. + - Выборка на основе HMAC-SHA256 (`HMAC_SHA256_FY_V1`) и несмещенной перетасовки Фишера-Йетса. + - **Seed Pre-Commitment**: SHA-256 хеш сида фиксируется и публикуется на этапе фиксации слепка до жеребьевки, исключая seed grinding. + - Snapshot Hash (SHA-256) канонического списка участников + раскрытый Seed = 100% математическая воспроизводимость. + - Публичный результат (`GET /api/giveaways/[id]/public` и страница `/giveaways/[id]`) доступен любому участнику без входа в систему. - Поддержка основных и резервных призовых мест. - **Интерактивный UI**: - Dashboard со статистикой и списком кампаний. - 5-шаговый визард создания розыгрыша. - Живое превью условий и статуса допуска каждого участника с указанием причин отклонения. - - Презентация победителей и сертификат криптографического аудита. + - Публичная страница розыгрыша с проверкой победителей и сертификатом криптографического аудита. --- diff --git a/agents/antigravity/done/TASK-2026-08-21-06-public-verification.md b/agents/antigravity/done/TASK-2026-08-21-06-public-verification.md new file mode 100644 index 0000000..ccdf2e9 --- /dev/null +++ b/agents/antigravity/done/TASK-2026-08-21-06-public-verification.md @@ -0,0 +1,70 @@ +# Task 06: Публичная проверяемость розыгрыша Report + +**Date:** 2026-08-21 +**Base Commit SHA:** `1a27a10847fe510f0ed0f128087271f78a489c7b` +**Status:** COMPLETED / PASS +**Assigned Agent:** Antigravity (Implementation Orchestrator) + +--- + +## 1. Executive Summary + +Реализована архитектура публичной проверяемости результатов розыгрыша для участников и внешних наблюдателей без раскрытия персональных данных третьих лиц: + +1. **Публичный API-эндпоинт (`GET /api/giveaways/[id]/public`):** + - Доступен без аутентификации, защищен rate limiter'ом `expensiveApiRateLimiter` (`giveaway-public-get:${clientIp}:${id}`). + - Возвращает метаданные публикации, условия отбора, хеш слепка `participantsSnapshotHash`, хеш условий `conditionsHash`, `algorithmVersion`. + - **`seedCommitment`:** публично доступен **и до, и после** розыгрыша. + - **`seed`:** строго скрыт (`null`) до завершения жеребьевки (`SNAPSHOT_LOCKED`), раскрывается только в статусе `DRAWN`/`PUBLISHED`. + - **Защита PII:** полные списки участников (`participants`, `eligibleParticipants`, `excludedParticipants`) исключены из публичного ответа. Публикуются только победители (имя, аватар, ID). + - Токены, учетные данные и `organizerId` исключены из ответа. + +2. **Публичная страница розыгрыша (`src/app/giveaways/[id]/page.tsx`):** + - Переведена на получение данных через `/api/giveaways/[id]/public`. + - Открывается анонимным пользователям без необходимости авторизации через VK ID. + - Отображает карточки победителей, хеш сида `Seed Commitment (SHA-256)`, `deterministicProofHash`, `auditEventHash` и кнопку онлайн-верификации (`/api/giveaways/[id]/verify`). + +3. **UI Визарда (Шаг 4):** + - Добавлена кнопка быстрого копирования `seedCommitment` в буфер обмена для публикации организатором в комментариях к посту до запуска розыгрыша. + +4. **Документация (`README.md`, `docs/ARCHITECTURE.md`):** + - Честно зафиксированы границы проверяемости и компромисс защиты приватности (PII). + +--- + +## 2. Границы публичной проверяемости (Provably Fair Scope & Privacy Compromise) + +### Что может независимо проверить любой внешний наблюдатель: +1. **Защита от Seed Grinding:** совпадение $\text{SHA256}(\text{seed}) == \text{SeedCommitment}$ гарантирует, что случайное число было сгенерировано и зафиксировано на этапе создания слепка до жеребьевки, а не подбиралось организатором под конкретных победителей. +2. **Воспроизводимость алгоритма:** соответствие вычислений стандарту `HMAC_SHA256_FY_V1`. +3. **Целостность доказательства:** совпадение `deterministicProofHash` и `auditEventHash`. + +### Что остаётся непроверяемым внешним наблюдателем (и почему): +1. **Вычисление `participantsSnapshotHash` с нуля:** без полного списка участников сторонний наблюдатель не может самостоятельно пересчитать хеш слепка участников. Список участников намеренно не отдаётся анонимам ради защиты персональных данных третьих лиц (PII). +2. **Внешний якорь времени:** доказательство фиксируется в базе данных Randomayzer. Внешний децентрализованный якорь (блокчейн, drand beacon, RFC 3161) на текущем этапе отсутствует. + +--- + +## 3. Modified Files + +| File | Type | Description | +|------|------|-------------| +| `src/app/api/giveaways/[id]/public/route.ts` | API Route (NEW) | Публичный маршрут с отдачей данных розыгрыша без PII и с защитой сида до жеребьевки. | +| `src/app/giveaways/[id]/page.tsx` | UI | Перевод страницы на `/api/giveaways/[id]/public` и отображение `seedCommitment`. | +| `src/app/giveaways/new/page.tsx` | UI | Кнопка копирования `seedCommitment` на шаге 4 визарда. | +| `docs/ARCHITECTURE.md` | Docs | Обновлен раздел механизма честности и границ проверяемости. | +| `README.md` | Docs | Описаны возможности публичной проверки и Seed Pre-Commitment. | +| `tests/public-verification.test.ts` | Tests (NEW) | Тесты публичного эндпоинта (5 тестов): доступ анонимов, маскирование seed, проверка commitment, защита 401 на приватном маршруте, отсутствие PII. | + +--- + +## 4. Verification Evidence & Test Gate + +```text +npx prisma generate -> EXIT 0 (Prisma Client v5.22.0) +npx tsc --noEmit -> EXIT 0 (0 ошибок типизации) +npm test -> EXIT 0 (54 тестовых файла, 316 тестов прошли успешно) +npm run lint -> EXIT 0 (0 ошибок, 6 warnings на no-img-element) +npm run build -> EXIT 0 (Все 17 маршрутов скомпилированы успешно) +npm audit --omit=dev -> EXIT 0 (0 vulnerabilities) +``` diff --git a/agents/antigravity/inbox/TASK-2026-08-21-06-public-verification.md b/agents/antigravity/inbox/TASK-2026-08-21-06-public-verification.md new file mode 100644 index 0000000..3df08cf --- /dev/null +++ b/agents/antigravity/inbox/TASK-2026-08-21-06-public-verification.md @@ -0,0 +1,32 @@ +# Task 06: Публичная проверяемость розыгрыша + +**Assigned to:** Antigravity (Implementation Orchestrator) +**Priority:** MEDIUM (product claim alignment) +**Date:** 2026-08-21 +**Base SHA:** `1a27a10847fe510f0ed0f128087271f78a489c7b` + +## Scope +1. Implement public read-only giveaway result endpoint `GET /api/giveaways/[id]/public`: + - Publicly accessible without session. + - Bounded by anonymous rate limiting (`expensiveApiRateLimiter.assertAllowed('giveaway-public-get:' + clientIp + ':' + id)`). + - Exposes safe public information: + * Post metadata & snapshot `filterRules` + * `participantsSnapshotHash`, `conditionsHash`, `algorithmVersion` + * `seedCommitment` — both BEFORE and AFTER the draw + * After `DRAWN`: `seed` (revealed only once finalized), `deterministicProofHash`, `auditEventHash`, winners & reserve winners (public winner names & IDs) + * Before `DRAWN`: `seed === null` (strictly masked) + * Zero private PII: full eligible/excluded participants list is omitted to protect third-party privacy + * Zero credential/token/internal organizer metadata +2. Update public giveaway view page `src/app/giveaways/[id]/page.tsx`: + - Works seamlessly for unauthenticated visitors by fetching from `/api/giveaways/[id]/public`. + - Displays post information, winners, seed pre-commitment SHA-256, proof hash, and mathematical verification state. +3. Update `README.md` and `docs/ARCHITECTURE.md` to document the Provably Fair model honestly and explicitly: + - What is independently verifiable by external observers (seed pre-commitment binding, draw execution reproducibility from snapshot hash + seed). + - What remains unverifiable externally without raw PII (external observers cannot re-compute `participantsSnapshotHash` without the full raw participant list, and the server DB holds the proof without external decentralized timestamping/blockchain anchor). +4. Create test suite `tests/public-verification.test.ts`: + - Anonymous user retrieves public results for `DRAWN` giveaway. + - Anonymous user does not receive `seed` on non-`DRAWN` giveaway (`seed === null`). + - `seedCommitment` is visible before draw and equals `sha256(seed)` after draw. + - Anonymous user still receives `401 Unauthorized` on private `GET /api/giveaways/[id]`. + - Zero private participant PII or tokens in public response. +5. Verification gate & report in `agents/antigravity/done/TASK-2026-08-21-06-public-verification.md`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7edbbe8..6557896 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -48,12 +48,34 @@ graph TD ## 3. Механизм честности и доказуемости (Provably Fair) -Каждый розыгрыш формирует криптографический аудит-след: -1. **Снапшот участников**: Список прошедших фильтрацию (eligible) участников сортируется по `platformUserId` и хешируется через SHA-256: - $$\text{ParticipantsHash} = \text{SHA256}(\text{JSON}(\text{sortedEligibleParticipants}))$$ -2. **Seed розыгрыша**: Пользовательский или сгенерированный криптографически стойкий seed. -3. **Детерминированный выбор**: - - Для каждого шага выбора индекса вычисляется: - $$\text{Hash}_i = \text{HMAC-SHA256}(\text{seed} + ":" + i, \text{ParticipantsHash})$$ - - Индекс победителя определяется детерминированно из полученного хеша. -4. **Результат**: Зная `ParticipantsHash` и `seed`, любой внешний наблюдатель может воспроизвести выбор и убедиться в честности результата на 100%. +Каждый розыгрыш формирует криптографический аудит-след на базе алгоритма `HMAC_SHA256_FY_V1`: + +1. **Снапшот участников**: Список прошедших фильтрацию (eligible) участников сортируется по `platformUserId` и канонически сериализуется: + $$\text{ParticipantsSnapshotHash} = \text{SHA256}(\text{canonicalStringify}(\text{sortedEligibleParticipants}))$$ +2. **Seed Pre-Commitment (Защита от Seed Grinding)**: + - В момент фиксации слепка (`SNAPSHOT_LOCKED`) сервер генерирует CSPRNG seed и публикует его SHA-256 обязательство: + $$\text{SeedCommitment} = \text{SHA256}(\text{seed})$$ + - До момента проведения жеребьевки сам `seed` строго скрыт (`seed: null`), но `seedCommitment` доступен публично. Организатор может зафиксировать его публично (например, в комментарии к конкурсному посту VK) до розыгрыша. +3. **Детерминированный выбор (`HMAC_SHA256_FY_V1`)**: + - Выборка осуществляется с помощью несмещенного сэмплинга Фишера-Йетса (Fisher-Yates) поверх потока псевдослучайных байт HMAC-SHA256: + $$\text{ByteStream} = \text{HMAC-SHA256}(\text{key} = \text{seed}, \text{data} = \text{ParticipantsSnapshotHash} \parallel \text{ConditionsHash} \parallel \text{blockIndex})$$ + - Позиции победителей и резерва рассчитываются детерминированно. +4. **Публичное раскрытие и аудит**: + - После перевода розыгрыша в статус `DRAWN` сервер раскрывает `seed`. + - Любой участник может проверить: + 1. $\text{SHA256}(\text{seed}) == \text{SeedCommitment}$ (гарантия того, что seed не подбирался под желаемого победителя); + 2. Воспроизведение результатов выборки при наличии слепка; + 3. Неизменность `deterministicProofHash` и `auditEventHash`. + +### 3.1. Границы публичной проверяемости и защита приватности (PII Compromise) + +- **Что проверяется внешним наблюдателем:** + - Корректность раскрытия seed относительно опубликованного pre-commitment. + - Математическая повторяемость алгоритма. + - Совпадение хешей доказательства (`deterministicProofHash`). +- **Что остаётся приватным:** + - Полный список участников и их персональные данные (PII) **не отдаются анонимным пользователям** в целях соблюдения требований защиты данных третьих лиц. Публикуются только победители и хеш слепка `participantsSnapshotHash`. + - Внешний наблюдатель без исходного списка участников не может самостоятельно с нуля пересчитать `participantsSnapshotHash`. +- **Архитектурный статус доверия:** + - Доказательство формируется и проверяется на сервере Randomayzer на основе зафиксированного в БД слепка. Децентрализованный внешний якорь (блокчейн, drand, RFC 3161) на текущем этапе не используется. + diff --git a/src/app/api/giveaways/[id]/public/route.ts b/src/app/api/giveaways/[id]/public/route.ts new file mode 100644 index 0000000..44c0fcf --- /dev/null +++ b/src/app/api/giveaways/[id]/public/route.ts @@ -0,0 +1,109 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { GiveawayStore } from '@/lib/giveaway-store'; +import { handleApiError, NotFoundError } from '@/core/errors/http-errors'; +import { expensiveApiRateLimiter } from '@/lib/rate-limiter'; +import { resolveClientIp } from '@/lib/client-ip'; +import { computeSeedCommitment } from '@/core/randomizer/hasher'; + +export const dynamic = 'force-dynamic'; + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ id: string }> | { id: string } } +) { + try { + const { id } = await params; + const clientIp = resolveClientIp(req); + + // Anonymous rate limiter + expensiveApiRateLimiter.assertAllowed(`giveaway-public-get:${clientIp}:${id}`); + + const giveaway = await GiveawayStore.getById(id); + if (!giveaway) { + throw new NotFoundError(`Giveaway with id "${id}" not found`); + } + + const isDrawn = giveaway.status === 'DRAWN' || giveaway.status === 'PUBLISHED'; + + // Calculate or retrieve seed commitment (accessible before and after draw) + const seedCommitment = giveaway.seedCommitment || (giveaway.seed ? computeSeedCommitment(giveaway.seed) : null); + + // Revealed seed: strictly null before finalized draw, revealed once drawn + const seed = isDrawn ? giveaway.seed : null; + + const publicGiveaway = { + id: giveaway.id, + status: giveaway.status, + title: giveaway.title, + description: giveaway.description, + platform: giveaway.platform, + sourceUrl: giveaway.sourceUrl, + post: { + platform: giveaway.platform, + ownerId: giveaway.platformOwnerId, + postId: giveaway.platformPostId, + title: giveaway.title, + imageUrl: giveaway.postImageUrl, + likesCount: giveaway.postLikesCount, + commentsCount: giveaway.postCommentsCount, + repostsCount: giveaway.postRepostsCount, + }, + postImageUrl: giveaway.postImageUrl, + filterRules: giveaway.latestSnapshot?.filterRulesSnapshot || giveaway.filterRules, + winnersCount: giveaway.winnersCount, + reserveWinnersCount: giveaway.reserveWinnersCount, + seedCommitment, + seed, + drawnAt: giveaway.drawnAt, + createdAt: giveaway.createdAt, + updatedAt: giveaway.updatedAt, + latestSnapshot: giveaway.latestSnapshot ? { + version: giveaway.latestSnapshot.version, + createdAt: giveaway.latestSnapshot.createdAt, + participantCount: giveaway.latestSnapshot.participantCount, + participantsSnapshotHash: giveaway.latestSnapshot.participantsSnapshotHash, + conditionsHash: giveaway.latestSnapshot.conditionsHash, + } : null, + drawResult: giveaway.drawResult ? { + drawId: giveaway.drawResult.drawId, + algorithmVersion: giveaway.drawResult.algorithmVersion, + totalEligibleCount: giveaway.drawResult.totalEligibleCount, + totalLoadedCount: giveaway.drawResult.totalLoadedCount, + seedUsed: giveaway.drawResult.seedUsed, + snapshotId: giveaway.drawResult.snapshotId, + drawnAt: giveaway.drawResult.drawnAt, + deterministicProofHash: giveaway.drawResult.deterministicProofHash, + auditEventHash: giveaway.drawResult.auditEventHash, + participantsSnapshotHash: giveaway.drawResult.participantsSnapshotHash, + conditionsHash: giveaway.drawResult.conditionsHash, + winners: giveaway.drawResult.winners.map(w => ({ + position: w.position, + participant: { + platformUserId: w.participant.platformUserId, + firstName: w.participant.firstName, + lastName: w.participant.lastName, + avatarUrl: w.participant.avatarUrl, + }, + })), + reserveWinners: giveaway.drawResult.reserveWinners.map(w => ({ + position: w.position, + participant: { + platformUserId: w.participant.platformUserId, + firstName: w.participant.firstName, + lastName: w.participant.lastName, + avatarUrl: w.participant.avatarUrl, + }, + })), + winnerIds: giveaway.drawResult.winnerIds, + reserveWinnerIds: giveaway.drawResult.reserveWinnerIds, + } : null, + }; + + return NextResponse.json({ + success: true, + giveaway: publicGiveaway, + }); + } catch (error: any) { + return handleApiError(error); + } +} diff --git a/src/app/giveaways/[id]/page.tsx b/src/app/giveaways/[id]/page.tsx index 7e995f2..12facc5 100644 --- a/src/app/giveaways/[id]/page.tsx +++ b/src/app/giveaways/[id]/page.tsx @@ -33,9 +33,9 @@ export default function GiveawayDetailPage() { const fetchGw = async () => { try { setLoading(true); - const res = await fetch(`/api/giveaways/${id}`); + const res = await fetch(`/api/giveaways/${id}/public`); const data = await res.json(); - if (!res.ok) throw new Error(data.error || 'Розыгрыш не найден'); + if (!res.ok) throw new Error(data.error?.message || data.error || 'Розыгрыш не найден'); setGiveaway(data.giveaway); } catch (err: any) { setError(err.message); @@ -269,9 +269,15 @@ export default function GiveawayDetailPage() {
{drawResult.algorithmVersion}
{drawResult.seedUsed}
{giveaway.seedCommitment}
+{drawResult.participantsSnapshotHash}
diff --git a/src/app/giveaways/new/page.tsx b/src/app/giveaways/new/page.tsx index c42e047..b01c2e2 100644 --- a/src/app/giveaways/new/page.tsx +++ b/src/app/giveaways/new/page.tsx @@ -54,6 +54,7 @@ export default function NewGiveawayWizardPage() { const [participants, setParticipants] = useState