The signer
binary contains a ruleset engine, implemented with OttoVM
It enables usecases like the following:
CasinoDapp
, with up to 0.05 ether
in value to maximum 1 ether
per 24h periodEthAlarmClock
with data
=0xdeadbeef
, if value=0
, gas < 44k
and gasPrice < 40Gwei
The two main features that are required for this to work well are;
The section below deals with both of them
A ruleset file is implemented as a js
file. Under the hood, the ruleset-engine is a SignerUI
, implementing the same methods as the json-rpc
methods
defined in the UI protocol. Example:
function asBig(str){
if(str.slice(0,2) == "0x"){ return new BigNumber(str.slice(2),16)}
return new BigNumber(str)
}
// Approve transactions to a certain contract if value is below a certain limit
function ApproveTx(req){
var limit = big.Newint("0xb1a2bc2ec50000")
var value = asBig(req.transaction.value);
if(req.transaction.to.toLowerCase()=="0xae967917c465db8578ca9024c205720b1a3651a9")
&& value.lt(limit) ){
return "Approve"
}
// If we return "Reject", it will be rejected.
// By not returning anything, it will be passed to the next UI, for manual processing
}
//Approve listings if request made from IPC
function ApproveListing(req){
if (req.metadata.scheme == "ipc"){ return "Approve"}
}
Whenever the external API is called (and the ruleset is enabled), the signer
calls the UI, which is an instance of a ruleset-engine. The ruleset-engine
invokes the corresponding method. In doing so, there are three possible outcomes:
next
ui: the regular UI channel.A more advanced example can be found below, "Example 1: ruleset for a rate-limited window", using storage
to Put
and Get
string
s by key.
The Otto vm has a few caveats:
Additionally, a few more have been added
bignumber.js
version 2.0.3
. This one is fairly old, and is not aligned with the documentation at the github repository.storage
, since rules should not rely on ephemeral data.storage
and console
.Some security precautions can be made, such as:
ruleset.js
unless the file is readonly
(r-??-??-?
). If the user wishes to modify the ruleset, he must make it writeable and then set back to readonly.
sha3
of the ruleset.js
file in there.
signer --attest /path/to/ruleset --credential <creds>
The drawbacks of this very flexible solution is that the signer
needs to contain a javascript engine. This is pretty simple to implement, since it's already
implemented for geth
. There are no known security vulnerabilities in, nor have we had any security-problems with it so far.
The javascript engine would be an added attack surface; but if the validation of rulesets
is made good (with hash-based attestation), the actual javascript cannot be considered
an attack surface -- if an attacker can control the ruleset, a much simpler attack would be to implement an "always-approve" rule instead of exploiting the js vm. The only benefit
to be gained from attacking the actual signer
process from the js
side would be if it could somehow extract cryptographic keys from memory.
Javascript is flexible, but also easy to get wrong, especially when users assume that js
can handle large integers natively. Typical errors
include trying to multiply gasCost
with gas
without using bigint
:s.
It's unclear whether any other DSL could be more secure; since there's always the possibility of erroneously implementing a rule.
The ability to auto-approve transaction means that the signer needs to have necessary credentials to decrypt keyfiles. These passwords are hereafter called ksp
(keystore pass).
Upon startup of the signer, the signer is given a switch: --seed <path/to/masterseed>
The seed
contains a blob of bytes, which is the master seed for the signer
.
The signer
uses the seed
to:
path
where the settings are stored.
./settings/1df094eb-c2b1-4689-90dd-790046d38025/vault.dat
./settings/1df094eb-c2b1-4689-90dd-790046d38025/rules.js
vault.dat
.The vault.dat
would be an encrypted container storing the following information:
ksp
entriessha256
hash of rules.js
This would leave it up to the user to ensure that the path/to/masterseed
is handled in a secure way. It's difficult to get around this, although one could
imagine leveraging OS-level keychains where supported. The setup is however in general similar to how ssh-keys are stored in .ssh/
.
This is now implemented (with ephemeral non-encrypted storage for now, so not yet enabled).
function big(str){
if(str.slice(0,2) == "0x"){ return new BigNumber(str.slice(2),16)}
return new BigNumber(str)
}
// Time window: 1 week
var window = 1000* 3600*24*7;
// Limit : 1 ether
var limit = new BigNumber("1e18");
function isLimitOk(transaction){
var value = big(transaction.value)
// Start of our window function
var windowstart = new Date().getTime() - window;
var txs = [];
var stored = storage.Get('txs');
if(stored != ""){
txs = JSON.parse(stored)
}
// First, remove all that have passed out of the time-window
var newtxs = txs.filter(function(tx){return tx.tstamp > windowstart});
console.log(txs, newtxs.length);
// Secondly, aggregate the current sum
sum = new BigNumber(0)
sum = newtxs.reduce(function(agg, tx){ return big(tx.value).plus(agg)}, sum);
console.log("ApproveTx > Sum so far", sum);
console.log("ApproveTx > Requested", value.toNumber());
// Would we exceed weekly limit ?
return sum.plus(value).lt(limit)
}
function ApproveTx(r){
if (isLimitOk(r.transaction)){
return "Approve"
}
return "Nope"
}
/**
* OnApprovedTx(str) is called when a transaction has been approved and signed. The parameter
* 'response_str' contains the return value that will be sent to the external caller.
* The return value from this method is ignore - the reason for having this callback is to allow the
* ruleset to keep track of approved transactions.
*
* When implementing rate-limited rules, this callback should be used.
* If a rule responds with neither 'Approve' nor 'Reject' - the tx goes to manual processing. If the user
* then accepts the transaction, this method will be called.
*
* TLDR; Use this method to keep track of signed transactions, instead of using the data in ApproveTx.
*/
function OnApprovedTx(resp){
var value = big(resp.tx.value)
var txs = []
// Load stored transactions
var stored = storage.Get('txs');
if(stored != ""){
txs = JSON.parse(stored)
}
// Add this to the storage
txs.push({tstamp: new Date().getTime(), value: value});
storage.Put("txs", JSON.stringify(txs));
}
function ApproveTx(r){
if(r.transaction.from.toLowerCase()=="0x0000000000000000000000000000000000001337"){ return "Approve"}
if(r.transaction.from.toLowerCase()=="0x000000000000000000000000000000000000dead"){ return "Reject"}
// Otherwise goes to manual processing
}
function ApproveListing(){
return "Approve"
}