diff --git a/README.md b/README.md index 34883f41..aff39383 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,14 @@ [![PyPI version](https://badge.fury.io/py/cert-issuer.svg)](https://badge.fury.io/py/cert-issuer) + # cert-issuer The cert-issuer project issues blockchain certificates by creating a transaction from the issuing institution to the -recipient on the Bitcoin blockchain that includes the hash of the certificate itself. +recipient on the Bitcoin blockchain that includes the hash of the certificate itself. + +## What's special about this fork? +See our [documentation](docs/ethereum_smart_contract.md). ## Web resources For development or testing using web requests, check out the documentation at [docs/web_resources.md](./docs/web_resources.md). @@ -189,14 +193,18 @@ Decide which chain (Bitcoin or Ethereum) to issue to and follow the steps. The b By default, cert-issuer issues to the Bitcoin blockchain. Run the default setup script if this is the mode you want: ``` python setup.py install - ``` To issue to the ethereum blockchain, run the following: ``` -python setup.py experimental --blockchain=ethereum +python setup.py install experimental --blockchain=ethereum +``` +To issue to the ethereum blockchain using the smart contract backend, run the following: +``` +python setup.py install experimental --blockchain=ethereum_smart_contract ``` +For more information on the smart contract backend reference the [complete documentation](docs/ethereum_smart_contract.md). ### Create a Bitcoin issuing address diff --git a/cert_issuer/blockchain_handlers/bitcoin/transaction_handlers.py b/cert_issuer/blockchain_handlers/bitcoin/transaction_handlers.py index 54bfbf69..7fb0bb7b 100644 --- a/cert_issuer/blockchain_handlers/bitcoin/transaction_handlers.py +++ b/cert_issuer/blockchain_handlers/bitcoin/transaction_handlers.py @@ -50,7 +50,7 @@ def ensure_balance(self): logging.error(error_message) raise InsufficientFundsError(error_message) - def issue_transaction(self, blockchain_bytes): + def issue_transaction(self, blockchain_bytes, app_config): op_return_value = b2h(blockchain_bytes) prepared_tx = self.create_transaction(blockchain_bytes) signed_tx = self.sign_transaction(prepared_tx) diff --git a/cert_issuer/blockchain_handlers/ethereum/transaction_handlers.py b/cert_issuer/blockchain_handlers/ethereum/transaction_handlers.py index 9a4489ea..8a483f5f 100644 --- a/cert_issuer/blockchain_handlers/ethereum/transaction_handlers.py +++ b/cert_issuer/blockchain_handlers/ethereum/transaction_handlers.py @@ -54,7 +54,7 @@ def ensure_balance(self): logging.error(error_message) raise InsufficientFundsError(error_message) - def issue_transaction(self, blockchain_bytes): + def issue_transaction(self, blockchain_bytes, app_config): eth_data_field = b2h(blockchain_bytes) prepared_tx = self.create_transaction(blockchain_bytes) signed_tx = self.sign_transaction(prepared_tx) diff --git a/cert_issuer/blockchain_handlers/ethereum_sc/__init__.py b/cert_issuer/blockchain_handlers/ethereum_sc/__init__.py new file mode 100644 index 00000000..d62bd75c --- /dev/null +++ b/cert_issuer/blockchain_handlers/ethereum_sc/__init__.py @@ -0,0 +1,96 @@ +import logging +import os + +from cert_core import BlockchainType +from cert_core import Chain, UnknownChainError + +from cert_issuer.certificate_handlers import CertificateBatchHandler, CertificateV2Handler +from cert_issuer.blockchain_handlers.ethereum_sc.connectors import EthereumSCServiceProviderConnector +from cert_issuer.blockchain_handlers.ethereum_sc.ens import ENSConnector +from cert_issuer.blockchain_handlers.ethereum_sc.signer import EthereumSCSigner +from cert_issuer.blockchain_handlers.ethereum_sc.transaction_handlers import EthereumSCTransactionHandler +from cert_issuer.merkle_tree_generator import MerkleTreeGenerator +from cert_issuer.models import MockTransactionHandler +from cert_issuer.signer import FileSecretManager +from cert_issuer.errors import ENSEntryError, MissingArgumentError + + +class EthereumTransactionCostConstants(object): + def __init__(self, recommended_gas_price, recommended_gas_limit): + self.recommended_gas_price = recommended_gas_price + self.recommended_gas_limit = recommended_gas_limit + logging.info('Set cost constants to recommended_gas_price=%f, recommended_gas_limit=%f', + self.recommended_gas_price, self.recommended_gas_limit) + + """ + The below methods currently only use the supplied gasprice/limit. + These values can also be better estimated via a call to the Ethereum blockchain. + """ + + def get_recommended_max_cost(self): + return self.recommended_gas_price * self.recommended_gas_limit + + def get_gas_price(self): + return self.recommended_gas_price + + def get_gas_limit(self): + return self.recommended_gas_limit + + +def initialize_signer(app_config): + path_to_secret = os.path.join(app_config.usb_name, app_config.key_file) + + if app_config.chain.blockchain_type == BlockchainType.ethereum: + signer = EthereumSCSigner(ethereum_chain=app_config.chain) + elif app_config.chain == Chain.mockchain: + signer = None + else: + raise UnknownChainError(app_config.chain) + secret_manager = FileSecretManager(signer=signer, path_to_secret=path_to_secret, + safe_mode=app_config.safe_mode, issuing_address=app_config.issuing_address) + return secret_manager + + +def instantiate_connector(app_config, cost_constants): + # if contr_addr is not set explicitly (recommended), get it from ens entry + ens = ENSConnector(app_config) + contr_addr = ens.get_addr() + + if contr_addr == "0x0000000000000000000000000000000000000000": + raise ENSEntryError(f"Resolved address {contr_addr} from ENS entry {app_config.ens_name}") + + app_config.contract_address = contr_addr + + connector = EthereumSCServiceProviderConnector(app_config, contr_addr, cost_constants=cost_constants) + return connector + +def check_necessary_arguments(app_config): + # required arguments only for smart_contract method + if app_config.ens_name is None: + raise MissingArgumentError("Missing argument ens_name, check your config file.") + if app_config.node_url is None: + raise MissingArgumentError("Missing argument node_url, check your config file.") + + # required only if revoke is set + if app_config.revoke is True and app_config.revocation_list_file is None: + raise MissingArgumentError("Missing argument revocation_list_file, check your config file.") + +def instantiate_blockchain_handlers(app_config): + check_necessary_arguments(app_config) + + issuing_address = app_config.issuing_address + chain = app_config.chain + secret_manager = initialize_signer(app_config) + certificate_batch_handler = CertificateBatchHandler(secret_manager=secret_manager, + certificate_handler=CertificateV2Handler(), + merkle_tree=MerkleTreeGenerator()) + if chain == Chain.mockchain: + transaction_handler = MockTransactionHandler() + # ethereum chains + elif chain == Chain.ethereum_mainnet or chain == Chain.ethereum_ropsten: + cost_constants = EthereumTransactionCostConstants(app_config.gas_price, app_config.gas_limit) + connector = instantiate_connector(app_config, cost_constants) + transaction_handler = EthereumSCTransactionHandler(connector, cost_constants, secret_manager, + issuing_address=issuing_address) + + return certificate_batch_handler, transaction_handler, connector diff --git a/cert_issuer/blockchain_handlers/ethereum_sc/connectors.py b/cert_issuer/blockchain_handlers/ethereum_sc/connectors.py new file mode 100644 index 00000000..c3ab981b --- /dev/null +++ b/cert_issuer/blockchain_handlers/ethereum_sc/connectors.py @@ -0,0 +1,103 @@ +import json +import os +import logging +from errors import UnableToSignTxError + +from cert_issuer.models import ServiceProviderConnector +from web3 import Web3, HTTPProvider + + +def get_abi(contract): + """ + Returns smart contract abi. + possible values for contract: "blockcerts", "ens_registry" + """ + + directory = os.path.dirname(os.path.abspath(__file__)) + path = os.path.join(directory, f"data/{contract}_abi.json") + + with open(path, "r") as f: + raw = f.read() + abi = json.loads(raw) + return abi + + +# this class can be used for both ENS contracts as well as our own ("cert_store") +class EthereumSCServiceProviderConnector(ServiceProviderConnector): + """ + Collects abi, address, contract data and instantiates a contract object + """ + def __init__(self, app_config, contract_address, abi_type="cert_store", private_key=None, cost_constants=None): + self.cost_constants = cost_constants + + self.app_config = app_config + self._private_key = private_key + + if abi_type == "cert_store": + from cert_issuer.blockchain_handlers.ethereum_sc.ens import ENSConnector + abi = ENSConnector(app_config).get_abi() + else: + abi = get_abi(abi_type) + + self._w3 = Web3(HTTPProvider(self.app_config.node_url)) + self._w3.eth.defaultAccount = self.app_config.issuing_address + + self._contract_obj = self._w3.eth.contract(address=contract_address, abi=abi) + + def get_balance(self, address): + return self._w3.eth.getBalance(address) + + def create_transaction(self, method, *argv): + gas_limit = self.cost_constants.get_gas_limit() + estimated_gas = self._contract_obj.functions[method](*argv).estimateGas() * 2 + if estimated_gas > gas_limit: + logging.warning("Estimated gas of %s more than gas limit of %s, transaction might fail. Please verify on etherescan.com.", estimated_gas, gas_limit) + estimated_gas = gas_limit + + gas_price = self._w3.eth.gasPrice + gas_price_limit = self.cost_constants.get_gas_price() + + if gas_price > gas_price_limit: + logging.warning("Gas price provided by network of %s higher than gas price of %s set in config, transaction might fail. Please verify on etherescan.com.", gas_price, gas_price_limit) + gas_price = gas_price_limit + + tx_options = { + 'nonce': self._w3.eth.getTransactionCount(self._w3.eth.defaultAccount), + 'gas': estimated_gas, + 'gasPrice': gas_price + } + + construct_txn = self._contract_obj.functions[method](*argv).buildTransaction(tx_options) + return construct_txn + + def broadcast_tx(self, signed_tx): + tx_hash = self._w3.eth.sendRawTransaction(signed_tx.rawTransaction) + tx_receipt = self._w3.eth.waitForTransactionReceipt(tx_hash) + return tx_receipt.transactionHash.hex() + + def transact(self, method, *argv): + """ + Sends a signed transaction on the blockchain and waits for a response. + If initialized with private key this class can sign the transaction. + In general, an external signer should be used in conjunction with + create_transaction() and broadcast_tx. + """ + if self._private_key is None: + raise UnableToSignTxError("This method is only available if a private key was passed upon initialization") + + prepared_tx = self.create_transaction(method, *argv) + signed_tx = self._sign_transaction(prepared_tx) + txid = self.broadcast_transaction(signed_tx) + return txid + + def _sign_transaction(self, prepared_tx): + acct = self._w3.eth.account.from_key(self._private_key) + + try: + signed_tx = acct.sign_transaction(prepared_tx) + return signed_tx + except Exception: + raise UnableToSignTxError('You are trying to sign a non transaction type') + + def call(self, method, *argv): + return self._contract_obj.functions[method](*argv).call() diff --git a/cert_issuer/blockchain_handlers/ethereum_sc/data/ens_registry_abi.json b/cert_issuer/blockchain_handlers/ethereum_sc/data/ens_registry_abi.json new file mode 100644 index 00000000..25153f0f --- /dev/null +++ b/cert_issuer/blockchain_handlers/ethereum_sc/data/ens_registry_abi.json @@ -0,0 +1,533 @@ +[ + { + "inputs":[ + { + "internalType":"contract ENS", + "name":"_old", + "type":"address" + +} + +], + "payable":false, + "stateMutability":"nonpayable", + "type":"constructor" + +}, + { + "anonymous":false, + "inputs":[ + { + "indexed":true, + "internalType":"address", + "name":"owner", + "type":"address" + +}, + { + "indexed":true, + "internalType":"address", + "name":"operator", + "type":"address" + +}, + { + "indexed":false, + "internalType":"bool", + "name":"approved", + "type":"bool" + +} + +], + "name":"ApprovalForAll", + "type":"event" + +}, + { + "anonymous":false, + "inputs":[ + { + "indexed":true, + "internalType":"bytes32", + "name":"node", + "type":"bytes32" + +}, + { + "indexed":true, + "internalType":"bytes32", + "name":"label", + "type":"bytes32" + +}, + { + "indexed":false, + "internalType":"address", + "name":"owner", + "type":"address" + +} + +], + "name":"NewOwner", + "type":"event" + +}, + { + "anonymous":false, + "inputs":[ + { + "indexed":true, + "internalType":"bytes32", + "name":"node", + "type":"bytes32" + +}, + { + "indexed":false, + "internalType":"address", + "name":"resolver", + "type":"address" + +} + +], + "name":"NewResolver", + "type":"event" + +}, + { + "anonymous":false, + "inputs":[ + { + "indexed":true, + "internalType":"bytes32", + "name":"node", + "type":"bytes32" + +}, + { + "indexed":false, + "internalType":"uint64", + "name":"ttl", + "type":"uint64" + +} + +], + "name":"NewTTL", + "type":"event" + +}, + { + "anonymous":false, + "inputs":[ + { + "indexed":true, + "internalType":"bytes32", + "name":"node", + "type":"bytes32" + +}, + { + "indexed":false, + "internalType":"address", + "name":"owner", + "type":"address" + +} + +], + "name":"Transfer", + "type":"event" + +}, + { + "constant":true, + "inputs":[ + { + "internalType":"address", + "name":"owner", + "type":"address" + +}, + { + "internalType":"address", + "name":"operator", + "type":"address" + +} + +], + "name":"isApprovedForAll", + "outputs":[ + { + "internalType":"bool", + "name":"", + "type":"bool" + +} + +], + "payable":false, + "stateMutability":"view", + "type":"function" + +}, + { + "constant":true, + "inputs":[ + + +], + "name":"old", + "outputs":[ + { + "internalType":"contract ENS", + "name":"", + "type":"address" + +} + +], + "payable":false, + "stateMutability":"view", + "type":"function" + +}, + { + "constant":true, + "inputs":[ + { + "internalType":"bytes32", + "name":"node", + "type":"bytes32" + +} + +], + "name":"owner", + "outputs":[ + { + "internalType":"address", + "name":"", + "type":"address" + +} + +], + "payable":false, + "stateMutability":"view", + "type":"function" + +}, + { + "constant":true, + "inputs":[ + { + "internalType":"bytes32", + "name":"node", + "type":"bytes32" + +} + +], + "name":"recordExists", + "outputs":[ + { + "internalType":"bool", + "name":"", + "type":"bool" + +} + +], + "payable":false, + "stateMutability":"view", + "type":"function" + +}, + { + "constant":true, + "inputs":[ + { + "internalType":"bytes32", + "name":"node", + "type":"bytes32" + +} + +], + "name":"resolver", + "outputs":[ + { + "internalType":"address", + "name":"", + "type":"address" + +} + +], + "payable":false, + "stateMutability":"view", + "type":"function" + +}, + { + "constant":false, + "inputs":[ + { + "internalType":"address", + "name":"operator", + "type":"address" + +}, + { + "internalType":"bool", + "name":"approved", + "type":"bool" + +} + +], + "name":"setApprovalForAll", + "outputs":[ + + +], + "payable":false, + "stateMutability":"nonpayable", + "type":"function" + +}, + { + "constant":false, + "inputs":[ + { + "internalType":"bytes32", + "name":"node", + "type":"bytes32" + +}, + { + "internalType":"address", + "name":"owner", + "type":"address" + +} + +], + "name":"setOwner", + "outputs":[ + + +], + "payable":false, + "stateMutability":"nonpayable", + "type":"function" + +}, + { + "constant":false, + "inputs":[ + { + "internalType":"bytes32", + "name":"node", + "type":"bytes32" + +}, + { + "internalType":"address", + "name":"owner", + "type":"address" + +}, + { + "internalType":"address", + "name":"resolver", + "type":"address" + +}, + { + "internalType":"uint64", + "name":"ttl", + "type":"uint64" + +} + +], + "name":"setRecord", + "outputs":[ + + +], + "payable":false, + "stateMutability":"nonpayable", + "type":"function" + +}, + { + "constant":false, + "inputs":[ + { + "internalType":"bytes32", + "name":"node", + "type":"bytes32" + +}, + { + "internalType":"address", + "name":"resolver", + "type":"address" + +} + +], + "name":"setResolver", + "outputs":[ + + +], + "payable":false, + "stateMutability":"nonpayable", + "type":"function" + +}, + { + "constant":false, + "inputs":[ + { + "internalType":"bytes32", + "name":"node", + "type":"bytes32" + +}, + { + "internalType":"bytes32", + "name":"label", + "type":"bytes32" + +}, + { + "internalType":"address", + "name":"owner", + "type":"address" + +} + +], + "name":"setSubnodeOwner", + "outputs":[ + { + "internalType":"bytes32", + "name":"", + "type":"bytes32" + +} + +], + "payable":false, + "stateMutability":"nonpayable", + "type":"function" + +}, + { + "constant":false, + "inputs":[ + { + "internalType":"bytes32", + "name":"node", + "type":"bytes32" + +}, + { + "internalType":"bytes32", + "name":"label", + "type":"bytes32" + +}, + { + "internalType":"address", + "name":"owner", + "type":"address" + +}, + { + "internalType":"address", + "name":"resolver", + "type":"address" + +}, + { + "internalType":"uint64", + "name":"ttl", + "type":"uint64" + +} + +], + "name":"setSubnodeRecord", + "outputs":[ + + +], + "payable":false, + "stateMutability":"nonpayable", + "type":"function" + +}, + { + "constant":false, + "inputs":[ + { + "internalType":"bytes32", + "name":"node", + "type":"bytes32" + +}, + { + "internalType":"uint64", + "name":"ttl", + "type":"uint64" + +} + +], + "name":"setTTL", + "outputs":[ + + +], + "payable":false, + "stateMutability":"nonpayable", + "type":"function" + +}, + { + "constant":true, + "inputs":[ + { + "internalType":"bytes32", + "name":"node", + "type":"bytes32" + +} + +], + "name":"ttl", + "outputs":[ + { + "internalType":"uint64", + "name":"", + "type":"uint64" + +} + +], + "payable":false, + "stateMutability":"view", + "type":"function" + +} +] diff --git a/cert_issuer/blockchain_handlers/ethereum_sc/data/ens_resolver_abi.json b/cert_issuer/blockchain_handlers/ethereum_sc/data/ens_resolver_abi.json new file mode 100644 index 00000000..801c9be0 --- /dev/null +++ b/cert_issuer/blockchain_handlers/ethereum_sc/data/ens_resolver_abi.json @@ -0,0 +1,842 @@ +[ + { + "constant": true, + "inputs": [ + { + "internalType": "bytes4", + "name": "interfaceID", + "type": "bytes4" + } + ], + "name": "supportsInterface", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "payable": false, + "stateMutability": "pure", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "setDNSRecords", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "string", + "name": "key", + "type": "string" + }, + { + "internalType": "string", + "name": "value", + "type": "string" + } + ], + "name": "setText", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "bytes4", + "name": "interfaceID", + "type": "bytes4" + } + ], + "name": "interfaceImplementer", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "contentTypes", + "type": "uint256" + } + ], + "name": "ABI", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "", + "type": "bytes" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "x", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "y", + "type": "bytes32" + } + ], + "name": "setPubkey", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "bytes", + "name": "hash", + "type": "bytes" + } + ], + "name": "setContenthash", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + } + ], + "name": "addr", + "outputs": [ + { + "internalType": "address payable", + "name": "", + "type": "address" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "target", + "type": "address" + }, + { + "internalType": "bool", + "name": "isAuthorised", + "type": "bool" + } + ], + "name": "setAuthorisation", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "name", + "type": "bytes32" + } + ], + "name": "hasDNSRecords", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "string", + "name": "key", + "type": "string" + } + ], + "name": "text", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "contentType", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "setABI", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + } + ], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "string", + "name": "name", + "type": "string" + } + ], + "name": "setName", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "coinType", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "a", + "type": "bytes" + } + ], + "name": "setAddr", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "name", + "type": "bytes32" + }, + { + "internalType": "uint16", + "name": "resource", + "type": "uint16" + } + ], + "name": "dnsRecord", + "outputs": [ + { + "internalType": "bytes", + "name": "", + "type": "bytes" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + } + ], + "name": "clearDNSZone", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + } + ], + "name": "contenthash", + "outputs": [ + { + "internalType": "bytes", + "name": "", + "type": "bytes" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + } + ], + "name": "pubkey", + "outputs": [ + { + "internalType": "bytes32", + "name": "x", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "y", + "type": "bytes32" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "a", + "type": "address" + } + ], + "name": "setAddr", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "bytes4", + "name": "interfaceID", + "type": "bytes4" + }, + { + "internalType": "address", + "name": "implementer", + "type": "address" + } + ], + "name": "setInterface", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "coinType", + "type": "uint256" + } + ], + "name": "addr", + "outputs": [ + { + "internalType": "bytes", + "name": "", + "type": "bytes" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "authorisations", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract ENS", + "name": "_ens", + "type": "address" + } + ], + "payable": false, + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "target", + "type": "address" + }, + { + "indexed": false, + "internalType": "bool", + "name": "isAuthorised", + "type": "bool" + } + ], + "name": "AuthorisationChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "string", + "name": "indexedKey", + "type": "string" + }, + { + "indexed": false, + "internalType": "string", + "name": "key", + "type": "string" + } + ], + "name": "TextChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "x", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "y", + "type": "bytes32" + } + ], + "name": "PubkeyChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "string", + "name": "name", + "type": "string" + } + ], + "name": "NameChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "bytes4", + "name": "interfaceID", + "type": "bytes4" + }, + { + "indexed": false, + "internalType": "address", + "name": "implementer", + "type": "address" + } + ], + "name": "InterfaceChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "name", + "type": "bytes" + }, + { + "indexed": false, + "internalType": "uint16", + "name": "resource", + "type": "uint16" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "record", + "type": "bytes" + } + ], + "name": "DNSRecordChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "name", + "type": "bytes" + }, + { + "indexed": false, + "internalType": "uint16", + "name": "resource", + "type": "uint16" + } + ], + "name": "DNSRecordDeleted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + } + ], + "name": "DNSZoneCleared", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "hash", + "type": "bytes" + } + ], + "name": "ContenthashChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "address", + "name": "a", + "type": "address" + } + ], + "name": "AddrChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "coinType", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "newAddress", + "type": "bytes" + } + ], + "name": "AddressChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "node", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "contentType", + "type": "uint256" + } + ], + "name": "ABIChanged", + "type": "event" + } +] diff --git a/cert_issuer/blockchain_handlers/ethereum_sc/ens.py b/cert_issuer/blockchain_handlers/ethereum_sc/ens.py new file mode 100644 index 00000000..9edf1e76 --- /dev/null +++ b/cert_issuer/blockchain_handlers/ethereum_sc/ens.py @@ -0,0 +1,61 @@ +from ens import ENS +import json +from cert_issuer.blockchain_handlers.ethereum_sc.connectors import EthereumSCServiceProviderConnector +from web3 import Web3, HTTPProvider + +from cert_core import Chain + +class ENSConnector(object): + def __init__(self, app_config): + self.app_config = app_config + self._w3 = Web3(HTTPProvider()) + + def get_registry_address(self): + if self.app_config.chain == Chain.ethereum_ropsten: + addr = self.app_config.ens_registry_ropsten + else: + addr = self.app_config.ens_registry_mainnet + + return self._w3.toChecksumAddress(addr) + + def get_registry_contract(self): + registry_addr = self.get_registry_address() + ens_registry = EthereumSCServiceProviderConnector( + self.app_config, + contract_address=registry_addr, + abi_type="ens_registry") + return ens_registry + + def get_resolver_address(self): + ens_registry = self.get_registry_contract() + ens_name = self.app_config.ens_name + node = self.get_node(ens_name) + resolver_addr = ens_registry.call("resolver", node) + return self._w3.toChecksumAddress(resolver_addr) + + def get_resolver_contract(self): + resolver_addr = self.get_resolver_address() + ens_resolver = EthereumSCServiceProviderConnector( + self.app_config, + contract_address=resolver_addr, + abi_type="ens_resolver") + return ens_resolver + + def get_node(self, ens_name): + return ENS.namehash(ens_name) + + def get_abi(self): + ens_resolver = self.get_resolver_contract() + + node = self.get_node(self.app_config.ens_name) + + abi = ens_resolver.call("ABI", node, 1) # 1 for content type json + return json.loads(abi[1]) + + def get_addr(self): + ens_resolver = self.get_resolver_contract() + + node = self.get_node(self.app_config.ens_name) + + addr = ens_resolver.call("addr", node) + return addr diff --git a/cert_issuer/blockchain_handlers/ethereum_sc/signer.py b/cert_issuer/blockchain_handlers/ethereum_sc/signer.py new file mode 100644 index 00000000..dcddd8c5 --- /dev/null +++ b/cert_issuer/blockchain_handlers/ethereum_sc/signer.py @@ -0,0 +1,35 @@ +import rlp +# from ethereum import transactions +# from ethereum.utils import encode_hex +from web3 import Web3, HTTPProvider + +from cert_issuer.errors import UnableToSignTxError +from cert_issuer.models import Signer + + +class EthereumSCSigner(Signer): + def __init__(self, ethereum_chain): + self.ethereum_chain = ethereum_chain + # Netcode ensures replay protection (see EIP155) + if ethereum_chain.external_display_value == 'ethereumMainnet': + self.netcode = 1 + elif ethereum_chain.external_display_value == 'ethereumRopsten': + self.netcode = 3 + else: + self.netcode = None + + # wif = unencrypted private key as string in the first line of the supplied private key file + def sign_message(self, wif, message_to_sign): + pass + + def sign_transaction(self, wif, transaction_to_sign): + # try to sign the transaction. + + self.w3 = Web3(HTTPProvider()) + acct = self.w3.eth.account.from_key(wif) + + try: + signed_tx = acct.sign_transaction(transaction_to_sign) + return signed_tx + except Exception as msg: + raise UnableToSignTxError('You are trying to sign a non transaction type') diff --git a/cert_issuer/blockchain_handlers/ethereum_sc/transaction_handlers.py b/cert_issuer/blockchain_handlers/ethereum_sc/transaction_handlers.py new file mode 100644 index 00000000..60721188 --- /dev/null +++ b/cert_issuer/blockchain_handlers/ethereum_sc/transaction_handlers.py @@ -0,0 +1,55 @@ +import logging + +from cert_issuer.errors import InsufficientFundsError +from cert_issuer.models import TransactionHandler +from cert_issuer.signer import FinalizableSigner + + +class EthereumSCTransactionHandler(TransactionHandler): + def __init__(self, connector, tx_cost_constants, secret_manager, issuing_address, prepared_inputs=None): + self.connector = connector + self.tx_cost_constants = tx_cost_constants + self.secret_manager = secret_manager + self.issuing_address = issuing_address + # input transactions are not needed for Ether + self.prepared_inputs = prepared_inputs + + def ensure_balance(self): + self.balance = self.connector.get_balance(self.issuing_address) + + transaction_cost = self.tx_cost_constants.get_recommended_max_cost() + logging.info('Total cost will be %d wei', transaction_cost) + + if transaction_cost > self.balance: + error_message = 'Please add {} wei to the address {}'.format( + transaction_cost - self.balance, self.issuing_address) + logging.error(error_message) + raise InsufficientFundsError(error_message) + + def revoke_transaction(self, blockchain_bytes, app_config): + return self.make_transaction(blockchain_bytes, app_config, "revoke_hash") + + def issue_transaction(self, blockchain_bytes, app_config): + return self.make_transaction(blockchain_bytes, app_config, "issue_hash") + + def make_transaction(self, blockchain_bytes, app_config, method): + prepared_tx = self.connector.create_transaction(method, blockchain_bytes) + signed_tx = self.sign_transaction(prepared_tx) + + logging.info('Broadcasting transaction to the blockchain...') + + txid = self.broadcast_transaction(signed_tx) + return txid + + def sign_transaction(self, prepared_tx): + # stubbed from BitcoinTransactionHandler + + with FinalizableSigner(self.secret_manager) as signer: + signed_tx = signer.sign_transaction(prepared_tx) + + logging.info('signed Ethereum trx = %s', signed_tx) + return signed_tx + + def broadcast_transaction(self, signed_tx): + txid = self.connector.broadcast_tx(signed_tx) + return txid diff --git a/cert_issuer/certificate_handlers.py b/cert_issuer/certificate_handlers.py index c1f050d0..fe969dc8 100644 --- a/cert_issuer/certificate_handlers.py +++ b/cert_issuer/certificate_handlers.py @@ -2,13 +2,13 @@ import logging from cert_schema import normalize_jsonld -from cert_schema import validate_v2 from cert_issuer import helpers from pycoin.serialize import b2h from cert_issuer.models import CertificateHandler, BatchHandler from cert_issuer.signer import FinalizableSigner + class CertificateV2Handler(CertificateHandler): def get_byte_array_to_issue(self, certificate_metadata): certificate_json = self._get_certificate_to_issue(certificate_metadata) @@ -32,6 +32,7 @@ def _get_certificate_to_issue(self, certificate_metadata): certificate_json = json.load(unsigned_cert_file) return certificate_json + class CertificateWebV2Handler(CertificateHandler): def get_byte_array_to_issue(self, certificate_json): normalized = normalize_jsonld(certificate_json, detect_unmapped_fields=False) @@ -46,10 +47,11 @@ def add_proof(self, certificate_json, merkle_proof): certificate_json['signature'] = merkle_proof return certificate_json + class CertificateBatchWebHandler(BatchHandler): - def finish_batch(self, tx_id, chain): + def finish_batch(self, tx_id, chain, app_config): self.proof = [] - proof_generator = self.merkle_tree.get_proof_generator(tx_id, chain) + proof_generator = self.merkle_tree.get_proof_generator(tx_id, app_config, chain) for metadata in self.certificates_to_issue: proof = next(proof_generator) self.proof.append(self.certificate_handler.add_proof(metadata, proof)) @@ -69,7 +71,7 @@ def prepare_batch(self): Propagates exception on failure :return: byte array to put on the blockchain """ - + for cert in self.certificates_to_issue: self.certificate_handler.validate_certificate(cert) @@ -86,7 +88,7 @@ class CertificateBatchHandler(BatchHandler): """ def pre_batch_actions(self, config): self._process_directories(config) - + def post_batch_actions(self, config): helpers.copy_output(self.certificates_to_issue) logging.info('Your Blockchain Certificates are in %s', config.blockchain_certificates_dir) @@ -119,8 +121,8 @@ def get_certificate_generator(self): data_to_issue = self.certificate_handler.get_byte_array_to_issue(metadata) yield data_to_issue - def finish_batch(self, tx_id, chain): - proof_generator = self.merkle_tree.get_proof_generator(tx_id, chain) + def finish_batch(self, tx_id, chain, app_config): + proof_generator = self.merkle_tree.get_proof_generator(tx_id, app_config, chain) for _, metadata in self.certificates_to_issue.items(): proof = next(proof_generator) self.certificate_handler.add_proof(metadata, proof) @@ -130,7 +132,7 @@ def _process_directories(self, config): signed_certs_dir = config.signed_certificates_dir blockchain_certificates_dir = config.blockchain_certificates_dir work_dir = config.work_dir - + certificates_metadata = helpers.prepare_issuance_batch( unsigned_certs_dir, signed_certs_dir, @@ -144,4 +146,3 @@ def _process_directories(self, config): logging.info('Processing %d certificates under work path=%s', num_certificates, work_dir) self.set_certificates_in_batch(certificates_metadata) - diff --git a/cert_issuer/config.py b/cert_issuer/config.py index bbdfbc2e..faffa53d 100644 --- a/cert_issuer/config.py +++ b/cert_issuer/config.py @@ -31,8 +31,12 @@ def configure_logger(): # restructured arguments to put the chain specific arguments together. def add_arguments(p): + # invoked from cli p.add('-c', '--my-config', required=False, env_var='CONFIG_FILE', is_config_file=True, help='config file path') + p.add('-r', '--revoke', required=False, action='store_true', help='revoke certificates in file set in revocation_list_file') + + # 'invoked' through config file p.add_argument('--issuing_address', required=True, help='issuing address', env_var='ISSUING_ADDRESS') p.add_argument('--usb_name', required=True, help='usb path to key_file', env_var='USB_NAME') p.add_argument('--key_file', required=True, @@ -70,13 +74,25 @@ def add_arguments(p): # ethereum arguments p.add_argument('--gas_price', default=20000000000, type=int, help='decide the price per gas spent (in wei (smallest ETH unit))', env_var='GAS_PRICE') - p.add_argument('--gas_limit', default=25000, type=int, + p.add_argument('--gas_limit', default=60000, type=int, help='decide on the maximum spendable gas. gas_limit < 25000 might not be sufficient', env_var='GAS_LIMIT') p.add_argument('--api_token', default=None, type=str, help='the API token of the blockchain broadcaster you are using. Currently Etherscan only supported.', env_var='API_TOKEN') p.add_argument('--blockcypher_api_token', default=None, type=str, help='the API token of the blockcypher broadcaster', env_var='BLOCKCYPHER_API_TOKEN') - + # ethereum smart contract arguments + p.add_argument('--node_url', default=None, required=False, + help='issuing public node url (infura)', env_var='NODE_URL') + p.add_argument('--issuing_method', default=None, + help='issuing method for ethereum blockchain', env_var='ISSUING_METHOD') + p.add_argument('--ens_name', default=None, + help='ens_name that points to the smart contract to which to issue', env_var='ENS_NAME') + p.add_argument('--revocation_list_file', required=False, + help='list of certificates or batches to be revokes', env_var='REVOCATION_LIST_FILE') + p.add_argument('--ens_registry_ropsten', required=False, default="0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", + help='ENS registry address on ropsten', env_var='ENS_RESGISTRY_ROPSTEN') + p.add_argument('--ens_registry_mainnet', required=False, default="0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", + help='ENS registry address on ropsten', env_var='ENS_RESGISTRY_MAINNET') def get_config(): configure_logger() diff --git a/cert_issuer/errors.py b/cert_issuer/errors.py index c5b65386..588267b8 100644 --- a/cert_issuer/errors.py +++ b/cert_issuer/errors.py @@ -68,3 +68,15 @@ class UnrecognizedChainError(Error): Didn't recognize chain """ pass + +class ENSEntryError(Error): + """ + Failure to resolve valid address from ENS + """ + pass + +class MissingArgumentError(Error): + """ + Missing required argument + """ + pass diff --git a/cert_issuer/issue_certificates.py b/cert_issuer/issue_certificates.py index 3cd2c092..feeb4345 100644 --- a/cert_issuer/issue_certificates.py +++ b/cert_issuer/issue_certificates.py @@ -4,6 +4,7 @@ from cert_core import Chain from cert_issuer.issuer import Issuer +from cert_issuer.revoker import Revoker if sys.version_info.major < 3: sys.stderr.write('Sorry, Python 3.x required by this script.\n') @@ -19,17 +20,33 @@ def issue(app_config, certificate_batch_handler, transaction_handler): certificate_batch_handler=certificate_batch_handler, transaction_handler=transaction_handler, max_retry=app_config.max_retry) - tx_id = issuer.issue(app_config.chain) + tx_id = issuer.issue(app_config.chain, app_config) certificate_batch_handler.post_batch_actions(app_config) return tx_id +def revoke_certificates(app_config, transaction_handler): + # revocations are executed one hash at a time - balance is ensure before each tx + # transaction_handler.ensure_balance() + + revoker = Revoker( + transaction_handler=transaction_handler, + max_retry=app_config.max_retry) + tx_id = revoker.revoke(app_config) + + return tx_id def main(app_config): chain = app_config.chain if chain == Chain.ethereum_mainnet or chain == Chain.ethereum_ropsten: - from cert_issuer.blockchain_handlers import ethereum - certificate_batch_handler, transaction_handler, connector = ethereum.instantiate_blockchain_handlers(app_config) + if app_config.issuing_method == "smart_contract": + from cert_issuer.blockchain_handlers import ethereum_sc + certificate_batch_handler, transaction_handler, connector = ethereum_sc.instantiate_blockchain_handlers(app_config) + if app_config.revoke is True: + return revoke_certificates(app_config, transaction_handler) + else: + from cert_issuer.blockchain_handlers import ethereum + certificate_batch_handler, transaction_handler, connector = ethereum.instantiate_blockchain_handlers(app_config) else: from cert_issuer.blockchain_handlers import bitcoin certificate_batch_handler, transaction_handler, connector = bitcoin.instantiate_blockchain_handlers(app_config) @@ -42,6 +59,7 @@ def main(app_config): try: parsed_config = config.get_config() tx_id = main(parsed_config) + if tx_id: logging.info('Transaction id is %s', tx_id) else: diff --git a/cert_issuer/issuer.py b/cert_issuer/issuer.py index 86bc94f0..dc43b228 100644 --- a/cert_issuer/issuer.py +++ b/cert_issuer/issuer.py @@ -14,7 +14,7 @@ def __init__(self, certificate_batch_handler, transaction_handler, max_retry=MAX self.transaction_handler = transaction_handler self.max_retry = max_retry - def issue(self, chain): + def issue(self, chain, app_config): """ Issue the certificates on the blockchain :return: @@ -24,8 +24,8 @@ def issue(self, chain): for attempt_number in range(0, self.max_retry): try: - txid = self.transaction_handler.issue_transaction(blockchain_bytes) - self.certificate_batch_handler.finish_batch(txid, chain) + txid = self.transaction_handler.issue_transaction(blockchain_bytes, app_config) + self.certificate_batch_handler.finish_batch(txid, chain, app_config) logging.info('Broadcast transaction with txid %s', txid) return txid except BroadcastError: diff --git a/cert_issuer/merkle_tree_generator.py b/cert_issuer/merkle_tree_generator.py index 6d10815e..e6f5e3cb 100644 --- a/cert_issuer/merkle_tree_generator.py +++ b/cert_issuer/merkle_tree_generator.py @@ -1,7 +1,8 @@ import hashlib from cert_core import Chain -from chainpoint.chainpoint import MerkleTools +# from chainpoint3.chainpoint import MerkleTools +from merkletools import MerkleTools from pycoin.serialize import h2b @@ -40,7 +41,7 @@ def get_blockchain_data(self): merkle_root = self.tree.get_merkle_root() return h2b(ensure_string(merkle_root)) - def get_proof_generator(self, tx_id, chain=Chain.bitcoin_mainnet): + def get_proof_generator(self, tx_id, app_config, chain=Chain.bitcoin_mainnet): """ Returns a generator (1-time iterator) of proofs in insertion order. @@ -59,21 +60,45 @@ def get_proof_generator(self, tx_id, chain=Chain.bitcoin_mainnet): dict2[key] = ensure_string(value) proof2.append(dict2) target_hash = ensure_string(self.tree.get_leaf(index)) - merkle_proof = { - "type": ['MerkleProof2017', 'Extension'], - "merkleRoot": root, - "targetHash": target_hash, - "proof": proof2, - "anchors": [{ - "sourceId": to_source_id(tx_id, chain), - "type": chain.blockchain_type.external_display_value, - "chain": chain.external_display_value - }]} + if app_config.issuing_method == "smart_contract": + from blockchain_handlers.ethereum_sc.ens import ENSConnector + + ens = ENSConnector(app_config) + abi = ens.get_abi() + + merkle_proof = { + "type": ['MerkleProof2017', 'Extension'], + "merkleRoot": root, + "targetHash": target_hash, + "proof": proof2, + "anchors": [{ + "sourceId": to_source_id(tx_id, chain), + "type": "ETHSmartContract", + "chain": chain.external_display_value, + "contract_address": app_config.contract_address, + "ens_name": app_config.ens_name, + "contract_abi": abi + }]} + else: + merkle_proof = { + "type": ['MerkleProof2017', 'Extension'], + "merkleRoot": root, + "targetHash": target_hash, + "proof": proof2, + "anchors": [{ + "sourceId": to_source_id(tx_id, chain), + "type": chain.blockchain_type.external_display_value, + "chain": chain.external_display_value + }]} + yield merkle_proof def to_source_id(txid, chain): - if chain == Chain.bitcoin_mainnet or Chain.bitcoin_testnet or Chain.ethereum_mainnet or Chain.ethereum_ropsten: - return txid + # workaround + return txid + # previously the == operator to actually compare with 'chain' was missing - this caused the below text to be returned, breaking the tests + if chain == Chain.bitcoin_mainnet or chain == Chain.bitcoin_testnet or chain == Chain.ethereum_mainnet or chain == Chain.ethereum_ropsten: + return txid else: return 'This has not been issued on a blockchain and is for testing only' diff --git a/cert_issuer/revoker.py b/cert_issuer/revoker.py new file mode 100644 index 00000000..0c0097cb --- /dev/null +++ b/cert_issuer/revoker.py @@ -0,0 +1,81 @@ +""" +Base class for building blockchain transactions to issue Blockchain Certificates. +""" +import logging +import json + +from pycoin.serialize import h2b + +from cert_issuer.errors import BroadcastError + +MAX_TX_RETRIES = 5 + + +def ensure_string(value): + if isinstance(value, str): + return value + return value.decode('utf-8') + + +def get_revocation_hashes(app_config): + revocation_list_file = app_config.revocation_list_file + with open(revocation_list_file, "r") as f: + data = f.read() + revocations = json.loads(data) + hashes = revocations["hashes_to_be_revoked"] + return hashes + + +def remove_from_revocations_list(app_config, hash): + revocation_list_file = app_config.revocation_list_file + with open(revocation_list_file, "r") as f: + data = f.read() + revocations = json.loads(data) + + revocations["hashes_to_be_revoked"].remove(hash) + + with open(revocation_list_file, "w+") as f: + data = json.dump(revocations, f, indent=4) + + +class Revoker: + def __init__(self, transaction_handler, max_retry=MAX_TX_RETRIES): + self.transaction_handler = transaction_handler + self.max_retry = max_retry + + def revoke(self, app_config): + """ + Revoke certificates or batches on the blockchain listed in revocation_list_file. + Multiple transactions will be executed. + :return: + """ + + hashes = get_revocation_hashes(app_config) + + tx_ids = [] + + if hashes == []: + logging.info('No hashes to revoke. Check your revocation_list_file if you meant to revoke hashes.') + return None + else: + logging.info('Revoking the following hashes: %s', hashes) + + while len(hashes) > 0: + hash = hashes.pop() + # ensure balance before every transaction + self.transaction_handler.ensure_balance() + + # transform to hex + blockchain_bytes = h2b(ensure_string(hash)) + + try: + txid = self.transaction_handler.revoke_transaction(blockchain_bytes, app_config) + logging.info('Broadcast revocation of hash %s in tx with txid %s', hash, txid) + + tx_ids.append(txid) + + remove_from_revocations_list(app_config, hash) + except BroadcastError: + logging.warning('Failed broadcast of transaction.') + + return tx_ids diff --git a/conf_ethtest.ini b/conf_ethtest.ini index 540567c6..8c4ccbeb 100644 --- a/conf_ethtest.ini +++ b/conf_ethtest.ini @@ -1,4 +1,4 @@ -issuing_address = +issuing_address = chain = @@ -13,3 +13,10 @@ blockchain_certificates_dir= work_dir= no_safe_mode + +issuing_method = +# only necessary if issuing_method is set to +node_url = +# ens_name should be set by cert-deployer +ens_name = +revocation_list_file= diff --git a/docs/ethereum_smart_contract.md b/docs/ethereum_smart_contract.md new file mode 100644 index 00000000..8fe2335e --- /dev/null +++ b/docs/ethereum_smart_contract.md @@ -0,0 +1,195 @@ +# Ethereum Smart Contract Backend +*v1.1* + +## Quick Start +1. [Create Ethereum wallet](https://www.myetherwallet.com/) +1. [Register an ENS name](app.ens.domains/) +1. Get ready to issue + 1. Install cert-deployer + 1. Configure cert-deployer + 1. Deploy smart contract +1. Issue certificates + 1. Install cert-issuer + 1. Run cert-issuer to issue certificates to the blockchain + +## Introduction +The value added by this extension lies in the move of core functionalities (e.g. issuance and revocation of certificates) to smart contracts located on the Ethereum blockchain and the utilization of the [Ethereum Name System (ENS)](https://ens.domains/) enabling validation of issuers' identities. Backwards compatibility for all tool components is ensured at any time – a flag in the corresponding config file is used to choose the desired issuing or verification method. + +## Why use the smart contract backend? +Using simple transactions on the blockchain to store merkle root hashes requires external data to be stored and queried from web servers. Each issuing institution has to host both a file proving its identity and a list of revoked certificates on a server. This approach is prone to availability and security issues – particularly for smaller institutions – as it is neither trivial to run an updated and secure web server, nor the most efficient option. Even a temporary server outage would lead to valid certificates being indistinguishable from invalid ones. Longer lasting outages could thus make existing certificates useless. + +Tackling this way of managing the issuer’s identity and the list of revoked certificates, we identified the following requirements that need to be met: + +- Maximum availability +- Consistent and continuous chain of trust +- Cost efficiency – costs per transaction have to be constant and proportional to the number of batches issued or revoked + +## Design +The issuer does no longer need to host the `issuer.json` and `revocation_list.json` files. +As a consequence, asserting issuer's identity and revoking certificates are handled directly on the blockchain. The basis of the changes we propose is the introduction of a smart contract to act as a certificate (hash) store and the Ethereum Name Service. + +Both issuing and revoking can be done via the smart contract on the Ethereum blockchain. Since the Bitcoin blockchain does not provide the required capabilities, it will not be supported. Additionally, we introduce the ENS to link human-readable names to smart contracts. As a consequence, the issuer’s identity can be publicly linked to their smart contract, when their ENS name is public knowledge. Ideally the institution asserts it on their website. + +### Revocation +The revocation process was transferred to the smart contract. Now, instead of certificate id's their hashes are used to attach a *state* to them on the blockchain. This state can be `not issued`, `revoked` or `valid`. + +Initially, as a batch is issued, the state associated with the batch's merkle root hash is set to `valid`. To revoke the batch, this state can be set to `revoked`. +Individual certificates can be revoked as well. Initially a single certificate's hash has the state `not issued`, as it was not explicitly issued. The certificate's state can be set to `revoked` the same way a batches merkle root hash can. This is reclected in the verification process as well. + +### Identity +A strong chain of trust has to be established to trust the issuer's identity. In the initial BlockCerts design this was done by hosting an issuer.json file that has to be validated by the verifier. This file is generally accessible via a URL on the certificate. The only information to be cross-referenced by humans: the domain name itself, which, for any given institution, is public knowledge. Thus, a strong chain of trust can be established – as long as the server is online. + +This issuing method moves this chain of trust completely onto the blockchain by using ENS which allows any addressable blockchain resources to be linked to a human-readable name, e.g. tu-berlin.eth. When an institution’s Blockcerts smart contract is deployed, its ENS domain is instructed to point to this contract. In any certificates issued to this contract, this ENS name will be present as the URL has been previously. If institutions advertise their domain and it becomes public knowledge, this chain of trust established supersedes the former, as ENS comes with the same availability guarantees as the blockchain itself. In the verification process only the institution's ENS name has to be manually verified, as before the institution's hostname. + +## Usage +### Smart contract +Once before starting the issuing process a smart contract has to be deployed by an institution. The contract bundles functionality for both issuing and revoking certificates. It works by storing storing a hash representation of the certificate or a batch of certificates. Each hash has one of three states associated to it: `not issued` (default), `revoked` or `valid`. +Internally the use of a mapping ensures constant complexity for both read and write access to certificate states, thus minimizing gas-costs. Since no gas-costs are incurred by calling data from the ethereum blockchain, the verification process is free of charge. + +To only allow authorized write access to the contract, it is restricted to the ethereum account that deployed the contract. Internally, this is ensured by an `only_owner` modifier. + +The contract is shipped in source with the deployer package and only compiled upon deployment onto the blockchain. This is beneficial for two reasons: First, the code can be reviewed, ensuring it contains no malevolent functions. Second, if an institution wishes to make changes to the inner workings of the contract, this can be easily done. + +### Cert-deployer +The cert-deployer package provides the tools to prepare the infrastructure necessary to issue and revoke certificates. On a basic level three things happen one after another: The contract is locally compiled from source, then deployed onto the blockchain, and lastly the institution’s ENS name is linked to the contract's address. + +This new component aims to use familiar abstractions to the rest of the projects. +The main difference is the way blockchain resources are accessed. The Web3 library is used to connect to local or public blockchain nodes to be able to call smart contract methods the same way as native method calls. This change is reflected in the cert-issuer and cert-verifier as well. + +#### Configuration +Configuration is done via the `conf_eth.ini` file. Cert-deployer's configuration closely resembles that of cert-issuer. All options are detailed in the subsequent section. + +`deploying_address = ` +The ethereum account's address. + +`chain = ` +Choice of deployment on Ethereum Mainnet or the Ropsten test network. + +`node_url = ` +The web3py library requires an ethereum node that is compatible with the json-rpc interface. The easiest option is to use a public node e.g. infura’s, or connect to a locally-run node such as geth or parity. + +`ens_name = ` +The institution's ENS name - has to be registered beforehand via the [ENS Management App](https://app.ens.domains/). + +`overwrite_ens_link = ` +In a scenario, where an ENS domain already points to an existing smart contract, this flag has to be explicitly set to overwrite this link. This is meant to prevent accidental loss of data. This should normally be set to False, except you explicitly want to deploy a new contract that your ENS entry points to. + +`usb_name= ` +`key_file= ` +Path and file name of the account's private key. + +#### Setup and requirements +There are two administrative requirements potential issuers have to meet before they can start deploying smart contracts. +1. Have an Ethereum wallet with enough currency +1. Be owner or controller of an [ENS domain](app.ens.domains/) +1. Issue v2 certificates + +All dependencies required can be installed by running (preferrably inside a [virtual environment](docs/virtualenv.md)): + +`python setup.py install` + +### Cert-issuer +The issuer was subject to two main changes: First, another Ethereum blockchain handler was added that enables interaction with smart contract functions via Web3. On this basis, the ability to issue and revoke certificates was set up and some configuration options were added for these features. + +#### Components +The main contribution to the issuer can be found in the addition of an ethereum_sc blockchain handler. Being a wrapper for the web3 library this module enables the issuer to interact with smart contracts in general and, via the interface model defined in `ServiceProviderConnector`, implements the same interface as the other blockchain handlers. Thus, only minor adjustments were required to enable issuing of certificate hashes to the smart contract. +The main changes made to the existing program logic were needed to embed the necessary information about how to verify within the certificates. This information includes a valid contract address and contract abi. More information on this can be found in the cert-schema section. The required information is generated in `merkle_tree_generator.py`. + +#### Revocation +The cert-issuer tool is also used to revoke certificates. As opposed to using certificate IDs to identify certificates in the revocation list, certificate and merkle root hashes are used for a reduced footprint on the blockchain. When running with the `--revoke` flag, a json file containing a list of hashes to be revoked is referenced. One by one, the hashes are processed. In case of failure, the hashes that have not been revoked at that point are written back into the file. + +The `revocations.json` should be of the following format: +```json +{ + "hashes_to_be_revoked": [ + "637ec732fa4b7b56f4c15a6a12680519a17a9e9eade09f5b424a48eb0e6f5ad0" + ] +} + +``` + +#### Configuration +The following options were added: + +`issuing_method = ` +This indicates whether to use the smart contract backend or the current transaction-based approach. As explained below, due to dependency clashes this distinction also has to be made at install time. + +`node_url = ` +The web3py library requires an ethereum node that is compatible with the json-rpc interface. The easiest option is to use a public node e.g. infura’s, or connect to a locally-run node such as geth or parity. + +`ens_name = ` +The ENS domain that points to a smart contract deployed with cert-deployer. + +`revocation_list_file = ` +This file lists certificates that will be revoked when passing the --revoke flag when running from the command line. + +#### Setup and requirements +The smart contract backend requires the web3 module to interact with the blockchain. This dependency is incompatible with the ethereum module required by the current implementation. For this reason, there is an install-time option to install the smart contract backend. The use of a [virtual environment](virtualenv.md) is highly recommended. + +`python setup.py install experimental --blockchain=ethereum_smart_contract` + +Backwards compatibility is preserved insofar as that the current implementation can be installed in a separate virtual environment. To switch, there is only one flag to be adapted in the config file. We further switched to the chainpoint3 library, a fork of the chainpoint library, as there was another dependency conflict. + +### Cert-verifier +#### Description +The verifier has been extended to support the usage of issuer smart contracts while ensuring backwards compatibility. +All necessary information is embedded into the certificate (also see [cert-schema](docs/ethereum_smart_contract.md#cert-schema)). Using ENS entries, it is verified if a given smart contract belongs to the issuer. + +Following checks are done to verify a certificate, which is issued with the new method: + +1. Tamper Check - *same as before* +1. Expiration Check - *same as before* +1. Revocation / Validity Check +1. ENS Check + +In order to prevent attacks, where a smart contract is spoofed the ENS name has to be verified manually. + +#### Tamper Check +We adjusted the tamper check to support our implementation. This way, the verifier is able to check if a certificate has been tampered or not. The relevant fields are hashed and compared to the provided target hash. The merkle proof is calculated the same as before and will cause the validation to fail if the target hash does not belong to the merkle root hash. + +#### Expiration Check +For the expiration check there were no changes made. + +#### Revocation / Validity check +The issuer owned smart contract provides a function, which returns the state associated to any given hash. +Possible states are `not_issued`, `revoked` or `valid`. +To verify if a certificate is valid, the following checks are done: +- If merkle root hash and target hash differ from each other, the merkle root hash should be `valid` and not `revoked`, while the target hash should be `not_issued` (as it wasn't explicitly issued). +- If the batch consists of only one certificate i.e. merkle root hash and target hash are equal, the merkle root hash should be `issued` and not `revoked`. + +#### ENS Check +Since ENS is our trust anchor, we have added a new verification step. This verification step compares the address in the ENS Name with the smart contract address embedded in the anchors source id field and checks if they match. If there is an attempt to change the ENS Name in the certificate, the verifier will mark the validation as failed. + +#### Configuration +The config file is used to set Ethereum node addresses to be used in the verifying process. + +#### Setup +All dependencies required can be installed by running (preferrably inside a [virtual environment](docs/virtualenv.md)): + +`python setup.py install` + +### Cert-schema +#### Description +In order to verify certificates issued to a smart contract the necessary information needs to be stored in the certificate file. +The V2 schema was slightly modified by adding additional fields into the `anchors` sections to hold the additional information necessary to verify certificates issued to the smart contract. + +#### Changes +The `anchors` field looks as follows: + +`ens_name` +Contains an ENS name of a certificate issuer. + +`sourceId` +Contains the smart contract's address that this certificate was issued to. This value is compared to the address the ENS name points to. + +`type` +Is used by the verifier to identify the method to use in the verification process. Example: `["type" : "ETHSmartContract"]` + +`chain` +Which chain the certificate was issued to. As smart contracts are not supported by the Bitcoin blockchain, only the Ethereum chains are supported. + +`contract_abi` +The application binary interface (ABI) is necessary to communicate with the smart contract. + +`chain`, `type` and `sourceId` are present in the [chainpoint v2 schema](https://chainpoint.org/) used. +The additional fields used are therefore non-standard extensions. Ideally, a way is found to incorporate the functionality into the standard or a way is found to offer the same functionality while adhering to the standard. diff --git a/ethereum_requirements.txt b/ethereum_requirements.txt index 23c4429f..08f2e957 100644 --- a/ethereum_requirements.txt +++ b/ethereum_requirements.txt @@ -1,3 +1,3 @@ coincurve==7.1.0 ethereum==2.3.1 -rlp<1 \ No newline at end of file +rlp<1 diff --git a/ethereum_smart_contract_requirements.txt b/ethereum_smart_contract_requirements.txt new file mode 100644 index 00000000..b9e889d4 --- /dev/null +++ b/ethereum_smart_contract_requirements.txt @@ -0,0 +1,2 @@ +coincurve==7.1.0 +web3>=5 diff --git a/requirements.txt b/requirements.txt index e37c7d3d..fd717916 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ cert-core>=2.1.9 cert-schema>=2.1.5 -chainpoint>=0.0.2 +chainpoint3>=0.0.2 configargparse==0.12.0 glob2==0.6 mock==2.0.0 @@ -10,4 +10,4 @@ pyld>=1.0.3 pysha3>=1.0.2 python-bitcoinlib>=0.10.1 tox>=3.0.0 -jsonschema<3.0.0 \ No newline at end of file +jsonschema<3 diff --git a/revocations.json b/revocations.json new file mode 100644 index 00000000..12991579 --- /dev/null +++ b/revocations.json @@ -0,0 +1,3 @@ +{ + "hashes_to_be_revoked": [] +} diff --git a/setup.py b/setup.py index e440c06e..57782b72 100644 --- a/setup.py +++ b/setup.py @@ -28,7 +28,7 @@ def initialize_options(self): self.blockchain = 'bitcoin' def finalize_options(self): - assert self.blockchain in ('bitcoin', 'ethereum'), 'Invalid blockchain!' + assert self.blockchain in ('bitcoin', 'ethereum', 'ethereum_smart_contract'), 'Invalid blockchain!' def run(self): if self.blockchain == 'ethereum': @@ -36,6 +36,12 @@ def run(self): install_reqs = f.readlines() eth_reqs = [str(ir) for ir in install_reqs] reqs.extend(eth_reqs) + + if self.blockchain == 'ethereum_smart_contract': + with open('ethereum_smart_contract_requirements.txt') as f: + install_reqs = f.readlines() + eth_reqs = [str(ir) for ir in install_reqs] + reqs.extend(eth_reqs) else: with open('bitcoin_requirements.txt') as f: install_reqs = f.readlines() diff --git a/test_setup.sh b/test_setup.sh new file mode 100755 index 00000000..432ea734 --- /dev/null +++ b/test_setup.sh @@ -0,0 +1,6 @@ +#! /bin/sh +mkdir -p venv/ +rm -r venv/test/ +virtualenv venv/test +source venv/test/bin/activate +python setup.py install experimental --blockchain=ethereum_smart_contract diff --git a/tests/test_certificate_handler.py b/tests/test_certificate_handler.py index 4975413c..6aee45e7 100644 --- a/tests/test_certificate_handler.py +++ b/tests/test_certificate_handler.py @@ -121,6 +121,9 @@ def test_batch_handler_prepare_batch(self): b2h(result), '0932f1d2e98219f7d7452801e2b64ebd9e5c005539db12d9b1ddabe7834d9044') def test_batch_web_handler_finish_batch(self): + app_config = mock.Mock() + app_config.issuing_method == "transaction" + certificate_batch_handler, certificates_to_issue = self._get_certificate_batch_web_handler() certificate_batch_handler.set_certificates_in_batch(certificates_to_issue) @@ -131,7 +134,7 @@ def test_batch_web_handler_finish_batch(self): with patch.object(DummyCertificateHandler, 'add_proof', return_value= {"cert": "cert"} ) as mock_method: result = certificate_batch_handler.finish_batch( - '5604f0c442922b5db54b69f8f363b3eac67835d36a006b98e8727f83b6a830c0', chain + '5604f0c442922b5db54b69f8f363b3eac67835d36a006b98e8727f83b6a830c0', chain, app_config ) self.assertEqual(certificate_batch_handler.proof, [{'cert': 'cert'}, {'cert': 'cert'}, {'cert': 'cert'}]) mock_method.assert_any_call(ANY, proof) @@ -139,6 +142,9 @@ def test_batch_web_handler_finish_batch(self): mock_method.assert_any_call(ANY, proof_2) def test_batch_handler_finish_batch(self): + app_config = mock.Mock() + app_config.issuing_method = "transaction" + certificate_batch_handler, certificates_to_issue = self._get_certificate_batch_handler() certificate_batch_handler.set_certificates_in_batch(certificates_to_issue) @@ -149,7 +155,7 @@ def test_batch_handler_finish_batch(self): with patch.object(DummyCertificateHandler, 'add_proof') as mock_method: result = certificate_batch_handler.finish_batch( - '5604f0c442922b5db54b69f8f363b3eac67835d36a006b98e8727f83b6a830c0', chain + '5604f0c442922b5db54b69f8f363b3eac67835d36a006b98e8727f83b6a830c0', chain, app_config ) mock_method.assert_any_call(ANY, proof) diff --git a/tests/test_merkle_tree_generator.py b/tests/test_merkle_tree_generator.py index 20bd788d..2bf3d5af 100644 --- a/tests/test_merkle_tree_generator.py +++ b/tests/test_merkle_tree_generator.py @@ -1,5 +1,7 @@ import unittest +import mock + from cert_core import Chain from pycoin.serialize import b2h @@ -35,11 +37,15 @@ def test_proofs_mock(self): self.do_test_signature(Chain.mockchain, 'mockchain', 'Mock') def do_test_signature(self, chain, display_chain, type): + self.maxDiff = None + app_config = mock.Mock() + app_config.issuing_method = "transaction" + merkle_tree_generator = MerkleTreeGenerator() merkle_tree_generator.populate(get_test_data_generator()) _ = merkle_tree_generator.get_blockchain_data() gen = merkle_tree_generator.get_proof_generator( - '8087c03e7b7bc9ca7b355de9d9d8165cc5c76307f337f0deb8a204d002c8e582', chain) + '8087c03e7b7bc9ca7b355de9d9d8165cc5c76307f337f0deb8a204d002c8e582', app_config, chain) p1 = next(gen) _ = next(gen) p3 = next(gen)