Commit b7b3019c authored by Mahdi Yari's avatar Mahdi Yari

init

parents
Pipeline #12394 failed with stages
in 0 seconds
version: 2
jobs:
node8:
docker:
- image: node:8-alpine
steps:
- run: apk add --no-cache make bash ca-certificates
- checkout
- restore_cache:
keys:
- node8-dependencies-{{ checksum "yarn.lock" }}
- node8-dependencies-
- run: yarn install --frozen-lockfile
- save_cache:
paths:
- node_modules
key: node8-dependencies-{{ checksum "yarn.lock" }}
- run: yarn test
- store_test_results:
path: reports
- store_artifacts:
path: reports
destination: reports
node9:
docker:
- image: node:9-alpine
steps:
- run: apk add --no-cache make bash ca-certificates
- checkout
- restore_cache:
keys:
- node9-dependencies-{{ checksum "yarn.lock" }}
- node9-dependencies-
- run: yarn install --frozen-lockfile
- save_cache:
paths:
- node_modules
key: node9-dependencies-{{ checksum "yarn.lock" }}
- run: yarn test
- store_test_results:
path: reports
- store_artifacts:
path: reports
destination: reports
workflows:
version: 2
build:
jobs:
- node8
- node9
node_modules/
lib/
reports/
.nyc_output/
SHELL := /bin/bash
PATH := ./node_modules/.bin:$(PATH)
SRC_FILES := $(shell find src -name '*.ts')
all: lib
lib: $(SRC_FILES) node_modules tsconfig.json
tsc -p tsconfig.json --outDir lib
touch lib
reports:
mkdir reports
.PHONY: coverage
coverage: node_modules reports
nyc -r html -r text -e .ts -i ts-node/register \
--report-dir reports/coverage \
mocha --reporter nyan --require ts-node/register test/*.ts
.PHONY: test
test: node_modules
mocha --require ts-node/register test/*.ts --grep '$(grep)'
.PHONY: ci-test
ci-test: node_modules reports
nsp check
tslint -p tsconfig.json -c tslint.json
nyc -r lcov -e .ts -i ts-node/register \
--report-dir reports/coverage \
mocha --require ts-node/register \
--reporter mocha-junit-reporter \
--reporter-options mochaFile=./reports/unit-tests/junit.xml \
test/*.ts
.PHONY: lint
lint: node_modules
tslint -p tsconfig.json -c tslint.json -t stylish --fix
node_modules:
yarn install --non-interactive --frozen-lockfile
.PHONY: clean
clean:
rm -rf lib/
.PHONY: distclean
distclean: clean
rm -rf node_modules/
@steemit/rpc-auth
=================
JSONRPC 2.0 authentication with steem authorities
Specification
-------------
### Overview
Request signing for [JSON-RPC 2.0](http://www.jsonrpc.org/specification) implemented using [steem](https://steem.io) authorities.
### Design Goals
* Do not require request header modification.
* Result: Signature/auth must be in message body
* Signed requests do not violate json-rpc spec.
* Result: Extensions must go into `params`.
* Method name is not obscured so that it may be routed properly to the correct handler/backend.
* Result: `method` remains unchanged by signing.
### Signed request
Requests are signed with steem keys belonging to the sender.
Example JSON-RPC request:
```json
{
"jsonrpc": "2.0",
"id": 123,
"method": "foo.bar",
"params": {
"hello": "there"
}
}
```
Above request signed with the posting key belonging to `foo`:
```json
{
"jsonrpc": "2.0",
"method": "foo.bar",
"id": 123,
"params": {
"__signed": {
"account": "foo",
"nonce": "1773e363793b44c3",
"params": "eyJoZWxsbyI6InRoZXJlIn0=",
"signatures": [
"1f02df499f15c8757754c11251a6e5238296f56b17f7229202fce6ccd7289e224c49c32eaf77d5905e2b4d8a8a5ddcc215c51ce45c207ef0f038328200578d1bee"
],
"timestamp": "2017-11-26T16:57:40.633Z"
}
}
}
```
Signature creation pseudocode:
```python
# JSON+Base64 request params
params = base64(json_encode(request['params']))
# 8 byte nonce
nonce = random_bytes(8)
# ISO 8601 formatted timestamp
timestamp = date_now() # "2017-11-26T16:57:40.633Z"
# Signer account name
account = 'foo'
# Private posting key belonging to foo
signing_key = PrivateKey('...')
# Signing constant K (sha256('steem_jsonrpc_auth'))
K = bytes_from_hex('3b3b081e46ea808d5a96b08c4bc5003f5e15767090f344faab531ec57565136b')
# first round of sha256
first = sha256(timestamp + account + method + params)
# message to be signed
message = sha256(K + first + nonce)
signature = ecdsa_sign(message, signing_key)
```
### Signature validation
1. Entire request must be <64k for sanity/anti-DoS
1. Request must be valid json and json-rpc
1. `request['params']['__signed']` must exist
1. `request['params']['__signed']` must be the only item in `request['params']`
1. `request['params']['__signed']['params']` must be valid base64
1. `request['params']['__signed']['params']` when base64 decoded must be valid json
1. `request['params']['__signed']['nonce']` must exist and be a hex string of length 16 (8 bytes decoded)
1. `request['params']['__signed']['timestamp']` must exist and be a valid iso8601 datetime ending in Z
1. `request['params']['__signed']['timestamp']` must be within the last 60 seconds
1. `request['params']['__signed']['account']` must be a valid steem blockchain account
1. `request['params']['__signed']['signature']` must be a hex string >= 64 chars (32+ bytes decoded)
1. construct `first = sha256( request['params']['__signed']['timestamp'] + request['params']['__signed']['account'] + request['method'] + request['params']['__signed']['params'] ).bytes()`
1. construct `signedstring = sha256( K + first + unhexlify(nonce)).bytes()`
1. check signature, signedstring against posting authorities for `request['params']['__signed']['account']`
{
"name": "@steemit/rpc-auth",
"description": "JSON-RPC 2.0 authentication using steem blockchain authorities",
"version": "1.1.1",
"license": "MIT",
"main": "./lib/index",
"typings": "./lib/index",
"scripts": {
"test": "make ci-test",
"prepublish": "make lib"
},
"files": [
"lib/*"
],
"engines": {
"node": ">=8"
},
"dependencies": {
"@steemit/libcrypto": "^1.0.1"
},
"devDependencies": {
"@types/mocha": "^2.2.47",
"@types/node": "^9.3.0",
"dsteem": "^0.8.5",
"mocha": "^5.0.0",
"mocha-junit-reporter": "^1.16.0",
"node-fetch": "^1.7.3",
"nsp": "^3.1.0",
"nyc": "^11.4.1",
"ts-node": "^4.1.0",
"tslint": "^5.9.1",
"typescript": "^2.6.2"
}
}
/**
* @file JSONRPC 2.0 request authentication with steem authorities.
* @author Johan Nordberg <johan@steemit.com>
*/
import {hexify, PrivateKey} from '@steemit/libcrypto'
import {createHash, randomBytes} from 'crypto'
/**
* Signing constant used to reserve opcode space and prevent cross-protocol attacks.
* Output of `sha256('steem_jsonrpc_auth')`.
*/
export const K = Buffer.from('3b3b081e46ea808d5a96b08c4bc5003f5e15767090f344faab531ec57565136b', 'hex')
/**
* JSONRPC 2.0 ID.
*/
export type JsonRpcId = string | number | null
/**
* JSONRPC 2.0 Request.
*/
export interface JsonRpcRequest {
jsonrpc: '2.0'
id: JsonRpcId
method: string
params?: any
}
/**
* Signed JSONRPC 2.0 Request.
*/
export interface SignedJsonRpcRequest extends JsonRpcRequest {
params: {
__signed: {
/** 8 bytes of hex-encoded random data */
nonce: string
/** ISO8601 formatted date */
timestamp: string
/** Signers steemit account name */
account: string
/** JSON+base64 encoded request params */
params: string
/** Array of hex-encoded ecdsa signatures */
signatures: string[]
}
}
}
/**
* Thrown when a request fails validation.
*/
class ValidationError extends Error {
/** Underlying error. */
public cause?: Error
constructor(message: string, cause?: Error) {
super(message)
this.name = 'ValidationError'
if (cause) {
this.cause = cause
this.message += ` (${ cause.message })`
}
}
}
/**
* Create request hash to be signed.
*
* @param timestamp ISO8601 formatted date e.g. `2017-11-14T19:40:29.077Z`.
* @param account Steem account name that is the signer.
* @param method RPC request method.
* @param params Base64 encoded JSON string containing request params.
* @param nonce 8 bytes of random data.
*
* @returns bytes to be signed or validated.
*/
function hashMessage(timestamp: string, account: string, method: string,
params: string, nonce: Buffer): Buffer {
const first = createHash('sha256')
first.update(timestamp)
first.update(account)
first.update(method)
first.update(params)
const second = createHash('sha256')
second.update(K)
second.update(first.digest())
second.update(nonce)
return second.digest()
}
/**
* Sign a JSON RPC Request.
*/
export function sign(request: JsonRpcRequest, account: string, keys: any[]): SignedJsonRpcRequest {
if (!request.params) {
throw new Error('Unable to sign a request without params')
}
const params = Buffer.from(JSON.stringify(request.params), 'utf8').toString('base64')
const nonceBytes = randomBytes(8)
const nonce = nonceBytes.toString('hex')
const timestamp = new Date().toISOString()
const message = hashMessage(
timestamp, account, request.method, params, nonceBytes
)
const signatures: string[] = []
for (let key of keys) {
if (typeof key === 'string') {
key = PrivateKey.from(key)
}
const signature = hexify(key.sign(message.buffer))
signatures.push(signature)
}
return {
jsonrpc: '2.0',
method: request.method,
id: request.id,
params: {
__signed: {
account,
nonce,
params,
signatures,
timestamp,
}
}
}
}
/**
* Verify that message is signed by account and that the signatures are valid, should throw if verification fails.
*
* @param message Message to verify.
* @param signatures Signatures to verify.
* @param account Account whose posting authority created the signatures.
*
* Responsible for:
* 1. Account must be a valid steem blockchain account
* 2. All signatures must be a hex string >= 64 chars (32+ bytes decoded)
* 3. Signature matches message
* 4. Signature was made with accounts posting authority
*
*/
export type VerifyMessage = (message: Buffer, signatures: string[], account: string) => Promise<void>
/**
* Validate a signed JSON RPC request.
* Throws a {@link ValidationError} if the request fails validation.
*
* @returns Resolved request params.
*/
export async function validate(request: SignedJsonRpcRequest, verify: VerifyMessage): Promise<any> {
if (request.jsonrpc !== '2.0' || typeof request.method !== 'string') {
throw new ValidationError('Invalid JSON RPC Request')
}
if (request.params == undefined || request.params.__signed == undefined) {
throw new ValidationError('Signed payload missing')
}
if (Object.keys(request.params).length !== 1) {
throw new ValidationError('Invalid request params')
}
const signed = request.params.__signed
if (signed.account == undefined) {
throw new ValidationError('Missing account')
}
let params: string
try {
const jsonString = Buffer.from(signed.params, 'base64').toString('utf8')
params = JSON.parse(jsonString)
} catch (cause) {
throw new ValidationError('Invalid encoded params', cause)
}
if (signed.nonce == undefined || typeof signed.nonce !== 'string') {
throw new ValidationError('Invalid nonce')
}
const nonce = Buffer.from(signed.nonce, 'hex')
if (nonce.length !== 8) {
throw new ValidationError('Invalid nonce')
}
const timestamp = Date.parse(signed.timestamp)
if (Number.isNaN(timestamp)) {
throw new ValidationError('Invalid timestamp')
}
if (Date.now() - timestamp > 60 * 1000) {
throw new ValidationError('Signature expired')
}
const message = hashMessage(
signed.timestamp, signed.account, request.method, signed.params, nonce
)
try {
await verify(message, signed.signatures, signed.account)
} catch (cause) {
throw new ValidationError('Verification failed', cause)
}
return params
}
import 'mocha'
import * as assert from 'assert'
import {randomBytes} from 'crypto'
import * as fetch from 'node-fetch'
import {PrivateKey, Client, utils, Signature} from 'dsteem'
import {sign, validate, JsonRpcRequest, VerifyMessage, SignedJsonRpcRequest} from './../src/'
const dummyVerify: VerifyMessage = async (message: Buffer, signatures: string[], account: string) => {}
const client = Client.testnet()
const dsteemVerify: VerifyMessage = async (message: Buffer, signatures: string[], account: string) => {
const opts = {
hash: message,
signatures,
required_posting: [account],
}
const rv = await client.database.call('verify_signatures', [opts])
if (rv.valid !== true) {
throw new Error('Signature invalid')
}
}
function randomString(length: number) {
return randomBytes(length*2)
.toString('base64')
.replace(/[^0-9a-z]+/gi, '')
.slice(0, length)
.toLowerCase()
}
async function createTestnetAccount(): Promise<{username: string, password: string}> {
if (process.env['TESTNET_USER'] && process.env['TESTNET_PASSWORD']) {
return {
username: process.env['TESTNET_USER'] as string,
password: process.env['TESTNET_PASSWORD'] as string,
}
}
const password = randomString(32)
const username = `rpcauth-${ randomString(8) }`
const response = await fetch('https://testnet.steem.vc/create', {
method: 'POST',
body: `username=${ username }&password=${ password }`,
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
}, 1000, (n) => n*10)
const text = await response.text()
if (response.status !== 200) {
throw new Error(`Unable to create user: ${ text }`)
}
return {username, password}
}
async function assertThrows(block: () => Promise<any>) {
try {
await block()
} catch (error) {
return error
}
assert.fail('Missing expected exception')
}
describe('rpc auth', function() {
this.timeout(20 * 1000)
this.slow(10 * 1000)
let testAccount: {username: string, password: string}
let testKey: string
before(async function() {
testAccount = await createTestnetAccount()
testKey = PrivateKey.fromLogin(testAccount.username, testAccount.password, 'posting').toString()
})
it('signs and validates', async function() {
const req: JsonRpcRequest = {
jsonrpc: '2.0',
id: 123,
method: 'foo.bar',
params: {bongo: 'bingo'}
}
const signed = sign(req, testAccount.username, [testKey])
assert(signed.params.__signed != undefined)
assert.equal(signed.jsonrpc, '2.0')
assert.equal(signed.method, req.method)
assert.equal(signed.id, req.id)
const verifiedParams = await validate(signed, dummyVerify)
assert.deepEqual(req.params, verifiedParams)
})
it('handles invalid requests', async function() {
let error
let req: any = {}
error = await assertThrows(async () => {
await validate(req, dummyVerify)
})
assert.equal('ValidationError: Invalid JSON RPC Request', String(error))
req.jsonrpc = '2.0'
req.method = 'foo.bar'
error = await assertThrows(async () => {
await validate(req, dummyVerify)
})
assert.equal('ValidationError: Signed payload missing', String(error))
req.params = {__signed: {}, other: 'foo'}
error = await assertThrows(async () => {
await validate(req, dummyVerify)
})
assert.equal('ValidationError: Invalid request params', String(error))
req.params = {__signed: {}}
error = await assertThrows(async () => {
await validate(req, dummyVerify)
})
assert.equal('ValidationError: Missing account', String(error))
req.params.__signed.account = 'foo'
error = await assertThrows(async () => {
await validate(req, dummyVerify)
})
assert.equal('ValidationError: Invalid encoded params', String(error).slice(0, 39))
req.params.__signed.params = Buffer.from(JSON.stringify({foo: 'bar'})).toString('base64')
error = await assertThrows(async () => {
await validate(req, dummyVerify)
})
assert.equal('ValidationError: Invalid nonce', String(error))
req.params.__signed.nonce = 'banana'
error = await assertThrows(async () => {
await validate(req, dummyVerify)
})
assert.equal('ValidationError: Invalid nonce', String(error))
req.params.__signed.nonce = randomBytes(8).toString('hex')
error = await assertThrows(async () => {
await validate(req, dummyVerify)
})
assert.equal('ValidationError: Invalid timestamp', String(error))
req.params.__signed.timestamp = '2001-01-01T00:00:00Z'
error = await assertThrows(async () => {
await validate(req, dummyVerify)
})
assert.equal('ValidationError: Signature expired', String(error))
req.params.__signed.timestamp = new Date().toISOString()
error = await assertThrows(async () => {
await validate(req, async () => {
throw new Error('Nope')
})
})
assert.equal('ValidationError: Verification failed (Nope)', String(error))
})
it('handles invalid signatures', async function() {
this.skip() // This should be broken out as an integration later
let error, invalid: SignedJsonRpcRequest
const req: JsonRpcRequest = {
jsonrpc: '2.0',
id: 123,
method: 'foo.bar',
params: {hello: 'there'}
}
const signed = sign(req, testAccount.username, [testKey])
// valid
await validate(signed, dsteemVerify)
// invalid method
invalid = utils.copy(signed)
invalid.method = 'foo.bar2'
error = await assertThrows(async () => {
await validate(invalid, dsteemVerify)
})
assert.equal(String(error), 'ValidationError: Verification failed (Signature invalid)')
// invalid account
invalid = utils.copy(signed)
invalid.params.__signed.account = 'baz'
error = await assertThrows(async () => {
await validate(invalid, dsteemVerify)
})
assert.equal(String(error), 'ValidationError: Verification failed (Signature invalid)')
// invalid account
invalid = utils.copy(signed)
invalid.params.__signed.account = 'baz'
error = await assertThrows(async () => {
await validate(invalid, dsteemVerify)
})
assert.equal(String(error), 'ValidationError: Verification failed (Signature invalid)')
// invalid nonce
invalid = utils.copy(signed)
invalid.params.__signed.nonce = randomBytes(8).toString('hex')
error = await assertThrows(async () => {
await validate(invalid, dsteemVerify)
})
assert.equal(String(error), 'ValidationError: Verification failed (Signature invalid)')
// invalid params
invalid = utils.copy(signed)
invalid.params.__signed.params = 'eyJpbGlrZSI6InR1cnRsZXMifQ=='
error = await assertThrows(async () => {
await validate(invalid, dsteemVerify)
})
assert.equal(String(error), 'ValidationError: Verification failed (Signature invalid)')