diff --git a/README.md b/README.md index 6a64e36c5968790a4dbbb3595797662ec581154d..fe1f765bce16754ce124c958a8595254a0442cee 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,8 @@ Actions As above but constructs a transaction around the operation before signing. * `hive://sign/ops/` As above but allows multiple operations as an array. + * `hive://sign/msg/` + Sign an arbitrary messages with given authority. * `hive://sign/[/operation_params..]` Action aliases, see the "Specialized actions" section for more info. @@ -120,6 +122,8 @@ Params are global to all actions and encoded as query string params. * `cb` (callback) - Base64u encoded url that will be redirected to when the transaction has been signed. The url also allows some templating, see the callback section below for more info. + * `a` (authority) - Preferred signing authority. + Params uses short names to save space in encoded URIs. @@ -136,6 +140,7 @@ Callback template params: * `id` - Hex-encoded string containing the 20-byte transaction hash* * `block` - The block number the transaction was included in* * `txn` - The block transaction index* + * `data` - Arbitrary message/data *Will not be available if the `nb` param was set for the action. diff --git a/package.json b/package.json index fe843bdac2dfdee506989735dbf701310670512c..ea711d2789179464f3f94ed768b4b6114d0f3ca0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hive-uri", - "version": "0.2.5", + "version": "0.2.8", "description": "Hive URI parser and encoder", "license": "MIT", "main": "./lib/index", @@ -12,9 +12,13 @@ "lib/*" ], "devDependencies": { - "@hiveio/dhive": "^1.2.2", + "@hiveio/dhive": "^1.3.2", "@types/node": "^14.14.28", + "bytebuffer": "^5.0.1", "tslint": "^5.10.0", "typescript": "^4.1.5" + }, + "dependencies": { + "url-parse": "^1.5.10" } } diff --git a/src/index.ts b/src/index.ts index 7545ac1652b398374f244aefc3b811c7ae540b22..fcfb2ec9d7ef67b905f3794e2279bdeea9211029 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,60 +1,70 @@ // Only used for typings, no code is pulled in. -import {Operation, Transaction} from '@hiveio/dhive' - +import { Operation, Transaction } from '@hiveio/dhive' +import * as URLParse from 'url-parse' // Assumes node.js if any of the utils needed are missing. -if (typeof URL === 'undefined') { - global['URL'] = require('url').URL -} -if (typeof URLSearchParams === 'undefined') { - global['URLSearchParams'] = require('url').URLSearchParams -} if (typeof btoa === 'undefined') { - global['btoa'] = (str) => Buffer.from(str, 'binary').toString('base64') + global['btoa'] = (str) => Buffer.from(str, 'binary').toString('base64') } if (typeof atob === 'undefined') { - global['atob'] = (str) => Buffer.from(str, 'base64').toString('binary') + global['atob'] = (str) => Buffer.from(str, 'base64').toString('binary') } /// URL-safe Base64 encoding and decoding. -const B64U_LOOKUP = {'/': '_', '_': '/', '+': '-', '-': '+', '=': '.', '.': '='} -const b64uEnc = (str) => btoa(unescape(encodeURIComponent(str))).replace(/(\+|\/|=)/g, (m) => B64U_LOOKUP[m]) -const b64uDec = (str) => decodeURIComponent(escape(atob(str.replace(/(-|_|\.)/g, (m) => B64U_LOOKUP[m])))) +const B64U_LOOKUP = { + '/': '_', + '_': '/', + '+': '-', + '-': '+', + '=': '.', + '.': '=', +} +const b64uEnc = (str) => + btoa(unescape(encodeURIComponent(str))).replace( + /(\+|\/|=)/g, + (m) => B64U_LOOKUP[m], + ) +const b64uDec = (str) => + decodeURIComponent( + escape(atob(str.replace(/(-|_|\.)/g, (m) => B64U_LOOKUP[m]))), + ) /** * Protocol parameters. */ export interface Parameters { - /** Requested signer. */ - signer?: string - /** Redurect uri. */ - callback?: string - /** Whether to just sign the transaction. */ - no_broadcast?: boolean + /** Requested signer. */ + signer?: string + /** Redirect uri. */ + callback?: string + /** Whether to just sign the transaction. */ + no_broadcast?: boolean + /** Authority required to sign, posting, active, etc. */ + authority?: string } /** * A transactions that may contain placeholders. */ export interface UnresolvedTransaction extends Transaction { - ref_block_num: any - ref_block_prefix: any - expiration: any - operations: any[] + ref_block_num: any + ref_block_prefix: any + expiration: any + operations: any[] } /** * Decoding result. */ export interface DecodeResult { - /** - * Decoded transaction. May have placeholders, use {@link resolve} to - * resolve them into a signable transaction. - */ - tx: UnresolvedTransaction - /** - * Decoded protocol parameters. - */ - params: Parameters + /** + * Decoded transaction. May have placeholders, use {@link resolve} to + * resolve them into a signable transaction. + */ + tx: UnresolvedTransaction | any + /** + * Decoded protocol parameters. + */ + params: Parameters } /** @@ -64,81 +74,98 @@ export interface DecodeResult { * @returns The resolved transaction and parameters. */ export function decode(hiveUrl: string): DecodeResult { - const protocol = hiveUrl.slice(0, 5) - // workaround for chrome not parsing custom protocols correctly - const url = new URL(hiveUrl.replace(/^hive:/, 'http:')) - if (protocol !== 'hive:') { - throw new Error(`Invalid protocol, expected 'hive:' got '${ protocol }'`) - } - if (url.host !== 'sign') { - throw new Error(`Invalid action, expected 'sign' got '${ url.host }'`) - } - const [type, rawPayload] = url.pathname.split('/').slice(1) - let payload: any - try { - payload = JSON.parse(b64uDec(rawPayload)) - } catch (error) { - error.message = `Invalid payload: ${ error.message }` - throw error - } - let tx: UnresolvedTransaction - switch (type) { - case 'tx': - tx = payload - break - case 'op': - case 'ops': - const operations: any[] = type === 'ops' ? payload : [payload] - tx = { - ref_block_num: '__ref_block_num', - ref_block_prefix: '__ref_block_prefix', - expiration: '__expiration', - extensions: [], - operations, - } - break - // case 'transfer': - // case 'follow': - default: - throw new Error(`Invalid signing action '${ type }'`) - } - const params: Parameters = {} - if (url.searchParams.has('cb')) { - params.callback = b64uDec(url.searchParams.get('cb')!) - } - if (url.searchParams.has('nb')) { - params.no_broadcast = true - } - if (url.searchParams.has('s')) { - params.signer = url.searchParams.get('s')! - } - return {tx, params} + const protocol = hiveUrl.slice(0, 5) + // workaround for chrome not parsing custom protocols correctly + const url = new URLParse(hiveUrl.replace(/^hive:/, 'http:')) + if (protocol !== 'hive:') { + throw new Error(`Invalid protocol, expected 'hive:' got '${protocol}'`) + } + if (url.host !== 'sign') { + throw new Error(`Invalid action, expected 'sign' got '${url.host}'`) + } + const [type, rawPayload] = url.pathname.split('/').slice(1) + let payload: any + try { + payload = JSON.parse(b64uDec(rawPayload)) + } catch (error) { + error.message = `Invalid payload: ${error.message}` + throw error + } + let tx: UnresolvedTransaction | any + switch (type) { + case 'tx': + tx = payload + break + case 'op': + case 'ops': + const operations: any[] = type === 'ops' ? payload : [payload] + tx = { + ref_block_num: '__ref_block_num', + ref_block_prefix: '__ref_block_prefix', + expiration: '__expiration', + extensions: [], + operations, + } + break + // case 'transfer': + // case 'follow': + case 'msg': + tx = payload + break + default: + throw new Error(`Invalid signing action '${type}'`) + } + const queryParams = Object.fromEntries( + url.query + .substring(1) // remove leading '?' + .split('&') + .filter(Boolean) + .map((param) => { + const [key, value = ''] = param.split('=') + return [decodeURIComponent(key), decodeURIComponent(value)] + }), + ) + + const params: Parameters = {} + if (queryParams.cb) { + params.callback = b64uDec(queryParams.cb) + } + if (queryParams.nb !== undefined) { + params.no_broadcast = true + } + if (queryParams.a) { + params.authority = queryParams.a + } + if (queryParams.s) { + params.signer = queryParams.s + } + return { tx, params } } /** * Transaction resolving options. */ export interface ResolveOptions { - /** The ref block number used to fill in the `__ref_block_num` placeholder. */ - ref_block_num: number - /** The ref block prefix used to fill in the `__ref_block_prefix` placeholder. */ - ref_block_prefix: number - /** The date string used to fill in the `__expiration` placeholder. */ - expiration: string, - /** List of account names avialable as signers. */ - signers: string[] - /** Preferred signer if none is explicitly set in params. */ - preferred_signer: string + /** The ref block number used to fill in the `__ref_block_num` placeholder. */ + ref_block_num: number + /** The ref block prefix used to fill in the `__ref_block_prefix` placeholder. */ + ref_block_prefix: number + /** The date string used to fill in the `__expiration` placeholder. */ + expiration: string + /** List of account names available as signers. */ + signers: string[] + /** Preferred signer if none is explicitly set in params. */ + preferred_signer: string } /** * Transaction resolving result. */ export interface ResolveResult { - /** The resolved transaction ready to be signed. */ - tx: Transaction, - /** The account that should sign the transaction. */ - signer: string, + /** The resolved transaction ready to be signed. */ + tx: Transaction + /** The account that should sign the transaction. */ + signer: string } const RESOLVE_PATTERN = /(__(ref_block_(num|prefix)|expiration|signer))/g @@ -150,56 +177,62 @@ const RESOLVE_PATTERN = /(__(ref_block_(num|prefix)|expiration|signer))/g * @param options Values to use when resolving. * @returns The resolved transaction and signer. */ -export function resolveTransaction(utx: UnresolvedTransaction, params: Parameters, options: ResolveOptions): ResolveResult { - const signer = params.signer || options.preferred_signer - if (!options.signers.includes(signer)) { - throw new Error(`Signer '${ signer }' not available`) - } - const ctx = { - __ref_block_num: options.ref_block_num, - __ref_block_prefix: options.ref_block_prefix, - __expiration: options.expiration, - __signer: signer, +export function resolveTransaction( + utx: UnresolvedTransaction, + params: Parameters, + options: ResolveOptions, +): ResolveResult { + const signer = params.signer || options.preferred_signer + if (!options.signers.includes(signer)) { + throw new Error(`Signer '${signer}' not available`) + } + const ctx = { + __ref_block_num: options.ref_block_num, + __ref_block_prefix: options.ref_block_prefix, + __expiration: options.expiration, + __signer: signer, + } + const walk = (val: any) => { + let type: string = typeof val + if (type === 'object' && Array.isArray(val)) { + type = 'array' + } else if (val === null) { + type = 'null' } - const walk = (val: any) => { - let type: string = typeof val - if (type === 'object' && Array.isArray(val)) { - type = 'array' - } else if (val === null) { - type = 'null' - } - switch (type) { - case 'string': - return val.replace(RESOLVE_PATTERN, (m) => ctx[m]) - case 'array': - return val.map(walk) - case 'object': { - const rv: any = {} - for (const [k, v] of Object.entries(val)) { - rv[k] = walk(v) - } - return rv - } - default: - return val + switch (type) { + case 'string': + return val.replace(RESOLVE_PATTERN, (m) => ctx[m]) + case 'array': + return val.map(walk) + case 'object': { + const rv: any = {} + for (const [k, v] of Object.entries(val)) { + rv[k] = walk(v) } + return rv + } + default: + return val } - let tx = walk(utx) as Transaction - return {signer, tx} + } + const tx = walk(utx) as Transaction + return { signer, tx } } /** * Transaction confirmation including signature. */ export interface TransactionConfirmation { - /** Transaction signature. */ - sig: string - /** Transaction hash. */ - id?: string - /** Block number transaction was included in. */ - block?: number - /** Transaction index in block. */ - txn?: number + /** Transaction signature. */ + sig: string + /** Transaction hash. */ + id?: string + /** Block number transaction was included in. */ + block?: number + /** Transaction index in block. */ + txn?: number + /** arbitrary data */ + data?: any } const CALLBACK_RESOLVE_PATTERN = /({{(sig|id|block|txn|data)}})/g @@ -211,44 +244,40 @@ const CALLBACK_RESOLVE_PATTERN = /({{(sig|id|block|txn|data)}})/g * @returns The resolved url. */ export function resolveCallback(url: string, ctx: TransactionConfirmation) { - return url.replace(CALLBACK_RESOLVE_PATTERN, (_1, _2, m) => ctx[m] || '') + return url.replace(CALLBACK_RESOLVE_PATTERN, (_1, _2, m) => ctx[m] || '') } /*** Internal helper to encode Parameters to a querystring. */ function encodeParameters(params: Parameters) { - const out = new URLSearchParams() - if (params.no_broadcast === true) { - out.set('nb', '') - } - if (params.signer) { - out.set('s', params.signer) - } - if (params.callback) { - out.set('cb', b64uEnc(params.callback)) - } - let qs = out.toString() - if (qs.length > 0) { - qs = '?' + qs - } - return qs + const parts: string[] = [] + if (params.no_broadcast) { parts.push('nb=') } + if (params.signer) { parts.push('s=' + params.signer) } + if (params.callback) { parts.push('cb=' + b64uEnc(params.callback)) } + if (params.authority) { parts.push('a=' + params.authority) } + return parts.length ? '?' + parts.join('&') : '' } /** Internal helper to encode a tx or op to a b64u+json payload. */ function encodeJson(data: any) { - return b64uEnc(JSON.stringify(data, null, 0)) + return b64uEnc(JSON.stringify(data, null, 0)) } /** Encodes a Hive transaction to a hive: URI. */ export function encodeTx(tx: Transaction, params: Parameters = {}) { - return `hive://sign/tx/${ encodeJson(tx) }${ encodeParameters(params) }` + return `hive://sign/tx/${encodeJson(tx)}${encodeParameters(params)}` } /** Encodes a Hive operation to a hive: URI. */ export function encodeOp(op: Operation, params: Parameters = {}) { - return `hive://sign/op/${ encodeJson(op) }${ encodeParameters(params) }` + return `hive://sign/op/${encodeJson(op)}${encodeParameters(params)}` } /** Encodes several Hive operations to a hive: URI. */ export function encodeOps(ops: Operation[], params: Parameters = {}) { - return `hive://sign/ops/${ encodeJson(ops) }${ encodeParameters(params) }` + return `hive://sign/ops/${encodeJson(ops)}${encodeParameters(params)}` +} + +/** Encodes arbitrary message to a hive: URI. */ +export function encodeMsg(msg: any, params: Parameters = {}) { + return `hive://sign/msg/${encodeJson(msg)}${encodeParameters(params)}` } diff --git a/yarn.lock b/yarn.lock index 066b1306661f0c23c9c5ed2d1c051fb1493a81a0..74722757b01d854d795f112192a76299e12323e3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,18 +2,28 @@ # yarn lockfile v1 -"@hiveio/dhive@^1.2.2": - version "1.2.2" - resolved "https://registry.yarnpkg.com/@hiveio/dhive/-/dhive-1.2.2.tgz#a85f89b90f2c107354bab7e7144f48c0fb781143" - integrity sha512-EK7MIXAcgXne1jV8OV/4WXw1gY/1Oax568yMdsB912yCiPU7dP3Ocd9M1zhCWAshH62MUmjfdl/iw7q6v60igQ== +"@ecency/bytebuffer@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@ecency/bytebuffer/-/bytebuffer-6.0.0.tgz#8d4f98bf5777f18e056c85899a9bdb37119cc62a" + integrity sha512-rGPjzD7a7cPtMHjpJEtLMt/RmqX8XK25tN5qjuu9iaDMK/Ril86CecU5DN/TXEMUQMY1p6b2cVvKBLTdFMr2DA== dependencies: + long "~3" + +"@hiveio/dhive@^1.3.2": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@hiveio/dhive/-/dhive-1.3.2.tgz#23733a8f0d537b1cad89e9bdd6e6a8a6d8b339d8" + integrity sha512-kJjp3TbpIlODxjJX4BWwvOf+cMxT8CFH/mNQ40RRjR2LP0a4baSWae1G+U/q/NtgjsIQz6Ja40tvnw6KF12I+g== + dependencies: + "@ecency/bytebuffer" "^6.0.0" + bigi "^1.4.2" bs58 "^4.0.1" - bytebuffer "^5.0.1" core-js "^3.6.4" cross-fetch "^3.0.4" + ecurve "^1.0.6" https "^1.0.0" jsbi "^3.1.4" node-fetch "^2.6.0" + ripemd160 "^2.0.2" secp256k1 "^3.8.0" verror "^1.10.0" whatwg-fetch "^3.0.0" @@ -67,6 +77,11 @@ base-x@^3.0.2: dependencies: safe-buffer "^5.0.1" +bigi@^1.1.0, bigi@^1.4.2: + version "1.4.2" + resolved "https://registry.yarnpkg.com/bigi/-/bigi-1.4.2.tgz#9c665a95f88b8b08fc05cfd731f561859d725825" + integrity sha512-ddkU+dFIuEIW8lE7ZwdIAf2UPoM90eaprg5m3YXAVVTmKlqV/9BX4A2M8BOK2yOq6/VgZFVhK6QAxJebhlbhzw== + bindings@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" @@ -129,7 +144,7 @@ builtin-modules@^1.1.1: bytebuffer@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/bytebuffer/-/bytebuffer-5.0.1.tgz#582eea4b1a873b6d020a48d58df85f0bba6cfddd" - integrity sha1-WC7qSxqHO20CCkjVjfhfC7ps/d0= + integrity sha512-IuzSdmADppkZ6DlpycMkm8l9zeEq16fWtLvunEwFiYciR/BHo4E8/xs5piFquG+Za8OWmMqHF8zuRviz2LHvRQ== dependencies: long "~3" @@ -230,6 +245,14 @@ drbg.js@^1.0.1: create-hash "^1.1.2" create-hmac "^1.1.4" +ecurve@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/ecurve/-/ecurve-1.0.6.tgz#dfdabbb7149f8d8b78816be5a7d5b83fcf6de797" + integrity sha512-/BzEjNfiSuB7jIWKcS/z8FK9jNjmEWvUV2YZ4RLSmcDtP7Lq0m6FvDuSnJpBlDpGRpfRQeTLGLBI8H+kEv0r+w== + dependencies: + bigi "^1.1.0" + safe-buffer "^5.0.1" + elliptic@^6.5.2: version "6.5.4" resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.4.tgz#da37cebd31e79a1367e941b592ed1fbebd58abbb" @@ -430,7 +453,7 @@ resolve@^1.3.2: dependencies: path-parse "^1.0.5" -ripemd160@^2.0.0, ripemd160@^2.0.1: +ripemd160@^2.0.0, ripemd160@^2.0.1, ripemd160@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-2.0.2.tgz#a1c1a6f624751577ba5d07914cbc92850585890c" integrity sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==