diff --git a/package-lock.json b/package-lock.json index 57cb629..279f3c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -76,7 +76,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1815,7 +1814,6 @@ "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1974,7 +1972,6 @@ "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.61.1", "@typescript-eslint/types": "8.61.1", @@ -2341,7 +2338,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2778,7 +2774,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3766,7 +3761,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4122,7 +4116,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -4404,7 +4397,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -5180,7 +5172,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -7187,7 +7178,6 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/engines": "5.22.0" }, @@ -8103,7 +8093,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8255,7 +8244,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -8443,7 +8431,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/services/verificationService.ts b/src/services/verificationService.ts index 2f5fe6a..0d4bfef 100644 --- a/src/services/verificationService.ts +++ b/src/services/verificationService.ts @@ -97,14 +97,36 @@ export async function autoVerify(proofId: string): Promise { // Photographic evidence is the core promise of a proof submission: no // combination of GPS/duplicate/expiry checks may auto-approve a proof that - // has no photos attached or contains corrupt/unreadable photos, no matter how - // high the rest of the score is. A photo-less or corrupt-photo proof can still - // land as inconclusive (routed to community validators) if the remaining - // signals are otherwise plausible, but never as an automatic approval. + // has no photos attached, no matter how high the rest of the score is. + // A photo-less proof can still land as inconclusive (routed to community + // validators) if the remaining signals are otherwise plausible, but never + // as an automatic approval. + const hasPhotos = proof.photos.length > 0; + // Non-spoofable server-side proof signal: the hash of the uploaded file is + // derived from the actual bytes on disk in the backend, so a client cannot + // forge EXIF/GPS metadata to satisfy the auto-approval gate. This is the + // minimum proof-of-capture invariant required for an automatic approval. + const hasServerProofSignal = proof.photos.some((photo) => Boolean(photo.sha256)); const hasCorruptPhotos = proof.photos.some(isCorruptPhoto); - const hasValidPhotos = proof.photos.length > 0 && !hasCorruptPhotos; - if (score >= 0.7 && hasValidPhotos) return { verdict: 'approved', confidence: score }; + if (hasPhotos && hasCorruptPhotos) { + return { + verdict: 'inconclusive', + confidence: score, + notes: 'corrupt_photos_detected', + }; + } + + if (score >= 0.7 && hasPhotos && !hasCorruptPhotos && hasServerProofSignal) { + return { verdict: 'approved', confidence: score }; + } + if (score >= 0.7 && hasPhotos && !hasServerProofSignal) { + return { + verdict: 'inconclusive', + confidence: score, + notes: 'missing_server_proof_signal', + }; + } if (score >= 0.4) return { verdict: 'inconclusive', confidence: score }; return { verdict: 'rejected', diff --git a/tests/integration/proof-to-reward.test.ts b/tests/integration/proof-to-reward.test.ts index 319aa0e..94abd49 100644 --- a/tests/integration/proof-to-reward.test.ts +++ b/tests/integration/proof-to-reward.test.ts @@ -13,6 +13,7 @@ jest.mock('../../src/utils/prisma', () => ({ }, proofPhoto: { create: jest.fn(), + count: jest.fn(), }, }, })); @@ -22,11 +23,13 @@ import prisma from '../../src/utils/prisma'; const mockPrisma = prisma as unknown as { proof: { findUnique: jest.Mock; update: jest.Mock }; verification: { create: jest.Mock }; + proofPhoto: { count: jest.Mock }; }; describe('Proof-to-Reward Integration', () => { beforeEach(() => { jest.clearAllMocks(); + mockPrisma.proofPhoto.count.mockResolvedValue(0); }); it('approves valid proof and generates mock reward tx', async () => { @@ -37,7 +40,16 @@ describe('Proof-to-Reward Integration', () => { status: 'PENDING', lat: -1.2921, lng: 36.8219, - photos: [{ id: 'photo-1', cid: 'cid-1', width: 1920, height: 1080 }], + photos: [ + { + id: 'photo-1', + cid: 'cid-1', + sha256: 'valid-proof-hash', + width: 1920, + height: 1080, + capturedAt: new Date(), + }, + ], user: { wallet: 'GC...USER...' }, task: { id: 'task-1', diff --git a/tests/services/verificationService.test.ts b/tests/services/verificationService.test.ts index e126499..821188a 100644 --- a/tests/services/verificationService.test.ts +++ b/tests/services/verificationService.test.ts @@ -68,6 +68,9 @@ describe('VerificationService', () => { id: 'photo-1', cid: 'cid-1', filename: 'test.jpg', + sha256: 'valid-proof-hash', + width: 4032, + height: 3024, width: 1920, height: 1080, capturedAt: new Date(), @@ -293,6 +296,34 @@ describe('VerificationService', () => { expect(result.confidence).toBe(0.55); }); + it('does not auto-approve EXIF/GPS-only spoofing without a server-generated photo signal', async () => { + const proofCreatedAt = new Date(); + const recentCapture = new Date(proofCreatedAt.getTime() - 60 * 1000); + mockPrisma.proof.findUnique.mockResolvedValue({ + id: 'proof-redteam', + lat: -1.2921, + lng: 36.8219, + createdAt: proofCreatedAt, + photos: [ + { + id: 'photo-1', + cid: 'cid-1', + filename: 'spoofed.jpg', + sha256: null, + width: 4032, + height: 3024, + capturedAt: recentCapture, + }, + ], + task: makeTask(), + }); + + const result = await autoVerify('proof-redteam'); + + expect(result.verdict).not.toBe('approved'); + expect(result.notes).toContain('missing_server_proof_signal'); + }); + // ── Photo gate: no combination of GPS/duplicate/expiry checks may // auto-approve a proof with zero photos (see autoVerify's `hasPhotos` // gate). Table-driven over {photos} x {radius} x {expiry}. ────────────