Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/functions/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
node_modules/
*.local
coverage/
lib/
2 changes: 1 addition & 1 deletion frontend/functions/__tests__/signature.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const { validateSignature } = require('../index');
const { validateSignature } = require('../src/index');

describe('validateSignature', () => {
let signedHeartbeat;
Expand Down
25 changes: 25 additions & 0 deletions frontend/functions/build.js
Original file line number Diff line number Diff line change
@@ -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.');
}
53 changes: 15 additions & 38 deletions frontend/functions/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 7 additions & 6 deletions frontend/functions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
67 changes: 40 additions & 27 deletions frontend/functions/index.ts → frontend/functions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,32 @@ 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;
scalar: string;
}

interface HeartbeatData {
publicKey: string;
data: string;
version: number;
payload: string;
submitter: string;
signature: SignatureJson;
}

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 });
Expand Down Expand Up @@ -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<HeartbeatData>) => {
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);
Expand All @@ -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 };
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// base58 encoded public keys that are allowed to submit data
const allowedPublicKeys: Set<string> = new Set([
// ALLOWED_PUBLIC_KEYS_PLACEHOLDER
]);

export function submitterAllowed(publicKeyBase58: string): boolean {
Expand Down
19 changes: 15 additions & 4 deletions frontend/functions/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
Loading