1. 程式人生 > >Crypto UX and Key Management

Crypto UX and Key Management

Multi-what?

Multisignature wallets are a familiar concept to many within the cryptocurrency space but despite their simplistic concept, many people not familiar with the space do not understand what this term means, nor the value of a functional multisig construct. For clarity in this article we consider a multisig contract (typically) to be a setup whereby m-of-n signatures (authorisations) are required before a transaction can be sent/spent. These constructs exist in traditional banking - in a common simple case two partners may have a joint bank account, each with their own debit/credit card that spends from this joint account. In crypto-speak this would be considered a 1-of-2 multisig. The flexible start contract language of Ethereum allows us to construct much more advanced multisignature contracts, impose arbitrary limitations and allowances as well as adjustable permissioning.

An interesting approach to key management is using the same concept as multisignature constructs but applied to the private keys themselves —for example instead of m-of-n authorising a spend, an addition of another private key can be authorised. This can be reffered to as “on-chain key management”. This idea (plus more) is expressed through

ERC-725 Identity standard proposal. This proposal covers much more than the ability to manage keys onchain; it can be considered closer to a specification for a self-soverign identity (these concepts are all closely interlinked), but the key management principles are of primary interest for this article. The standard has gained significant industry traction, with an
alliance formed
specifically to encourage the growth of the standard. We shall start by discussing the standard, look at a sample implementation of the standard, examine how it impacts on UX and discuss extensions and limitations of this standard.

ERC-725

From the perspective of traditional key management, ERC-725 is an onchain key management system. Each key involved in this draft standard is either an public key or corresponding ethereum address, corresponding to an off-chain private key. Each key has a corresponding keyType, purpose and actual key value:

struct Key {    uint256 purpose;    uint256 keyType;    bytes32 key;}

So far, four key purposes have been defined: MANAGEMENT, ACTION, CLAIM & ENCRYPTION, in ascending value. MANAGEMENT keys are permitted to perform administration type operations, such as adding or removing another key. ACTION keys are less “powerful” and may be limited to sending non-administration transactions. keyType specifies the cryptographic primitive the key relates to; RSA, Elliptic Curve, etc., although concerns lie around the number of key types that can fit inside 32 bytes (256 bits).

Claims (or attestations) and management of these claims (ERC-735 Claim Holder) are a large intertwined part of ERC-725 but as mentioned the primary focus is to examine the key management aspects and the increase in usability these bring. The ERC-725 standard specifies high level functionality that implementations should adhere to, to be considered compliant to the standard. For purposes of illustration it is easier to focus on one implementation; that of Status, a prominent company in the industry aiming to be a type of mobile gateway to the Ethereum ecosystem. Of interest in their github repository are the ERC725 and Identity Solidity contracts. This is not the only way to implement the standard, but highlights some of the features, patterns and challenges associated with writing smart contracts in Solidity.

mynameisidentity.sol

Identity.sol inherits both ERC725 and ERC735 (which we shall ignore in this analysis), two abstract “interface” style contracts. This means Identity.sol must define the following functions:

function getKey(bytes32 _key, uint256 _purpose) public view returns(uint256 purpose, uint256 keyType, bytes32 key); 
function getKeyPurpose(bytes32 _key) public view returns(uint256[] purpose); 
function getKeysByPurpose(uint256 _purpose) public view returns(bytes32[] keys); 
function addKey(bytes32 _key, uint256 _purpose, uint256 _keyType) public returns (bool success); 
function removeKey(bytes32 _key, uint256 _purpose) public returns (bool success); 
function execute(address _to, uint256 _value, bytes _data) public returns (uint256 executionId); 
function approve(uint256 _id, bool _approve) public returns (bool success);

Before we get to these functions we define some contract level variables for storage of keys, their corresponding purposes and various thresholds that are required or associated with each key:

mapping(bytes32 => Key) keys; //keccak256(key, purpose)=> Key Struct
mapping(uint256 => bytes32[]) keysByPurpose; //keys corresponding to each key purpose type (MANAGEMENT,ACTION, etc)
mapping(bytes32 => uint256) indexes; //indices of active keys
mapping(uint256 => uint256) purposeThreshold; //how many of keys are required to sign for that key purpose type (example: min of 1 for MANAGEMENT key)

Additionally a transaction is defined:

struct Transaction {    bool valid;  // flag to mark if tx is valid   address to;   uint256 value;   bytes data;   uint256 nonce; // tx nonce, not contract nonce   uint256 approverCount; // incremented by approve function   mapping(bytes32 => bool) approvals; //which keys have approved tx}

Transactions are stored in a mapping txx — transactions may be awaiting approval from other keys, and this also prevents a form of replay attack vulnerability alongside a contract level nonce. For the purpose of examination of key management functionality, the recovery associated fields/functionality (recoveryContract, recoveryManager) of this contract are also ignored in this analysis.

mapping (uint256 => Transaction) txx; // generally should correspond to nonce => Transaction
uint256 nonce; // tx nonce to prevent replay attacks

Adding, Removing and Replacing Keys

Starting with addKey(bytes32 _key, uint256 _purpose, uint256 _keyType) public managementOnly returns (bool success) , an externally callable “wrapper” function, we recall that the _key parameter can be either a generic public key or an Ethereum address. Practically speaking, this function is equivalent to adding a authorised spender, in the multisig context discussed earlier.

function addKey(bytes32 _key, uint256 _purpose, uint256 _type)public        managementOnly        returns (bool success)    {    _addKey(_key, _purpose, _type);    return true;}

The managementOnly modifier restricts access to this function to internal calls or ensuring the msg.sender is a MANAGEMENT key. It does so through the ubiquitously called function isKeyPurpose(bytes32 _key, uint256 _purpose). This function checks if keys[keccak256(_key, _purpose)].purpose == _purpose . Note that the keys mapping does not store a direct mapping of key.value => Key , but rather a keccak hash (keyHash) of the keys value and the purpose type. isKeyPurpose() is called throughout the contract, leading to reduced redundant code, an essential trait in smart contract design (due to high cost of deployment/storage and increased ease of security analysis/auditing).

If the managementOnly modifier allows the function call to continue, the internally restricted _addKey(bytes32 _key, uint256 _purpose, uint256 _type) function is called. This function calculates the keyHash for the passed key, ensures the key has not already been added, ensures the Key.keyType is defined, then adds the Key to the key mapping, indexed by keyHash.

function _addKey( bytes32 _key, uint256 _purpose, uint256 _type ) private {     bytes32 keyHash = keccak256(_key, _purpose);    require(keys[keyHash].purpose == 0);         require( _purpose == MANAGEMENT_KEY || _purpose == ACTION_KEY ||    _purpose == CLAIM_SIGNER_KEY || _purpose == ENCRYPTION_KEY );        keys[keyHash] = Key(_purpose, _type, _key);    indexes[keyHash] = keysByPurpose[_purpose].push(_key) — 1;    emit KeyAdded(_key, _purpose, _type);}

The _key is also pushed into the keysByPurpose mapping, using _purpose as its index. For example, if it is an ACTION key that is being added, this increases the number of keys stored under the ACTION index (2 in the case of ACTION).

The KeyAdded event is also emitted so that any wallets/infrastructure outside of the Ethereum blockchain can react/trigger.

The function works much the same as addKey(), but (as expected) in reverse. The same managementOnly restrictor is utilised. The key is deleted from the key mapping and removed from the keysByPurpose mapping. A KeyRemoved event is also fired.

Additional functionality is provided over the ERC-725 standard in the form of a replaceKey function, which calls _addKey(), then _removeKey().

Other ERC-725 Required Key Functions

Both getKeyPurpose(bytes32 _key) and getKeysByPurpose(uint256 _purpose) behave exactly as expected, with getKeyPurpose returning the purposes stored for the _key queried.getKeysByPurpose returns all the keys the contract associates with that _purpose.

Approve and Execute

Approve and execute are the two externally callable functions which result in execution of a transaction, provided sufficient approval from the appropriate key types is obtained. These functions are the main interface via which to send transactions.

function execute(address _to, uint256 _value, bytes _data)         public returns (uint256 executionId)    {            uint256 requiredKey = _to == address(this) ? MANAGEMENT_KEY :    ACTION_KEY;    if (purposeThreshold[requiredKey] == 1) {        executionId = nonce;                  nonce++;                    require(isKeyPurpose(bytes32(msg.sender), requiredKey));        _to.call.value(_value)(_data);         emit Executed(executionId, _to, _value, _data);    }     else {        executionId = _execute(_to, _value, _data);         approve(executionId, true);    }}

execute() checks if the transaction destination is to the contract itself, if so a MANAGEMENT key is required, if not an ACTION key. The purposeThreshold for the key type is checked — if only one key is required for that key purpose type, the key is checked to be of that purpose using isKeyPurpose and the transaction is sent with a low-level call: _to.call.value(_value)(_data) . If the purposeThreshold for that particular key purpose type is not equal to one, the internal _execute() is called. This internal function constructs a Transaction , sets approvalCount=0 , inserts it into the txx map, emits an ExecutionRequested event and returns the executionID, which is the contract level nonce.

The approve() “wrapper” function is then called with executionID as a parameter. Upon satisfying the managerOrActor modifier, _approve(bytes32(msg.sender), executionID, _approval=true) is called.

function _approve(bytes32 _key, uint256 _id, bool _approval)         private         returns(bool success){                    Transaction memory trx = txx[_id];    require(trx.valid);            uint256 requiredKeyPurpose = trx.to == address(this) ?    MANAGEMENT_KEY : ACTION_KEY;        require(isKeyPurpose(_key, requiredKeyPurpose));    bytes32 keyHash = keccak256(_key, requiredKeyPurpose);    require(txx[_id].approvals[keyHash] != _approval);        if (_approval) {        trx.approverCount++;    }    else {        trx.approverCount--;    }    emit Approved(_id, _approval);        if (trx.approverCount<purposeThreshold[requiredKeyPurpose]) {        txx[_id].approvals[keyHash] = _approval;        txx[_id] = trx;    }     else {        delete txx[_id];        success = address(trx.to).call.value(trx.value)(trx.data);                    emit Executed(_id, trx.to, trx.value, trx.data);    }}

The transaction is loaded from the txx array and checked for validity. It is interesting to note here that an optimisation was carried out to load the transaction into memory (there are three memory “areas” in the 256 bit stack based EVM — stack, memory and storage, listed ascending in operation cost, and descending in persistence and accessibility). isKeyPurpose is checked and the keyHash of the passed key is found. The approvals array of the transaction is checked to be false. The approverCount field of the transaction is incremented and if there are sufficient approvals for the purposeThreshold for that requiredKeyPurpose, the transaction is sent with:address(trx.to).call.value(trx.value)(trx.data). If there are insufficient appropriate approvals from the required key purpose type, then this approval is added to the transactions approvals field and saved back into storage, awaiting further required approvals.

This implementation shows the flexibility and versatility of on-chain key management, but also shows how quickly smart contracts become complex. This implementation ignores the ability to have different keyType (RSA, EC etc. ) as this would certainly add more complexity to the contract.