-
Notifications
You must be signed in to change notification settings - Fork 5k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
EIP-712: Sign typed data #4803
EIP-712: Sign typed data #4803
Changes from 3 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,6 +4,7 @@ const createId = require('./random-id') | |
const assert = require('assert') | ||
const sigUtil = require('eth-sig-util') | ||
const log = require('loglevel') | ||
const jsonschema = require('jsonschema') | ||
|
||
/** | ||
* Represents, and contains data about, an 'eth_signTypedData' type signature request. These are created when a | ||
|
@@ -17,7 +18,7 @@ const log = require('loglevel') | |
* @property {Object} msgParams.from The address that is making the signature request. | ||
* @property {string} msgParams.data A hex string conversion of the raw buffer data of the signature request | ||
* @property {number} time The epoch time at which the this message was created | ||
* @property {string} status Indicates whether the signature request is 'unapproved', 'approved', 'signed' or 'rejected' | ||
* @property {string} status Indicates whether the signature request is 'unapproved', 'approved', 'signed', 'rejected', or 'errored' | ||
* @property {string} type The json-prc signing method for which a signature request has been made. A 'Message' will | ||
* always have a 'eth_signTypedData' type. | ||
* | ||
|
@@ -26,17 +27,10 @@ const log = require('loglevel') | |
module.exports = class TypedMessageManager extends EventEmitter { | ||
/** | ||
* Controller in charge of managing - storing, adding, removing, updating - TypedMessage. | ||
* | ||
* @typedef {Object} TypedMessage | ||
* @param {Object} opts @deprecated | ||
* @property {Object} memStore The observable store where TypedMessage are saved. | ||
* @property {Object} memStore.unapprovedTypedMessages A collection of all TypedMessages in the 'unapproved' state | ||
* @property {number} memStore.unapprovedTypedMessagesCount The count of all TypedMessages in this.memStore.unapprobedMsgs | ||
* @property {array} messages Holds all messages that have been created by this TypedMessage | ||
* | ||
*/ | ||
constructor (opts) { | ||
constructor ({ networkController }) { | ||
super() | ||
this.networkController = networkController | ||
this.memStore = new ObservableStore({ | ||
unapprovedTypedMessages: {}, | ||
unapprovedTypedMessagesCount: 0, | ||
|
@@ -76,15 +70,17 @@ module.exports = class TypedMessageManager extends EventEmitter { | |
* @returns {promise} When the message has been signed or rejected | ||
* | ||
*/ | ||
addUnapprovedMessageAsync (msgParams, req) { | ||
addUnapprovedMessageAsync (msgParams, req, version) { | ||
return new Promise((resolve, reject) => { | ||
const msgId = this.addUnapprovedMessage(msgParams, req) | ||
const msgId = this.addUnapprovedMessage(msgParams, req, version) | ||
this.once(`${msgId}:finished`, (data) => { | ||
switch (data.status) { | ||
case 'signed': | ||
return resolve(data.rawSig) | ||
case 'rejected': | ||
return reject(new Error('MetaMask Message Signature: User denied message signature.')) | ||
case 'errored': | ||
return reject(new Error(`MetaMask Message Signature: ${data.error}`)) | ||
default: | ||
return reject(new Error(`MetaMask Message Signature: Unknown problem: ${JSON.stringify(msgParams)}`)) | ||
} | ||
|
@@ -102,7 +98,8 @@ module.exports = class TypedMessageManager extends EventEmitter { | |
* @returns {number} The id of the newly created TypedMessage. | ||
* | ||
*/ | ||
addUnapprovedMessage (msgParams, req) { | ||
addUnapprovedMessage (msgParams, req, version) { | ||
msgParams.version = version | ||
this.validateParams(msgParams) | ||
// add origin from request | ||
if (req) msgParams.origin = req.origin | ||
|
@@ -132,14 +129,33 @@ module.exports = class TypedMessageManager extends EventEmitter { | |
* | ||
*/ | ||
validateParams (params) { | ||
assert.equal(typeof params, 'object', 'Params should ben an object.') | ||
assert.ok('data' in params, 'Params must include a data field.') | ||
assert.ok('from' in params, 'Params must include a from field.') | ||
assert.ok(Array.isArray(params.data), 'Data should be an array.') | ||
assert.equal(typeof params.from, 'string', 'From field must be a string.') | ||
assert.doesNotThrow(() => { | ||
sigUtil.typedSignatureHash(params.data) | ||
}, 'Expected EIP712 typed data') | ||
switch (params.version) { | ||
case 'V1': | ||
assert.equal(typeof params, 'object', 'Params should ben an object.') | ||
assert.ok('data' in params, 'Params must include a data field.') | ||
assert.ok('from' in params, 'Params must include a from field.') | ||
assert.ok(Array.isArray(params.data), 'Data should be an array.') | ||
assert.equal(typeof params.from, 'string', 'From field must be a string.') | ||
assert.doesNotThrow(() => { | ||
sigUtil.typedSignatureHash(params.data) | ||
}, 'Expected EIP712 typed data') | ||
break | ||
case 'V3': | ||
let data | ||
assert.equal(typeof params, 'object', 'Params should be an object.') | ||
assert.ok('data' in params, 'Params must include a data field.') | ||
assert.ok('from' in params, 'Params must include a from field.') | ||
assert.equal(typeof params.from, 'string', 'From field must be a string.') | ||
assert.equal(typeof params.data, 'string', 'Data must be passed as a valid JSON string.') | ||
assert.doesNotThrow(() => { data = JSON.parse(params.data) }, 'Data must be passed as a valid JSON string.') | ||
const validation = jsonschema.validate(data, sigUtil.TYPED_MESSAGE_SCHEMA) | ||
assert.ok(data.primaryType in data.types, `Primary type of "${data.primaryType}" has no type definition.`) | ||
assert.equal(validation.errors.length, 0, 'Data must conform to EIP-712 schema. See https://git.io/fNtcx.') | ||
const chainId = data.domain.chainId | ||
const activeChainId = parseInt(this.networkController.getNetworkState()) | ||
chainId && assert.equal(chainId, activeChainId, `Provided chainId (${chainId}) must match the active chainId (${activeChainId})`) | ||
break | ||
} | ||
} | ||
|
||
/** | ||
|
@@ -214,6 +230,7 @@ module.exports = class TypedMessageManager extends EventEmitter { | |
*/ | ||
prepMsgForSigning (msgParams) { | ||
delete msgParams.metamaskId | ||
delete msgParams.version | ||
return Promise.resolve(msgParams) | ||
} | ||
|
||
|
@@ -227,6 +244,19 @@ module.exports = class TypedMessageManager extends EventEmitter { | |
this._setMsgStatus(msgId, 'rejected') | ||
} | ||
|
||
/** | ||
* Sets a TypedMessage status to 'errored' via a call to this._setMsgStatus. | ||
* | ||
* @param {number} msgId The id of the TypedMessage to error | ||
* | ||
*/ | ||
errorMessage (msgId, error) { | ||
const msg = this.getMsg(msgId) | ||
msg.error = error | ||
this._updateMsg(msg) | ||
this._setMsgStatus(msgId, 'errored') | ||
} | ||
|
||
// | ||
// PRIVATE METHODS | ||
// | ||
|
@@ -250,7 +280,7 @@ module.exports = class TypedMessageManager extends EventEmitter { | |
msg.status = status | ||
this._updateMsg(msg) | ||
this.emit(`${msgId}:${status}`, msg) | ||
if (status === 'rejected' || status === 'signed') { | ||
if (status === 'rejected' || status === 'signed' || status === 'errored') { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. unclear when status gets set to 'errored' due to errors thrown in validateParams There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure I follow; what's unclear? The conditions that cause a message to be considered invalid? Error descriptions should be fine; are you suggesting to improve the documentation for the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah sorry I was being dense, I didn't see the call to set the message status in metamask-controller |
||
this.emit(`${msgId}:finished`, msg) | ||
} | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -49,6 +49,8 @@ const log = require('loglevel') | |
const TrezorKeyring = require('eth-trezor-keyring') | ||
const LedgerBridgeKeyring = require('eth-ledger-bridge-keyring') | ||
const EthQuery = require('eth-query') | ||
const ethUtil = require('ethereumjs-util') | ||
const sigUtil = require('eth-sig-util') | ||
|
||
module.exports = class MetamaskController extends EventEmitter { | ||
|
||
|
@@ -205,7 +207,7 @@ module.exports = class MetamaskController extends EventEmitter { | |
this.networkController.lookupNetwork() | ||
this.messageManager = new MessageManager() | ||
this.personalMessageManager = new PersonalMessageManager() | ||
this.typedMessageManager = new TypedMessageManager() | ||
this.typedMessageManager = new TypedMessageManager({ networkController: this.networkController }) | ||
this.publicConfigStore = this.initPublicConfigStore() | ||
|
||
this.store.updateStructure({ | ||
|
@@ -266,7 +268,6 @@ module.exports = class MetamaskController extends EventEmitter { | |
// msg signing | ||
processEthSignMessage: this.newUnsignedMessage.bind(this), | ||
processPersonalMessage: this.newUnsignedPersonalMessage.bind(this), | ||
processTypedMessage: this.newUnsignedTypedMessage.bind(this), | ||
} | ||
const providerProxy = this.networkController.initializeProvider(providerOpts) | ||
return providerProxy | ||
|
@@ -975,22 +976,31 @@ module.exports = class MetamaskController extends EventEmitter { | |
* @param {Object} msgParams - The params passed to eth_signTypedData. | ||
* @returns {Object} Full state update. | ||
*/ | ||
signTypedMessage (msgParams) { | ||
log.info('MetaMaskController - signTypedMessage') | ||
async signTypedMessage (msgParams) { | ||
log.info('MetaMaskController - eth_signTypedData') | ||
const msgId = msgParams.metamaskId | ||
// sets the status op the message to 'approved' | ||
// and removes the metamaskId for signing | ||
return this.typedMessageManager.approveMessage(msgParams) | ||
.then((cleanMsgParams) => { | ||
// signs the message | ||
return this.keyringController.signTypedMessage(cleanMsgParams) | ||
}) | ||
.then((rawSig) => { | ||
// tells the listener that the message has been signed | ||
// and can be returned to the dapp | ||
this.typedMessageManager.setMsgStatusSigned(msgId, rawSig) | ||
return this.getState() | ||
}) | ||
const version = msgParams.version | ||
try { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Previously, we called down into the KeyringController, which in turn called into a specific keyring, which then called called |
||
const cleanMsgParams = await this.typedMessageManager.approveMessage(msgParams) | ||
const address = sigUtil.normalize(cleanMsgParams.from) | ||
const keyring = await this.keyringController.getKeyringForAccount(address) | ||
const wallet = keyring._getWalletForAccount(address) | ||
const privKey = ethUtil.toBuffer(wallet.getPrivateKey()) | ||
let signature | ||
switch (version) { | ||
case 'V1': | ||
signature = sigUtil.signTypedDataLegacy(privKey, { data: cleanMsgParams.data }) | ||
break | ||
case 'V3': | ||
signature = sigUtil.signTypedData(privKey, { data: JSON.parse(cleanMsgParams.data) }) | ||
break | ||
} | ||
this.typedMessageManager.setMsgStatusSigned(msgId, signature) | ||
return this.getState() | ||
} catch (error) { | ||
log.info('MetaMaskController - eth_signTypedData failed.', error) | ||
this.typedMessageManager.errorMessage(msgId, error) | ||
} | ||
} | ||
|
||
/** | ||
|
@@ -1241,6 +1251,9 @@ module.exports = class MetamaskController extends EventEmitter { | |
engine.push(createLoggerMiddleware({ origin })) | ||
engine.push(filterMiddleware) | ||
engine.push(this.preferencesController.requestWatchAsset.bind(this.preferencesController)) | ||
engine.push(this.createTypedDataMiddleware('eth_signTypedData', 'V1').bind(this)) | ||
engine.push(this.createTypedDataMiddleware('eth_signTypedData_v1', 'V1').bind(this)) | ||
engine.push(this.createTypedDataMiddleware('eth_signTypedData_v3', 'V3').bind(this)) | ||
engine.push(createProviderMiddleware({ provider: this.provider })) | ||
|
||
// setup connection | ||
|
@@ -1474,4 +1487,34 @@ module.exports = class MetamaskController extends EventEmitter { | |
set isClientOpenAndUnlocked (active) { | ||
this.tokenRatesController.isActive = active | ||
} | ||
|
||
/** | ||
* Creates RPC engine middleware for processing eth_signTypedData requests | ||
* | ||
* @param {Object} req - request object | ||
* @param {Object} res - response object | ||
* @param {Function} - next | ||
* @param {Function} - end | ||
*/ | ||
createTypedDataMiddleware (methodName, version) { | ||
return async (req, res, next, end) => { | ||
const { method, params } = req | ||
if (method === methodName) { | ||
const promise = this.typedMessageManager.addUnapprovedMessageAsync({ | ||
data: params.length >= 1 && params[0], | ||
from: params.length >= 2 && params[1], | ||
}, req, version) | ||
this.sendUpdate() | ||
this.opts.showUnconfirmedMessage() | ||
try { | ||
res.result = await promise | ||
end() | ||
} catch (error) { | ||
end(error) | ||
} | ||
} else { | ||
next() | ||
} | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For now, since there are only two versions of the proposal, we include both validation logic paths in the same TypedMessageManager class. If there ends up being even more iterations of the proposal with different validation schemes (unlikely, but possible), we can break this class down into a base Manager class and multiple implementations that extend it with a custom
validateParams
method.