diff --git a/frontend/functions/.gitignore b/frontend/functions/.gitignore index 40f062c1d2..5d00677939 100644 --- a/frontend/functions/.gitignore +++ b/frontend/functions/.gitignore @@ -1,3 +1,4 @@ node_modules/ *.local coverage/ +lib/ diff --git a/frontend/functions/__tests__/signature.test.js b/frontend/functions/__tests__/signature.test.js index a30df931fc..9099b8cef2 100644 --- a/frontend/functions/__tests__/signature.test.js +++ b/frontend/functions/__tests__/signature.test.js @@ -1,4 +1,4 @@ -const { validateSignature } = require('../index'); +const { validateSignature } = require('../src/index'); describe('validateSignature', () => { let signedHeartbeat; diff --git a/frontend/functions/build.js b/frontend/functions/build.js new file mode 100644 index 0000000000..485733fd6b --- /dev/null +++ b/frontend/functions/build.js @@ -0,0 +1,25 @@ +const fs = require('fs'); +const path = require('path'); + +const keysFilePath = path.resolve(__dirname, 'allowed_keys.txt'); +let keys = []; + +if (fs.existsSync(keysFilePath)) { + keys = fs.readFileSync(keysFilePath, 'utf-8') + .split('\n') + .map(key => key.trim()) + .filter(key => key.length > 0); + + const validatorFilePath = path.resolve(__dirname, 'functions/submitterValidator.ts'); + let validatorFileContent = fs.readFileSync(validatorFilePath, 'utf-8'); + + const keysSetString = keys.map(key => `'${key}'`).join(',\n '); + validatorFileContent = validatorFileContent.replace( + '// ALLOWED_PUBLIC_KEYS_PLACEHOLDER', + keysSetString, + ); + + fs.writeFileSync(validatorFilePath, validatorFileContent); +} else { + console.warn('allowed_keys.txt not found. All submitters will be allowed.'); +} diff --git a/frontend/functions/package-lock.json b/frontend/functions/package-lock.json index 13d355426c..c6a29d8af2 100644 --- a/frontend/functions/package-lock.json +++ b/frontend/functions/package-lock.json @@ -9,7 +9,7 @@ "blake2": "^5.0.0", "bs58check": "^3.0.1", "firebase-admin": "^12.1.0", - "firebase-functions": "^5.0.0", + "firebase-functions": "^6.2.0", "mina-signer": "^3.0.7" }, "devDependencies": { @@ -1394,20 +1394,21 @@ } }, "node_modules/@types/express": { - "version": "4.17.3", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.3.tgz", - "integrity": "sha512-I8cGRJj3pyOLs/HndoP+25vOqhqWkAZsWMEmq1qXy/b/M3ppufecUwaK2/TVDVxcV61/iSdhykUjQQ2DLSrTdg==", + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", + "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", "license": "MIT", "dependencies": { "@types/body-parser": "*", - "@types/express-serve-static-core": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", "@types/serve-static": "*" } }, "node_modules/@types/express-serve-static-core": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.4.tgz", - "integrity": "sha512-5kz9ScmzBdzTgB/3susoCgfqNDzBjvLL4taparufgSvlwjdLy6UyUy9T/tCpYd2GIdIilCatC4iSQS0QSYHt0w==", + "version": "4.19.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", + "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", "license": "MIT", "dependencies": { "@types/node": "*", @@ -2897,15 +2898,15 @@ } }, "node_modules/firebase-functions": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-5.1.1.tgz", - "integrity": "sha512-KkyKZE98Leg/C73oRyuUYox04PQeeBThdygMfeX+7t1cmKWYKa/ZieYa89U8GHgED+0mF7m7wfNZOfbURYxIKg==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-6.2.0.tgz", + "integrity": "sha512-vfyyVHS8elxplzEQ9To+NaINRPFUsDasQrasTa2eFJBYSPzdhkw6rwLmvwyYw622+ze+g4sDIb14VZym+afqXQ==", "license": "MIT", "dependencies": { "@types/cors": "^2.8.5", - "@types/express": "4.17.3", + "@types/express": "^4.17.21", "cors": "^2.8.5", - "express": "^4.17.1", + "express": "^4.21.0", "protobufjs": "^7.2.2" }, "bin": { @@ -2915,7 +2916,7 @@ "node": ">=14.10.0" }, "peerDependencies": { - "firebase-admin": "^11.10.0 || ^12.0.0" + "firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0" } }, "node_modules/firebase-functions-test": { @@ -4473,30 +4474,6 @@ "node": ">=14" } }, - "node_modules/jwks-rsa/node_modules/@types/express": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", - "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", - "license": "MIT", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "*" - } - }, - "node_modules/jwks-rsa/node_modules/@types/express-serve-static-core": { - "version": "4.19.6", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", - "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, "node_modules/jwks-rsa/node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", diff --git a/frontend/functions/package.json b/frontend/functions/package.json index 2f8c6c097b..8d76d54827 100644 --- a/frontend/functions/package.json +++ b/frontend/functions/package.json @@ -2,25 +2,26 @@ "name": "functions", "description": "Cloud Functions for Firebase", "scripts": { - "serve": "firebase emulators:start --only functions", + "serve": "npm run build && firebase emulators:start --only functions,firestore", "shell": "firebase functions:shell", - "start": "npm run shell", + "start": "npm run build && node lib/index.js", "deploy": "firebase deploy --only functions", "logs": "firebase functions:log", "test": "jest", "test:watch": "jest --watch", - "build": "tsc", + "build": "node build.js && tsc -p tsconfig.json", "build:watch": "tsc --watch" }, "engines": { - "node": "18" + "node": "22" }, - "main": "index.ts", + "type": "commonjs", + "main": "lib/index.js", "dependencies": { "blake2": "^5.0.0", "bs58check": "^3.0.1", "firebase-admin": "^12.1.0", - "firebase-functions": "^5.0.0", + "firebase-functions": "^6.2.0", "mina-signer": "^3.0.7" }, "devDependencies": { diff --git a/frontend/functions/index.ts b/frontend/functions/src/index.ts similarity index 65% rename from frontend/functions/index.ts rename to frontend/functions/src/index.ts index bf6709769b..bcf9751b12 100644 --- a/frontend/functions/index.ts +++ b/frontend/functions/src/index.ts @@ -4,6 +4,8 @@ import * as blake2 from 'blake2'; import bs58check from 'bs58check'; import Client from 'mina-signer'; import { submitterAllowed } from './submitterValidator'; +import { CallableRequest, onCall } from 'firebase-functions/v2/https'; +import { getFirestore, FieldValue } from 'firebase-admin/firestore'; interface SignatureJson { field: string; @@ -11,8 +13,9 @@ interface SignatureJson { } interface HeartbeatData { - publicKey: string; - data: string; + version: number; + payload: string; + submitter: string; signature: SignatureJson; } @@ -20,10 +23,13 @@ const minaClient = new Client({ network: 'testnet' }); admin.initializeApp(); +// Rate limit duration between heartbeats from the same submitter (15 seconds) +const HEARTBEAT_RATE_LIMIT_MS = 15000; + function validateSignature( data: string, signature: SignatureJson, - publicKeyBase58: string + publicKeyBase58: string, ): boolean { try { const h = blake2.createHash('blake2b', { digestLength: 32 }); @@ -62,49 +68,55 @@ function validateSignature( } } -export const handleValidationAndStore = functions - .region('us-central1') - .https.onCall(async (data: HeartbeatData, context: functions.https.CallableContext) => { - console.log('Received data:', data); - const { publicKey, data: inputData, signature } = data; +export const handleValidationAndStore = onCall( + { region: 'us-central1', enforceAppCheck: false }, + async (request: CallableRequest) => { + console.log('Received data:', request.data); + const data = request.data; + const { submitter, payload, signature } = data; - if (!submitterAllowed(publicKey)) { + if (!submitterAllowed(submitter)) { throw new functions.https.HttpsError( 'permission-denied', - 'Public key not authorized' + 'Public key not authorized', ); } - const rateLimitRef = admin.firestore().collection('publicKeyRateLimits').doc(publicKey); + const db = getFirestore(); try { - await admin.firestore().runTransaction(async (transaction) => { + if (!validateSignature(payload, signature, submitter)) { + throw new functions.https.HttpsError( + 'unauthenticated', + 'Signature validation failed', + ); + } + + const rateLimitRef = db.collection('publicKeyRateLimits').doc(submitter); + const newHeartbeatRef = db.collection('heartbeats').doc(); + + await db.runTransaction(async (transaction) => { const doc = await transaction.get(rateLimitRef); const now = Date.now(); - const cutoff = now - 15 * 1000; + const cutoff = now - HEARTBEAT_RATE_LIMIT_MS; if (doc.exists) { - const lastCall = doc.data()?.lastCall; + const lastCall = doc.data()?.['lastCall']; if (lastCall > cutoff) { throw new functions.https.HttpsError( 'resource-exhausted', - 'Rate limit exceeded for this public key' + 'Rate limit exceeded for this public key', ); } } - transaction.set(rateLimitRef, { lastCall: now }, { merge: true }); + transaction.set(rateLimitRef, { lastCall: FieldValue.serverTimestamp() }, { merge: true }); + transaction.create(newHeartbeatRef, { + ...data, + createTime: FieldValue.serverTimestamp(), + }); }); - if (!validateSignature(inputData, signature, publicKey)) { - throw new functions.https.HttpsError( - 'unauthenticated', - 'Signature validation failed' - ); - } - - await admin.firestore().collection('heartbeat').add(data); - return { message: 'Data validated and stored successfully' }; } catch (error) { console.error('Error during data validation and storage:', error); @@ -113,9 +125,10 @@ export const handleValidationAndStore = functions } throw new functions.https.HttpsError( 'internal', - 'An error occurred during validation or storage' + 'An error occurred during validation or storage', ); } - }); + }, +); export { validateSignature }; diff --git a/frontend/functions/submitterValidator.ts b/frontend/functions/src/submitterValidator.ts similarity index 88% rename from frontend/functions/submitterValidator.ts rename to frontend/functions/src/submitterValidator.ts index 3643e66977..7e44314735 100644 --- a/frontend/functions/submitterValidator.ts +++ b/frontend/functions/src/submitterValidator.ts @@ -1,5 +1,6 @@ // base58 encoded public keys that are allowed to submit data const allowedPublicKeys: Set = new Set([ + // ALLOWED_PUBLIC_KEYS_PLACEHOLDER ]); export function submitterAllowed(publicKeyBase58: string): boolean { diff --git a/frontend/functions/tsconfig.json b/frontend/functions/tsconfig.json index 8fb649875a..37ac884f80 100644 --- a/frontend/functions/tsconfig.json +++ b/frontend/functions/tsconfig.json @@ -1,16 +1,27 @@ { "compilerOptions": { - "module": "commonjs", + "target": "ES2019", + "module": "CommonJS", + "moduleResolution": "Node", "noImplicitReturns": true, "noUnusedLocals": true, "outDir": "lib", "sourceMap": true, "strict": true, - "target": "es2017", - "esModuleInterop": true + "esModuleInterop": true, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true }, "compileOnSave": true, "include": [ - "src" + "src/**/*" + ], + "exclude": [ + "build.js", + "jest.config.js" + ], + "types": [ + "node", + "jest" ] }