From 35132c5de2ee6b238133e3d172f761c8c167e9bd Mon Sep 17 00:00:00 2001 From: mistakia <1823355+mistakia@users.noreply.github.com> Date: Sun, 7 Apr 2024 19:52:50 -0400 Subject: [PATCH 1/2] feat: add api endpoint for signed message (close #105) --- api/routes/auth/index.mjs | 11 + api/routes/auth/message.mjs | 158 +++++++++++ api/routes/{auth.mjs => auth/register.mjs} | 17 +- api/routes/index.mjs | 2 +- api/server.mjs | 10 +- common/index.mjs | 5 + common/sign-nano-community-message.mjs | 33 +++ ...erify-nano-community-message-signature.mjs | 36 +++ constants.mjs | 1 + db/schema.sql | 4 +- package.json | 2 + test/auth.message.test.mjs | 262 ++++++++++++++++++ .../{auth.test.mjs => auth.register.test.mjs} | 2 +- yarn.lock | 51 ++++ 14 files changed, 581 insertions(+), 13 deletions(-) create mode 100644 api/routes/auth/index.mjs create mode 100644 api/routes/auth/message.mjs rename api/routes/{auth.mjs => auth/register.mjs} (86%) create mode 100644 common/sign-nano-community-message.mjs create mode 100644 common/verify-nano-community-message-signature.mjs create mode 100644 test/auth.message.test.mjs rename test/{auth.test.mjs => auth.register.test.mjs} (99%) diff --git a/api/routes/auth/index.mjs b/api/routes/auth/index.mjs new file mode 100644 index 00000000..dd71ae91 --- /dev/null +++ b/api/routes/auth/index.mjs @@ -0,0 +1,11 @@ +import express from 'express' + +import register from './register.mjs' +import message from './message.mjs' + +const router = express.Router() + +router.use('/register', register) +router.use('/message', message) + +export default router diff --git a/api/routes/auth/message.mjs b/api/routes/auth/message.mjs new file mode 100644 index 00000000..381a5239 --- /dev/null +++ b/api/routes/auth/message.mjs @@ -0,0 +1,158 @@ +import express from 'express' +import { tools } from 'nanocurrency-web' + +import { rpc, verify_nano_community_message_signature } from '#common' +import { + ACCOUNT_TRACKING_MINIMUM_BALANCE, + REPRESENTATIVE_TRACKING_MINIMUM_VOTING_WEIGHT +} from '#constants' + +const router = express.Router() + +router.post('/?', async (req, res) => { + const { logger, db } = req.app.locals + try { + const { message } = req.body + + const { + version, + + entry_id, + chain_id, + entry_clock, + chain_clock, + + public_key, + operation, + content, + tags, + + references, + + created_at, + + signature + } = message + + if (version !== 1) { + res.status(400).send('Invalid message version') + } + + // entry_id must be null or 32 byte hash + if (entry_id && entry_id.length !== 64) { + res.status(400).send('Invalid entry_id') + } + + // chain_id must be null or 32 byte hash + if (chain_id && chain_id.length !== 64) { + res.status(400).send('Invalid chain_id') + } + + // entry_clock must be null or positive integer + if (entry_clock && entry_clock < 0) { + res.status(400).send('Invalid entry_clock') + } + + // chain_clock must be null or positive integer + if (chain_clock && chain_clock < 0) { + res.status(400).send('Invalid chain_clock') + } + + // public_key must be 32 byte hash + if (public_key.length !== 64) { + res.status(400).send('Invalid public_key') + } + + // operation must be SET or DELETE + if (operation !== 'SET' && operation !== 'DELETE') { + res.status(400).send('Invalid operation') + } + + // content must be null or string + if (content && typeof content !== 'string') { + res.status(400).send('Invalid content') + } + + // tags must be null or array of strings + if (tags && !Array.isArray(tags)) { + res.status(400).send('Invalid tags') + } + + // references must be null or array of strings + if (references && !Array.isArray(references)) { + res.status(400).send('Invalid references') + } + + // created_at must be null or positive integer + if (created_at && created_at < 0) { + res.status(400).send('Invalid created_at') + } + + // signature must be 64 byte hash + if (signature.length !== 128) { + res.status(400).send('Invalid signature') + } + + // validate signature + const is_valid_signature = verify_nano_community_message_signature({ + entry_id, + chain_id, + entry_clock, + chain_clock, + public_key, + operation, + content, + tags, + references, + created_at, + signature + }) + if (!is_valid_signature) { + res.status(400).send('Invalid signature') + } + + // public_key can be a linked keypair or an existing nano account + + const linked_accounts = await db('user_addresses') + .select('user_addresses.address') + .leftJoin('users', 'users.id', '=', 'user_addresses.user_id') + .where({ public_key }) + + const nano_account = tools.publicKeyToAddress(public_key) + + const all_accounts = [ + ...linked_accounts.map((row) => row.address), + nano_account + ] + + const accounts_info = [] + for (const account of all_accounts) { + const account_info = await rpc.accountInfo({ account }) + if (account_info) { + accounts_info.push(account_info) + } + } + + // check if any of the accounts have a balance beyond the tracking threshold + const has_balance = accounts_info.some( + (account_info) => account_info.balance > ACCOUNT_TRACKING_MINIMUM_BALANCE + ) + + // check if any of the accounts have weight beyond the tracking threshold + const has_weight = accounts_info.some( + (account_info) => + account_info.weight > REPRESENTATIVE_TRACKING_MINIMUM_VOTING_WEIGHT + ) + + if (has_balance || has_weight) { + // TODO save the message to the database + } + + res.status(200).send(message) + } catch (error) { + logger.error(error) + res.status(500).send('Internal server error') + } +}) + +export default router diff --git a/api/routes/auth.mjs b/api/routes/auth/register.mjs similarity index 86% rename from api/routes/auth.mjs rename to api/routes/auth/register.mjs index 93eeaecb..687ab101 100644 --- a/api/routes/auth.mjs +++ b/api/routes/auth/register.mjs @@ -5,9 +5,10 @@ import { tools } from 'nanocurrency-web' const router = express.Router() const USERNAME_RE = /^[A-Za-z][a-zA-Z0-9_]+$/ -router.post('/register', async (req, res) => { +router.post('/?', async (req, res) => { const { logger, db } = req.app.locals try { + // public_key is a linked keypair for the given address const required = ['public_key', 'address', 'signature', 'username'] for (const prop of required) { if (!req.body[prop]) { @@ -48,9 +49,9 @@ router.post('/register', async (req, res) => { address = address.replace('xrb_', 'nano_') const exists = await db('user_addresses').where({ address }) - let accountId = exists.length ? exists[0].account_id : null + let user_id = exists.length ? exists[0].user_id : null - if (!accountId) { + if (!user_id) { const result = await db('users') .insert({ public_key, @@ -59,16 +60,16 @@ router.post('/register', async (req, res) => { }) .onConflict() .merge() - accountId = result[0] + user_id = result[0] } else { await db('users') .update({ last_visit: Math.round(Date.now() / 1000), username }) - .where({ id: accountId }) + .where({ id: user_id }) } await db('user_addresses') .insert({ - account_id: accountId, + user_id, address, signature }) @@ -76,14 +77,14 @@ router.post('/register', async (req, res) => { .merge() return res.send({ - accountId, + user_id, address, username, signature }) } catch (error) { logger(error) - res.status(500).send({ error: error.toString() }) + res.status(500).send('Internal server error') } }) diff --git a/api/routes/index.mjs b/api/routes/index.mjs index edd6db31..4a2c9d82 100644 --- a/api/routes/index.mjs +++ b/api/routes/index.mjs @@ -1,4 +1,4 @@ -export { default as auth } from './auth.mjs' +export { default as auth } from './auth/index.mjs' export { default as accounts } from './accounts/index.mjs' export { default as blocks } from './blocks.mjs' export { default as posts } from './posts.mjs' diff --git a/api/server.mjs b/api/server.mjs index 84c9e633..893d56a5 100644 --- a/api/server.mjs +++ b/api/server.mjs @@ -18,6 +18,7 @@ import serveStatic from 'serve-static' import cors from 'cors' import favicon from 'express-favicon' import robots from 'express-robots-txt' +import { slowDown } from 'express-slow-down' import config from '#config' import * as routes from '#api/routes/index.mjs' @@ -33,6 +34,12 @@ const logger = debug('api') const defaults = {} const options = extend(defaults, config) const IS_DEV = process.env.NODE_ENV === 'development' +const speedLimiter = slowDown({ + windowMs: 10 * 60 * 1000, // 10 minutes + delayAfter: 50, // allow 50 requests per 10 minutes, then... + delayMs: 500, // begin adding 500ms of delay per request above 50: + maxDelayMs: 20000 // maximum delay of 20 seconds +}) const api = express() @@ -88,13 +95,14 @@ api.use('/api/nanodb-experimental', routes.nanodb_experimental) api.use('/api/posts', routes.posts) api.use('/api/network', routes.network) api.use('/api/github', routes.github) -api.use('/api/auth', routes.auth) +api.use('/api/auth', speedLimiter, routes.auth) api.use('/api/accounts', routes.accounts) api.use('/api/blocks', routes.blocks) api.use('/api/representatives', routes.representatives) api.use('/api/weight', routes.weight) const docsPath = path.join(__dirname, '..', 'docs') + api.use('/api/docs', serveStatic(docsPath)) api.get('/api/docs/*', (req, res) => { res.status(404).send('Not found') diff --git a/common/index.mjs b/common/index.mjs index ed4db905..77da66bc 100644 --- a/common/index.mjs +++ b/common/index.mjs @@ -10,6 +10,8 @@ export * as cloudflare from './cloudflare.mjs' export { request } export { default as convertToCSV } from './convert-to-csv.mjs' export { default as read_csv } from './read-csv.mjs' +export { default as verify_nano_community_message_signature } from './verify-nano-community-message-signature.mjs' +export { default as sign_nano_community_message } from './sign-nano-community-message.mjs' const POST = (data) => ({ method: 'POST', @@ -83,6 +85,9 @@ const rpcRequest = async ( { url, trusted = false, timeout = 20000 } = {} ) => { if (url) { + console.log({ + url + }) const options = { url, timeout, ...POST(data) } return request(options) } diff --git a/common/sign-nano-community-message.mjs b/common/sign-nano-community-message.mjs new file mode 100644 index 00000000..a050f311 --- /dev/null +++ b/common/sign-nano-community-message.mjs @@ -0,0 +1,33 @@ +import ed25519 from '@trashman/ed25519-blake2b' + +export default function sign_nano_community_message(message, private_key) { + const { + entry_id, + chain_id, + entry_clock, + chain_clock, + public_key, + operation, + content, + tags, + references, + created_at + } = message + + const data = Buffer.from([ + entry_id, + chain_id, + entry_clock, + chain_clock, + public_key, + operation, + content, + tags, + references, + created_at + ]) + + const message_hash = ed25519.hash(data) + + return ed25519.sign(message_hash, private_key, public_key) +} diff --git a/common/verify-nano-community-message-signature.mjs b/common/verify-nano-community-message-signature.mjs new file mode 100644 index 00000000..7337bd6d --- /dev/null +++ b/common/verify-nano-community-message-signature.mjs @@ -0,0 +1,36 @@ +import ed25519 from '@trashman/ed25519-blake2b' + +export default function verify_nano_community_message_signature({ + entry_id, + chain_id, + entry_clock, + chain_clock, + + public_key, + operation, + content, + tags, + + references, + + created_at, + + signature +}) { + const data = Buffer.from([ + entry_id, + chain_id, + entry_clock, + chain_clock, + public_key, + operation, + content, + tags, + references, + created_at + ]) + + const message_hash = ed25519.hash(data) + + return ed25519.verify(signature, message_hash, public_key) +} diff --git a/constants.mjs b/constants.mjs index 85284b05..e209ef21 100644 --- a/constants.mjs +++ b/constants.mjs @@ -4,3 +4,4 @@ export const repo = 'mistakia/nano-community' export const BURN_ACCOUNT = 'nano_1111111111111111111111111111111111111111111111111111hifc8npp' export const REPRESENTATIVE_TRACKING_MINIMUM_VOTING_WEIGHT = 10000000000000000000000000000000000 +export const ACCOUNT_TRACKING_MINIMUM_BALANCE = 100000000000000000000000000000 diff --git a/db/schema.sql b/db/schema.sql index e9278ef6..4c975e5e 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -526,10 +526,10 @@ CREATE TABLE `users` ( DROP TABLE IF EXISTS `user_addresses`; CREATE TABLE `user_addresses` ( - `account_id` int(11) NOT NULL, + `user_id` int(11) NOT NULL, `address` char(65) NOT NULL, `signature` varchar(255) NOT NULL, - KEY (`account_id`), + KEY (`user_id`), UNIQUE KEY `address` (`address`) ) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/package.json b/package.json index 6800aca5..d7b0f83b 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "dependencies": { "@babel/polyfill": "^7.12.1", "@octokit/graphql": "^4.8.0", + "@trashman/ed25519-blake2b": "^0.0.6", "bignumber.js": "^9.1.1", "body-parser": "^1.20.2", "compression": "^1.7.4", @@ -96,6 +97,7 @@ "express-favicon": "^2.0.4", "express-jwt": "^8.4.1", "express-robots-txt": "^1.0.0", + "express-slow-down": "^2.0.1", "fetch-cheerio-object": "^1.3.0", "front-matter": "^4.0.2", "fs-extra": "^11.1.1", diff --git a/test/auth.message.test.mjs b/test/auth.message.test.mjs new file mode 100644 index 00000000..8fb8e306 --- /dev/null +++ b/test/auth.message.test.mjs @@ -0,0 +1,262 @@ +/* global describe before it */ +import chai from 'chai' +import crypto from 'crypto' +import chaiHTTP from 'chai-http' +import ed25519 from '@trashman/ed25519-blake2b' + +import server from '#api/server.mjs' +import { sign_nano_community_message } from '#common' +import { mochaGlobalSetup } from './global.mjs' + +process.env.NODE_ENV = 'test' +// chai.should() +chai.use(chaiHTTP) +const expect = chai.expect + +describe('API /auth/message', function () { + before(mochaGlobalSetup) + + describe('POST /api/auth/message', () => { + it('should return 200 for valid message', async () => { + const private_key = crypto.randomBytes(32) + const public_key = ed25519.publicKey(private_key) + + const message = { + version: 1, + public_key: public_key.toString('hex'), + operation: 'SET', + content: 'SET_TEST_CONTENT', + tags: [], + references: [], + created_at: Math.floor(Date.now() / 1000) + } + + const signature = sign_nano_community_message(message, private_key) + + const response = await chai + .request(server) + .post('/api/auth/message') + .send({ + message: { + ...message, + signature: signature.toString('hex') + } + }) + + expect(response).to.have.status(200) + }) + }) + + describe('errors', () => { + it('should return 400 for invalid message version', async () => { + const response = await chai + .request(server) + .post('/api/auth/message') + .send({ + message: { + version: 2, // invalid version + chain_id: 'a'.repeat(64), // valid chain_id length + public_key: 'a'.repeat(64), // valid public_key length + operation: 'SET', + signature: 'a'.repeat(128) // valid signature length + } + }) + expect(response).to.have.status(400) + expect(response.text).to.include('Invalid message version') + }) + + it('should return 400 for invalid entry_id length', async () => { + const response = await chai + .request(server) + .post('/api/auth/message') + .send({ + message: { + entry_id: '123', // invalid entry_id length + version: 1, // valid version + chain_id: 'a'.repeat(64), // valid chain_id length + public_key: 'a'.repeat(64), // valid public_key length + operation: 'SET', + signature: 'a'.repeat(128) // valid signature length + } + }) + expect(response).to.have.status(400) + expect(response.text).to.include('Invalid entry_id') + }) + + it('should return 400 for invalid chain_id length', async () => { + const response = await chai + .request(server) + .post('/api/auth/message') + .send({ + message: { + version: 1, + chain_id: '123', // invalid chain_id length + public_key: 'a'.repeat(64), // valid public_key length + operation: 'SET', + signature: 'a'.repeat(128) // valid signature length + } + }) + expect(response).to.have.status(400) + expect(response.text).to.include('Invalid chain_id') + }) + + it('should return 400 for negative entry_clock', async () => { + const response = await chai + .request(server) + .post('/api/auth/message') + .send({ + message: { + version: 1, + entry_clock: -1, // negative entry_clock + chain_id: 'a'.repeat(64), // valid chain_id length + public_key: 'a'.repeat(64), // valid public_key length + operation: 'SET', + signature: 'a'.repeat(128) // valid signature length + } + }) + expect(response).to.have.status(400) + expect(response.text).to.include('Invalid entry_clock') + }) + + it('should return 400 for negative chain_clock', async () => { + const response = await chai + .request(server) + .post('/api/auth/message') + .send({ + message: { + version: 1, + chain_clock: -1, // negative chain_clock + chain_id: 'a'.repeat(64), // valid chain_id length + public_key: 'a'.repeat(64), // valid public_key length + operation: 'SET', + signature: 'a'.repeat(128) // valid signature length + } + }) + expect(response).to.have.status(400) + expect(response.text).to.include('Invalid chain_clock') + }) + + it('should return 400 for invalid public_key length', async () => { + const response = await chai + .request(server) + .post('/api/auth/message') + .send({ + message: { + version: 1, + public_key: '123', // invalid public_key length + chain_id: 'a'.repeat(64), // valid chain_id length + operation: 'SET', + signature: 'a'.repeat(128) // valid signature length + } + }) + expect(response).to.have.status(400) + expect(response.text).to.include('Invalid public_key') + }) + + it('should return 400 for invalid operation', async () => { + const response = await chai + .request(server) + .post('/api/auth/message') + .send({ + message: { + version: 1, + operation: 'INVALID', // invalid operation + chain_id: 'a'.repeat(64), // valid chain_id length + public_key: 'a'.repeat(64), // valid public_key length + signature: 'a'.repeat(128) // valid signature length + } + }) + expect(response).to.have.status(400) + expect(response.text).to.include('Invalid operation') + }) + + it('should return 400 for non-string content', async () => { + const response = await chai + .request(server) + .post('/api/auth/message') + .send({ + message: { + version: 1, + content: 123, // non-string content + chain_id: 'a'.repeat(64), // valid chain_id length + public_key: 'a'.repeat(64), // valid public_key length + operation: 'SET', + signature: 'a'.repeat(128) // valid signature length + } + }) + expect(response).to.have.status(400) + expect(response.text).to.include('Invalid content') + }) + + it('should return 400 for invalid tags type', async () => { + const response = await chai + .request(server) + .post('/api/auth/message') + .send({ + message: { + version: 1, + tags: 'not an array', // invalid tags type + chain_id: 'a'.repeat(64), // valid chain_id length + public_key: 'a'.repeat(64), // valid public_key length + operation: 'SET', + signature: 'a'.repeat(128) // valid signature length + } + }) + expect(response).to.have.status(400) + expect(response.text).to.include('Invalid tags') + }) + + it('should return 400 for invalid references type', async () => { + const response = await chai + .request(server) + .post('/api/auth/message') + .send({ + message: { + version: 1, + references: 'not an array', // invalid references type + chain_id: 'a'.repeat(64), // valid chain_id length + public_key: 'a'.repeat(64), // valid public_key length + operation: 'SET', + signature: 'a'.repeat(128) // valid signature length + } + }) + expect(response).to.have.status(400) + expect(response.text).to.include('Invalid references') + }) + + it('should return 400 for negative created_at', async () => { + const response = await chai + .request(server) + .post('/api/auth/message') + .send({ + message: { + version: 1, + created_at: -1, // negative created_at + chain_id: 'a'.repeat(64), // valid chain_id length + public_key: 'a'.repeat(64), // valid public_key length + operation: 'SET', + signature: 'a'.repeat(128) // valid signature length + } + }) + expect(response).to.have.status(400) + expect(response.text).to.include('Invalid created_at') + }) + + it('should return 400 for invalid signature length', async () => { + const response = await chai + .request(server) + .post('/api/auth/message') + .send({ + message: { + version: 1, + signature: '123', // invalid signature length + chain_id: 'a'.repeat(64), // valid chain_id length + public_key: 'a'.repeat(64), // valid public_key length + operation: 'SET' + } + }) + expect(response).to.have.status(400) + expect(response.text).to.include('Invalid signature') + }) + }) +}) diff --git a/test/auth.test.mjs b/test/auth.register.test.mjs similarity index 99% rename from test/auth.test.mjs rename to test/auth.register.test.mjs index 85e520a3..4a7a3024 100644 --- a/test/auth.test.mjs +++ b/test/auth.register.test.mjs @@ -13,7 +13,7 @@ process.env.NODE_ENV = 'test' chai.use(chaiHTTP) const expect = chai.expect -describe('API /auth', () => { +describe('API /auth/register', () => { before(mochaGlobalSetup) describe('errors', () => { diff --git a/yarn.lock b/yarn.lock index a7f5527b..5981bd04 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4107,6 +4107,17 @@ __metadata: languageName: node linkType: hard +"@trashman/ed25519-blake2b@npm:^0.0.6": + version: 0.0.6 + resolution: "@trashman/ed25519-blake2b@npm:0.0.6" + dependencies: + napi-macros: 2.0.0 + node-gyp: latest + node-gyp-build: 4.4.0 + checksum: 8ad5da41d1581f3502127727aa8f1c94259380a52de1b974567f0df785186f9b7355f2cb9ad45affc072e361aa8ce090d2c522e5383d4bb6fe4de7d9eacd603a + languageName: node + linkType: hard + "@types/body-parser@npm:*": version: 1.19.5 resolution: "@types/body-parser@npm:1.19.5" @@ -8683,6 +8694,15 @@ __metadata: languageName: node linkType: hard +"express-rate-limit@npm:7": + version: 7.2.0 + resolution: "express-rate-limit@npm:7.2.0" + peerDependencies: + express: 4 || 5 || ^5.0.0-beta.1 + checksum: 6adc3e06d430e91cf8ef8da23107466ffd9193b5680b03bc12702894cab0adcbb980634602b839b3a1444620f2379707098af3a4dcc38c909e89214abaf6c1bf + languageName: node + linkType: hard + "express-robots-txt@npm:^1.0.0": version: 1.0.0 resolution: "express-robots-txt@npm:1.0.0" @@ -8692,6 +8712,17 @@ __metadata: languageName: node linkType: hard +"express-slow-down@npm:^2.0.1": + version: 2.0.1 + resolution: "express-slow-down@npm:2.0.1" + dependencies: + express-rate-limit: 7 + peerDependencies: + express: ">= 4" + checksum: bb59970b6321ef45affb5d1ba0331d065f8430ccae7e36fd67d3a8696634fe20a878420cd5659e210255cea0b6b1b502eca02f8d7a195695fa00c36cd258799e + languageName: node + linkType: hard + "express-unless@npm:^2.1.3": version: 2.1.3 resolution: "express-unless@npm:2.1.3" @@ -13601,6 +13632,13 @@ __metadata: languageName: node linkType: hard +"napi-macros@npm:2.0.0": + version: 2.0.0 + resolution: "napi-macros@npm:2.0.0" + checksum: 30384819386977c1f82034757014163fa60ab3c5a538094f778d38788bebb52534966279956f796a92ea771c7f8ae072b975df65de910d051ffbdc927f62320c + languageName: node + linkType: hard + "native-abort-controller@npm:^1.0.3": version: 1.0.4 resolution: "native-abort-controller@npm:1.0.4" @@ -13759,6 +13797,17 @@ __metadata: languageName: node linkType: hard +"node-gyp-build@npm:4.4.0": + version: 4.4.0 + resolution: "node-gyp-build@npm:4.4.0" + bin: + node-gyp-build: bin.js + node-gyp-build-optional: optional.js + node-gyp-build-test: build-test.js + checksum: 972a059f960253d254e0b23ce10f54c8982236fc0edcab85166d0b7f87443b2ce98391c877cfb2f6eeafcf03c538c5f4dd3e0bfff03828eb48634f58f4c64343 + languageName: node + linkType: hard + "node-gyp@npm:latest": version: 9.0.0 resolution: "node-gyp@npm:9.0.0" @@ -16022,6 +16071,7 @@ __metadata: "@mui/styles": ^5.15.11 "@mui/x-data-grid": ^6.19.5 "@octokit/graphql": ^4.8.0 + "@trashman/ed25519-blake2b": ^0.0.6 babel-loader: 9.1.3 babel-plugin-module-resolver: ^5.0.0 bignumber.js: ^9.1.1 @@ -16058,6 +16108,7 @@ __metadata: express-favicon: ^2.0.4 express-jwt: ^8.4.1 express-robots-txt: ^1.0.0 + express-slow-down: ^2.0.1 fetch-cheerio-object: ^1.3.0 file-loader: ^6.2.0 front-matter: ^4.0.2 From 71b06132e04910be97c795c32a88e27ae8cf489e Mon Sep 17 00:00:00 2001 From: mistakia <1823355+mistakia@users.noreply.github.com> Date: Tue, 9 Apr 2024 00:11:35 -0400 Subject: [PATCH 2/2] chore: add tests --- api/routes/auth/message.mjs | 82 ++++++++++---- api/server.mjs | 2 +- config.test.js | 16 +-- constants.mjs | 5 +- db/schema.sql | 22 ++++ package.json | 1 + scripts/generate-nano-reps.mjs | 4 +- test/auth.message.test.mjs | 201 ++++++++++++++++++++++++++++++++- yarn.lock | 26 +++++ 9 files changed, 322 insertions(+), 37 deletions(-) diff --git a/api/routes/auth/message.mjs b/api/routes/auth/message.mjs index 381a5239..6cdb68f2 100644 --- a/api/routes/auth/message.mjs +++ b/api/routes/auth/message.mjs @@ -1,5 +1,6 @@ import express from 'express' import { tools } from 'nanocurrency-web' +import BigNumber from 'bignumber.js' import { rpc, verify_nano_community_message_signature } from '#common' import { @@ -35,62 +36,62 @@ router.post('/?', async (req, res) => { } = message if (version !== 1) { - res.status(400).send('Invalid message version') + return res.status(400).send('Invalid message version') } // entry_id must be null or 32 byte hash if (entry_id && entry_id.length !== 64) { - res.status(400).send('Invalid entry_id') + return res.status(400).send('Invalid entry_id') } // chain_id must be null or 32 byte hash if (chain_id && chain_id.length !== 64) { - res.status(400).send('Invalid chain_id') + return res.status(400).send('Invalid chain_id') } // entry_clock must be null or positive integer if (entry_clock && entry_clock < 0) { - res.status(400).send('Invalid entry_clock') + return res.status(400).send('Invalid entry_clock') } // chain_clock must be null or positive integer if (chain_clock && chain_clock < 0) { - res.status(400).send('Invalid chain_clock') + return res.status(400).send('Invalid chain_clock') } // public_key must be 32 byte hash if (public_key.length !== 64) { - res.status(400).send('Invalid public_key') + return res.status(400).send('Invalid public_key') } // operation must be SET or DELETE if (operation !== 'SET' && operation !== 'DELETE') { - res.status(400).send('Invalid operation') + return res.status(400).send('Invalid operation') } // content must be null or string if (content && typeof content !== 'string') { - res.status(400).send('Invalid content') + return res.status(400).send('Invalid content') } // tags must be null or array of strings if (tags && !Array.isArray(tags)) { - res.status(400).send('Invalid tags') + return res.status(400).send('Invalid tags') } // references must be null or array of strings if (references && !Array.isArray(references)) { - res.status(400).send('Invalid references') + return res.status(400).send('Invalid references') } // created_at must be null or positive integer if (created_at && created_at < 0) { - res.status(400).send('Invalid created_at') + return res.status(400).send('Invalid created_at') } // signature must be 64 byte hash if (signature.length !== 128) { - res.status(400).send('Invalid signature') + return res.status(400).send('Invalid signature') } // validate signature @@ -108,7 +109,7 @@ router.post('/?', async (req, res) => { signature }) if (!is_valid_signature) { - res.status(400).send('Invalid signature') + return res.status(400).send('Invalid signature') } // public_key can be a linked keypair or an existing nano account @@ -134,23 +135,62 @@ router.post('/?', async (req, res) => { } // check if any of the accounts have a balance beyond the tracking threshold - const has_balance = accounts_info.some( - (account_info) => account_info.balance > ACCOUNT_TRACKING_MINIMUM_BALANCE + const has_balance = accounts_info.some((account_info) => + new BigNumber(account_info.balance).gte(ACCOUNT_TRACKING_MINIMUM_BALANCE) ) // check if any of the accounts have weight beyond the tracking threshold - const has_weight = accounts_info.some( - (account_info) => - account_info.weight > REPRESENTATIVE_TRACKING_MINIMUM_VOTING_WEIGHT + const has_weight = accounts_info.some((account_info) => + new BigNumber(account_info.weight).gte( + REPRESENTATIVE_TRACKING_MINIMUM_VOTING_WEIGHT + ) ) if (has_balance || has_weight) { - // TODO save the message to the database + await db('nano_community_messages') + .insert({ + version, + + entry_id, + chain_id, + entry_clock, + chain_clock, + + public_key, + operation, + content, + tags: tags.length > 0 ? tags.join(', ') : null, + + signature, + + references: references.length > 0 ? references.join(', ') : null, + + created_at + }) + .onConflict() + .merge() } - res.status(200).send(message) + res.status(200).send({ + version, + + entry_id, + chain_id, + entry_clock, + chain_clock, + + public_key, + operation, + content, + tags, + + references, + + created_at + }) } catch (error) { - logger.error(error) + console.log(error) + logger(error) res.status(500).send('Internal server error') } }) diff --git a/api/server.mjs b/api/server.mjs index 893d56a5..cf459b97 100644 --- a/api/server.mjs +++ b/api/server.mjs @@ -37,7 +37,7 @@ const IS_DEV = process.env.NODE_ENV === 'development' const speedLimiter = slowDown({ windowMs: 10 * 60 * 1000, // 10 minutes delayAfter: 50, // allow 50 requests per 10 minutes, then... - delayMs: 500, // begin adding 500ms of delay per request above 50: + delayMs: (hits, req) => (hits - req.slowDown.limit) * 500, // begin adding 500ms of delay per request above 50: maxDelayMs: 20000 // maximum delay of 20 seconds }) diff --git a/config.test.js b/config.test.js index 9e62040d..8a1e66b1 100644 --- a/config.test.js +++ b/config.test.js @@ -23,11 +23,9 @@ module.exports = { host: '127.0.0.1', user: 'root', database: 'nano_test', - multipleStatements: true - }, - pool: { - min: 2, - max: 20 + multipleStatements: true, + decimalNumbers: true, + charset: 'utf8mb4' } }, @@ -37,7 +35,9 @@ module.exports = { host: '127.0.0.1', user: 'root', database: 'nano_storage_test', - multipleStatements: true + multipleStatements: true, + decimalNumbers: true, + charset: 'utf8mb4' }, pool: { min: 2, @@ -47,8 +47,8 @@ module.exports = { nanodbAPI: '', nanodbAPI_experimental: '', - trustedAddresses: [], - rpcAddresses: [], + trustedAddresses: ['http://nano:7076'], + rpcAddresses: ['http://nano:7076'], cloudflare: { zone_id: '', diff --git a/constants.mjs b/constants.mjs index e209ef21..3a59ee39 100644 --- a/constants.mjs +++ b/constants.mjs @@ -3,5 +3,6 @@ export const discordNanoTrade = '403628195548495882' export const repo = 'mistakia/nano-community' export const BURN_ACCOUNT = 'nano_1111111111111111111111111111111111111111111111111111hifc8npp' -export const REPRESENTATIVE_TRACKING_MINIMUM_VOTING_WEIGHT = 10000000000000000000000000000000000 -export const ACCOUNT_TRACKING_MINIMUM_BALANCE = 100000000000000000000000000000 +export const REPRESENTATIVE_TRACKING_MINIMUM_VOTING_WEIGHT = + 10000000000000000000000000000000000n +export const ACCOUNT_TRACKING_MINIMUM_BALANCE = 100000000000000000000000000000n diff --git a/db/schema.sql b/db/schema.sql index 4c975e5e..e9e53d9b 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -637,3 +637,25 @@ CREATE TABLE `github_issue_labels` ( `label_color` varchar(255) CHARACTER SET utf8 NOT NULL, PRIMARY KEY (`issue_id`, `label_id`) ) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- -------------------------------------------------------- +-- +-- Table structure for table `nano_community_messages` +-- + +DROP TABLE IF EXISTS `nano_community_messages`; + +CREATE TABLE `nano_community_messages` ( + `version` tinyint(1) NOT NULL, + `entry_id` varchar(64) DEFAULT NULL, + `chain_id` varchar(64) DEFAULT NULL, + `entry_clock` int(10) unsigned DEFAULT NULL, + `chain_clock` int(10) unsigned DEFAULT NULL, + `public_key` varchar(64) NOT NULL, + `operation` varchar(10) NOT NULL, + `content` text CHARACTER SET utf8mb4 DEFAULT NULL, + `tags` text CHARACTER SET utf8mb4 DEFAULT NULL, + `references` text CHARACTER SET utf8mb4 DEFAULT NULL, + `created_at` int(11) unsigned NOT NULL, + `signature` varchar(128) NOT NULL +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci; \ No newline at end of file diff --git a/package.json b/package.json index d7b0f83b..09ba4e31 100644 --- a/package.json +++ b/package.json @@ -172,6 +172,7 @@ "markdown-it-anchor": "^8.6.7", "mocha": "^10.4.0", "nib": "^1.2.0", + "nock": "^13.5.4", "percentile": "^1.6.0", "prettier": "2.8.8", "react-immutable-proptypes": "^2.2.0", diff --git a/scripts/generate-nano-reps.mjs b/scripts/generate-nano-reps.mjs index e1957bcb..175e68b4 100644 --- a/scripts/generate-nano-reps.mjs +++ b/scripts/generate-nano-reps.mjs @@ -1,4 +1,5 @@ import debug from 'debug' +import BigNumber from 'bignumber.js' // import yargs from 'yargs' // import { hideBin } from 'yargs/helpers' import diff from 'deep-diff' @@ -134,8 +135,9 @@ const generate_nano_reps = async () => { for (const account in db_reps_index) { if ( !results_index[account] && - db_reps_index[account].weight > + new BigNumber(db_reps_index[account].weight).gte( REPRESENTATIVE_TRACKING_MINIMUM_VOTING_WEIGHT + ) ) { const { weight, diff --git a/test/auth.message.test.mjs b/test/auth.message.test.mjs index 8fb8e306..860c19e0 100644 --- a/test/auth.message.test.mjs +++ b/test/auth.message.test.mjs @@ -1,12 +1,17 @@ /* global describe before it */ import chai from 'chai' -import crypto from 'crypto' import chaiHTTP from 'chai-http' import ed25519 from '@trashman/ed25519-blake2b' +import nock from 'nock' import server from '#api/server.mjs' import { sign_nano_community_message } from '#common' import { mochaGlobalSetup } from './global.mjs' +import db from '#db' +import { + REPRESENTATIVE_TRACKING_MINIMUM_VOTING_WEIGHT, + ACCOUNT_TRACKING_MINIMUM_BALANCE +} from '#constants' process.env.NODE_ENV = 'test' // chai.should() @@ -14,18 +19,23 @@ chai.use(chaiHTTP) const expect = chai.expect describe('API /auth/message', function () { + this.timeout(10000) before(mochaGlobalSetup) describe('POST /api/auth/message', () => { - it('should return 200 for valid message', async () => { - const private_key = crypto.randomBytes(32) + it('should save message to database for nano account above balance threshold', async () => { + const private_key = Buffer.from( + '0000000000000000000000000000000000000000000000000000000000000000', + 'hex' + ) const public_key = ed25519.publicKey(private_key) const message = { version: 1, public_key: public_key.toString('hex'), operation: 'SET', - content: 'SET_TEST_CONTENT', + content: + 'should save message to database for nano account above balance threshold', tags: [], references: [], created_at: Math.floor(Date.now() / 1000) @@ -33,6 +43,13 @@ describe('API /auth/message', function () { const signature = sign_nano_community_message(message, private_key) + // Mocking the rpc request to simulate an account above the balance threshold + nock('http://nano:7076') + .post('/', (body) => body.action === 'account_info') + .reply(200, { + balance: String(ACCOUNT_TRACKING_MINIMUM_BALANCE) + }) + const response = await chai .request(server) .post('/api/auth/message') @@ -44,6 +61,80 @@ describe('API /auth/message', function () { }) expect(response).to.have.status(200) + + const saved_message = await db('nano_community_messages') + .where({ + public_key: message.public_key, + created_at: message.created_at, + content: message.content + }) + .first() + + // eslint-disable-next-line no-unused-expressions + expect(saved_message).to.exist + expect(saved_message.content).to.equal(message.content) + expect(saved_message.operation).to.equal(message.operation) + expect(saved_message.version).to.equal(message.version) + expect(saved_message.public_key).to.equal(message.public_key) + expect(saved_message.signature).to.equal(signature.toString('hex')) + expect(saved_message.created_at).to.equal(message.created_at) + }) + + it('should save message to database for nano representative above weight threshold', async () => { + const private_key = Buffer.from( + '0000000000000000000000000000000000000000000000000000000000000000', + 'hex' + ) + const public_key = ed25519.publicKey(private_key) + + const message = { + version: 1, + public_key: public_key.toString('hex'), + operation: 'SET', + content: + 'should save message to database for nano representative above weight threshold', + tags: [], + references: [], + created_at: Math.floor(Date.now() / 1000) + } + + const signature = sign_nano_community_message(message, private_key) + + // Mocking the rpc request to simulate a representative above the weight threshold + nock('http://nano:7076') + .post('/', (body) => body.action === 'account_info') + .reply(200, { + weight: String(REPRESENTATIVE_TRACKING_MINIMUM_VOTING_WEIGHT) + }) + + const response = await chai + .request(server) + .post('/api/auth/message') + .send({ + message: { + ...message, + signature: signature.toString('hex') + } + }) + + expect(response).to.have.status(200) + + const saved_message = await db('nano_community_messages') + .where({ + public_key: message.public_key, + created_at: message.created_at, + content: message.content + }) + .first() + + // eslint-disable-next-line no-unused-expressions + expect(saved_message).to.exist + expect(saved_message.content).to.equal(message.content) + expect(saved_message.operation).to.equal(message.operation) + expect(saved_message.version).to.equal(message.version) + expect(saved_message.public_key).to.equal(message.public_key) + expect(saved_message.signature).to.equal(signature.toString('hex')) + expect(saved_message.created_at).to.equal(message.created_at) }) }) @@ -258,5 +349,107 @@ describe('API /auth/message', function () { expect(response).to.have.status(400) expect(response.text).to.include('Invalid signature') }) + + it('should not save message to database for nano account below balance threshold', async () => { + const private_key = Buffer.from( + '0000000000000000000000000000000000000000000000000000000000000000', + 'hex' + ) + const public_key = ed25519.publicKey(private_key) + + const message = { + version: 1, + public_key: public_key.toString('hex'), + operation: 'SET', + content: + 'should not save message to database for nano account below balance threshold', + tags: [], + references: [], + created_at: Math.floor(Date.now() / 1000) + } + + const signature = sign_nano_community_message(message, private_key) + + // Mocking the rpc request to simulate an account below the balance threshold + nock('http://nano:7076') + .post('/', (body) => body.action === 'account_info') + .reply(200, { + balance: String(ACCOUNT_TRACKING_MINIMUM_BALANCE - 1n) + }) + + const response = await chai + .request(server) + .post('/api/auth/message') + .send({ + message: { + ...message, + signature: signature.toString('hex') + } + }) + + expect(response).to.have.status(200) + + const saved_message = await db('nano_community_messages') + .where({ + public_key: message.public_key, + created_at: message.created_at, + content: message.content + }) + .first() + + // eslint-disable-next-line no-unused-expressions + expect(saved_message).to.be.undefined + }) + + it('should not save message to database for nano representative below weight threshold', async () => { + const private_key = Buffer.from( + '0000000000000000000000000000000000000000000000000000000000000000', + 'hex' + ) + const public_key = ed25519.publicKey(private_key) + + const message = { + version: 1, + public_key: public_key.toString('hex'), + operation: 'SET', + content: + 'should not save message to database for nano representative below weight threshold', + tags: [], + references: [], + created_at: Math.floor(Date.now() / 1000) + } + + const signature = sign_nano_community_message(message, private_key) + + // Mocking the rpc request to simulate a representative below the weight threshold + nock('http://nano:7076') + .post('/', (body) => body.action === 'account_info') + .reply(200, { + weight: String(REPRESENTATIVE_TRACKING_MINIMUM_VOTING_WEIGHT - 1n) + }) + + const response = await chai + .request(server) + .post('/api/auth/message') + .send({ + message: { + ...message, + signature: signature.toString('hex') + } + }) + + expect(response).to.have.status(200) + + const saved_message = await db('nano_community_messages') + .where({ + public_key: message.public_key, + created_at: message.created_at, + content: message.content + }) + .first() + + // eslint-disable-next-line no-unused-expressions + expect(saved_message).to.be.undefined + }) }) }) diff --git a/yarn.lock b/yarn.lock index 5981bd04..c5d0b986 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12076,6 +12076,13 @@ __metadata: languageName: node linkType: hard +"json-stringify-safe@npm:^5.0.1": + version: 5.0.1 + resolution: "json-stringify-safe@npm:5.0.1" + checksum: 48ec0adad5280b8a96bb93f4563aa1667fd7a36334f79149abd42446d0989f2ddc58274b479f4819f1f00617957e6344c886c55d05a4e15ebb4ab931e4a6a8ee + languageName: node + linkType: hard + "json-text-sequence@npm:~0.3.0": version: 0.3.0 resolution: "json-text-sequence@npm:0.3.0" @@ -13720,6 +13727,17 @@ __metadata: languageName: node linkType: hard +"nock@npm:^13.5.4": + version: 13.5.4 + resolution: "nock@npm:13.5.4" + dependencies: + debug: ^4.1.0 + json-stringify-safe: ^5.0.1 + propagate: ^2.0.0 + checksum: d31f924e34c87ae985edfb7b5a56e8a4dcfc3a072334ceb6d686326581f93090b3e23492663a64ce61b8df4f365b113231d926bc300bcfe9e5eb309c3e4b8628 + languageName: node + linkType: hard + "node-cache@npm:^5.1.2": version: 5.1.2 resolution: "node-cache@npm:5.1.2" @@ -14985,6 +15003,13 @@ __metadata: languageName: node linkType: hard +"propagate@npm:^2.0.0": + version: 2.0.1 + resolution: "propagate@npm:2.0.1" + checksum: c4febaee2be0979e82fb6b3727878fd122a98d64a7fa3c9d09b0576751b88514a9e9275b1b92e76b364d488f508e223bd7e1dcdc616be4cdda876072fbc2a96c + languageName: node + linkType: hard + "proto-list@npm:~1.2.1": version: 1.2.4 resolution: "proto-list@npm:1.2.4" @@ -16133,6 +16158,7 @@ __metadata: nanocurrency: ^2.5.0 nanocurrency-web: ^1.4.3 nib: ^1.2.0 + nock: ^13.5.4 node-cache: ^5.1.2 node-cron: ^3.0.2 node-fetch: 2.6.1