Skip to content

Commit

Permalink
feat: add api endpoint for signed message (close #105)
Browse files Browse the repository at this point in the history
  • Loading branch information
mistakia committed Apr 8, 2024
1 parent ca50fe2 commit 35132c5
Show file tree
Hide file tree
Showing 14 changed files with 581 additions and 13 deletions.
11 changes: 11 additions & 0 deletions api/routes/auth/index.mjs
Original file line number Diff line number Diff line change
@@ -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
158 changes: 158 additions & 0 deletions api/routes/auth/message.mjs
Original file line number Diff line number Diff line change
@@ -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)

Check failure

Code scanning / CodeQL

Reflected cross-site scripting High

Cross-site scripting vulnerability due to a
user-provided value
.
} catch (error) {
logger.error(error)
res.status(500).send('Internal server error')
}
})

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
authorization
, but is not rate-limited.

export default router
17 changes: 9 additions & 8 deletions api/routes/auth.mjs → api/routes/auth/register.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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]) {
Expand Down Expand Up @@ -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,
Expand All @@ -59,31 +60,31 @@ 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
})
.onConflict()
.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')
}
})

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
authorization
, but is not rate-limited.

Expand Down
2 changes: 1 addition & 1 deletion api/routes/index.mjs
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
10 changes: 9 additions & 1 deletion api/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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()

Expand Down Expand Up @@ -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')
Expand Down
5 changes: 5 additions & 0 deletions common/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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)
}
Expand Down
33 changes: 33 additions & 0 deletions common/sign-nano-community-message.mjs
Original file line number Diff line number Diff line change
@@ -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)
}
36 changes: 36 additions & 0 deletions common/verify-nano-community-message-signature.mjs
Original file line number Diff line number Diff line change
@@ -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)
}
1 change: 1 addition & 0 deletions constants.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down

0 comments on commit 35132c5

Please sign in to comment.