Skip to content

Commit

Permalink
feat: add api endpoint for signed message (#110)
Browse files Browse the repository at this point in the history
* feat: add api endpoint for signed message (close #105)

* chore: add tests
  • Loading branch information
mistakia authored Apr 9, 2024
1 parent ca50fe2 commit 7aa308b
Show file tree
Hide file tree
Showing 16 changed files with 876 additions and 23 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
198 changes: 198 additions & 0 deletions api/routes/auth/message.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import express from 'express'
import { tools } from 'nanocurrency-web'
import BigNumber from 'bignumber.js'

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) {
return res.status(400).send('Invalid message version')
}

// entry_id must be null or 32 byte hash
if (entry_id && entry_id.length !== 64) {
return res.status(400).send('Invalid entry_id')
}

// chain_id must be null or 32 byte hash
if (chain_id && chain_id.length !== 64) {
return res.status(400).send('Invalid chain_id')
}

// entry_clock must be null or positive integer
if (entry_clock && entry_clock < 0) {
return res.status(400).send('Invalid entry_clock')
}

// chain_clock must be null or positive integer
if (chain_clock && chain_clock < 0) {
return res.status(400).send('Invalid chain_clock')
}

// public_key must be 32 byte hash
if (public_key.length !== 64) {
return res.status(400).send('Invalid public_key')
}

// operation must be SET or DELETE
if (operation !== 'SET' && operation !== 'DELETE') {
return res.status(400).send('Invalid operation')
}

// content must be null or string
if (content && typeof content !== 'string') {
return res.status(400).send('Invalid content')
}

// tags must be null or array of strings
if (tags && !Array.isArray(tags)) {
return res.status(400).send('Invalid tags')
}

// references must be null or array of strings
if (references && !Array.isArray(references)) {
return res.status(400).send('Invalid references')
}

// created_at must be null or positive integer
if (created_at && created_at < 0) {
return res.status(400).send('Invalid created_at')
}

// signature must be 64 byte hash
if (signature.length !== 128) {
return 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) {
return 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) =>
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) =>
new BigNumber(account_info.weight).gte(
REPRESENTATIVE_TRACKING_MINIMUM_VOTING_WEIGHT
)
)

if (has_balance || has_weight) {
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({
version,

entry_id,
chain_id,
entry_clock,
chain_clock,

public_key,
operation,
content,
tags,

references,

created_at
})
} catch (error) {
console.log(error)
logger(error)
res.status(500).send('Internal server error')
}
})

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')
}
})

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: (hits, req) => (hits - req.slowDown.limit) * 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)
}
Loading

0 comments on commit 7aa308b

Please sign in to comment.