From 2a9fbec6f9c0fa585c2aa5b4c79dc2e13d09427e Mon Sep 17 00:00:00 2001 From: Abd-Standard Date: Wed, 29 Apr 2026 12:39:16 +0100 Subject: [PATCH] feat: secure issuer middleware with on-chain verification and caching --- backend/package-lock.json | 3 + backend/src/app.js | 4 + backend/src/indexer/poller.js | 25 +++++- backend/src/middleware/issuer.js | 26 +++++- backend/src/routes/auth.js | 2 +- backend/src/stellar/issuerCache.js | 50 ++++++++++++ backend/tests/app.test.js | 3 +- backend/tests/issuer-verification.test.js | 99 +++++++++++++++++++++++ 8 files changed, 206 insertions(+), 6 deletions(-) create mode 100644 backend/src/stellar/issuerCache.js create mode 100644 backend/tests/issuer-verification.test.js diff --git a/backend/package-lock.json b/backend/package-lock.json index 2d4d3b1..3af73e2 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -55,6 +55,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1608,6 +1609,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2443,6 +2445,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", diff --git a/backend/src/app.js b/backend/src/app.js index dfc697b..55a4220 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -10,6 +10,8 @@ const authRoutes = require('./routes/auth'); const vaccinationRoutes = require('./routes/vaccination'); const verifyRoutes = require('./routes/verify'); const adminRoutes = require('./routes/admin'); +const eventsRoutes = require('./routes/events'); + const app = express(); @@ -26,6 +28,8 @@ app.use('/auth', authRoutes); app.use('/vaccination', vaccinationRoutes); app.use('/verify', verifyRoutes); app.use('/admin', adminRoutes); +app.use('/events', eventsRoutes); + app.get('/health', (_req, res) => res.json({ status: 'ok' })); diff --git a/backend/src/indexer/poller.js b/backend/src/indexer/poller.js index 21432ac..6ef6606 100644 --- a/backend/src/indexer/poller.js +++ b/backend/src/indexer/poller.js @@ -6,6 +6,8 @@ */ const StellarSdk = require('@stellar/stellar-sdk'); const { upsertEvents, getLatestLedger } = require('./db'); +const { invalidateCache } = require('../stellar/issuerCache'); + const SOROBAN_RPC_URL = process.env.SOROBAN_RPC_URL || 'https://soroban-testnet.stellar.org'; const CONTRACT_ID = process.env.VACCINATIONS_CONTRACT_ID; @@ -34,7 +36,7 @@ function parseEvent(raw) { const event_type = TOPIC_MAP[topicStr]; if (!event_type) return null; - return { + const parsed = { id: raw.id, event_type, ledger: raw.ledger, @@ -44,6 +46,17 @@ function parseEvent(raw) { contract_id: raw.contractId ?? CONTRACT_ID, payload: raw.value ?? {}, }; + + // Extract issuer address from topics for IssuerAdded/Revoked events + if (parsed.event_type === 'IssuerAdded' || parsed.event_type === 'IssuerRevoked') { + const issuerScVal = raw.topic?.[1]; + if (issuerScVal) { + parsed.payload.issuer = StellarSdk.scValToNative(issuerScVal); + } + } + + return parsed; + } catch { return null; } @@ -75,6 +88,16 @@ async function poll() { if (process.env.NODE_ENV !== 'test') { console.log(`[indexer] Stored ${events.length} new event(s) from ledger ${startLedger}`); } + + // Invalidate issuer cache on relevant events + for (const e of events) { + if ((e.event_type === 'IssuerRevoked' || e.event_type === 'IssuerAdded') && e.payload.issuer) { + invalidateCache(e.payload.issuer); + if (process.env.NODE_ENV !== 'test') { + console.log(`[indexer] Invalidated cache for issuer: ${e.payload.issuer}`); + } + } + } } } catch (err) { if (process.env.NODE_ENV !== 'test') { diff --git a/backend/src/middleware/issuer.js b/backend/src/middleware/issuer.js index 7204fcc..9508975 100644 --- a/backend/src/middleware/issuer.js +++ b/backend/src/middleware/issuer.js @@ -1,12 +1,32 @@ +const { isAuthorizedIssuer } = require('../stellar/issuerCache'); +const logger = require('../logger'); + /** - * Require the authenticated user to have the 'issuer' role. + * Require the authenticated user to have the 'issuer' role AND + * verify their wallet is currently authorized on-chain. * Must be used after authMiddleware. */ -function issuerMiddleware(req, res, next) { +async function issuerMiddleware(req, res, next) { if (req.user?.role !== 'issuer') { return res.status(403).json({ error: 'Issuer role required' }); } - next(); + + const wallet = req.user.wallet || req.user.publicKey; + if (!wallet) { + return res.status(401).json({ error: 'Wallet address missing in token' }); + } + + try { + const isAuthorized = await isAuthorizedIssuer(wallet); + if (!isAuthorized) { + logger.warn('Unauthorized issuer attempt', { wallet }); + return res.status(403).json({ error: 'Issuer authorization revoked or not found on-chain' }); + } + next(); + } catch (error) { + logger.error('Error verifying issuer allowlist', { wallet, error: error.message }); + res.status(500).json({ error: 'Failed to verify issuer authorization' }); + } } module.exports = issuerMiddleware; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 1a65ee2..d15dc1c 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -47,7 +47,7 @@ router.post('/verify', validate(verifySchema), (req, res) => { const role = publicKey === process.env.ADMIN_PUBLIC_KEY ? 'issuer' : 'patient'; const token = jwt.sign( - { publicKey, role }, + { sub: publicKey, wallet: publicKey, publicKey, role }, process.env.JWT_SECRET, { expiresIn: '1h' } ); diff --git a/backend/src/stellar/issuerCache.js b/backend/src/stellar/issuerCache.js new file mode 100644 index 0000000..c69a865 --- /dev/null +++ b/backend/src/stellar/issuerCache.js @@ -0,0 +1,50 @@ +const StellarSdk = require('@stellar/stellar-sdk'); +const { simulateContract } = require('./soroban'); + +const cache = new Map(); +const TTL = 30 * 1000; // 30 seconds + +/** + * Check if a wallet address is an authorized issuer on-chain. + * Results are cached for 30 seconds. + * @param {string} wallet + * @returns {Promise} + */ +async function isAuthorizedIssuer(wallet) { + const now = Date.now(); + const cached = cache.get(wallet); + + if (cached && (now - cached.timestamp < TTL)) { + return cached.value; + } + + try { + const args = [StellarSdk.Address.fromString(wallet).toScVal()]; + const result = await simulateContract('is_issuer', args); + const isAuthorized = StellarSdk.scValToNative(result); + + cache.set(wallet, { + value: !!isAuthorized, + timestamp: now + }); + + return !!isAuthorized; + } catch (error) { + // If simulation fails, we don't cache to allow retry + throw error; + } +} + +/** + * Invalidate the cache for a specific wallet or clear the entire cache. + * @param {string} [wallet] + */ +function invalidateCache(wallet) { + if (wallet) { + cache.delete(wallet); + } else { + cache.clear(); + } +} + +module.exports = { isAuthorizedIssuer, invalidateCache }; diff --git a/backend/tests/app.test.js b/backend/tests/app.test.js index 75f28f3..ba3061e 100644 --- a/backend/tests/app.test.js +++ b/backend/tests/app.test.js @@ -13,7 +13,8 @@ describe('Auth routes', () => { it('POST /auth/sep10 requires public_key', async () => { const res = await request(app).post('/auth/sep10').send({}); expect(res.status).toBe(400); - expect(res.body.error).toMatch(/public_key/); + expect(res.body.error).toBe('Validation failed'); + expect(JSON.stringify(res.body.details)).toMatch(/public_key/); }); it('POST /auth/sep10 rejects invalid key', async () => { diff --git a/backend/tests/issuer-verification.test.js b/backend/tests/issuer-verification.test.js new file mode 100644 index 0000000..2d019a8 --- /dev/null +++ b/backend/tests/issuer-verification.test.js @@ -0,0 +1,99 @@ +const { isAuthorizedIssuer, invalidateCache } = require('../src/stellar/issuerCache'); +const issuerMiddleware = require('../src/middleware/issuer'); +const { simulateContract } = require('../src/stellar/soroban'); +const StellarSdk = require('@stellar/stellar-sdk'); + +jest.mock('../src/stellar/soroban'); + +describe('Issuer Verification & Caching', () => { + const wallet = 'GD6W6X66Z3ND66Z3D7S3Y3A6S2Z6Z2Z6Z2Z6Z2Z6Z2Z6Z2Z6Z2Z6Z2Z'; // Still invalid length + // Let's use a real one + const validWallet = 'GDE76K45763M6HEDT26F5D3M5C3U4U5M6X2Z6Z2Z6Z2Z6Z2Z6Z2Z6Z2Z'; // Still not valid + // I'll use a known valid testnet address format + const testWallet = 'GB7V7Z57Z57Z57Z57Z57Z57Z57Z57Z57Z57Z57Z57Z57Z57Z57Z57Z57'; // 56 chars + const realTestWallet = 'GB7V7Z57Z57Z57Z57Z57Z57Z57Z57Z57Z57Z57Z57Z57Z57Z57Z57Z57'; // Actually, let's just use any 56-char G... address + const gAddress = StellarSdk.Keypair.random().publicKey(); + + beforeEach(() => { + jest.clearAllMocks(); + invalidateCache(); // clear all + }); + + it('isAuthorizedIssuer calls simulateContract and caches result', async () => { + simulateContract.mockResolvedValue(StellarSdk.xdr.ScVal.scvBool(true)); + + const res1 = await isAuthorizedIssuer(gAddress); + expect(res1).toBe(true); + expect(simulateContract).toHaveBeenCalledTimes(1); + + const res2 = await isAuthorizedIssuer(gAddress); + expect(res2).toBe(true); + expect(simulateContract).toHaveBeenCalledTimes(1); + }); + + it('invalidateCache clears the cached value', async () => { + simulateContract.mockResolvedValue(StellarSdk.xdr.ScVal.scvBool(true)); + + await isAuthorizedIssuer(gAddress); + expect(simulateContract).toHaveBeenCalledTimes(1); + + invalidateCache(gAddress); + + await isAuthorizedIssuer(gAddress); + expect(simulateContract).toHaveBeenCalledTimes(2); + }); + + it('issuerMiddleware blocks unauthorized issuers', async () => { + simulateContract.mockResolvedValue(StellarSdk.xdr.ScVal.scvBool(false)); + + const req = { + user: { role: 'issuer', wallet: gAddress } + }; + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis() + }; + const next = jest.fn(); + + await issuerMiddleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ error: 'Issuer authorization revoked or not found on-chain' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('issuerMiddleware allows authorized issuers', async () => { + simulateContract.mockResolvedValue(StellarSdk.xdr.ScVal.scvBool(true)); + + const req = { + user: { role: 'issuer', wallet: gAddress } + }; + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis() + }; + const next = jest.fn(); + + await issuerMiddleware(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + it('issuerMiddleware blocks non-issuer role even if authorized on-chain', async () => { + const req = { + user: { role: 'patient', wallet: gAddress } + }; + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis() + }; + const next = jest.fn(); + + await issuerMiddleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ error: 'Issuer role required' }); + expect(next).not.toHaveBeenCalled(); + expect(simulateContract).not.toHaveBeenCalled(); + }); +});