diff --git a/chainlink.py b/chainlink.py index f3afd1d..4cd1a09 100644 --- a/chainlink.py +++ b/chainlink.py @@ -1,305 +1,409 @@ -# Copyright (c) 2024, Circle Internet Financial, LTD. All rights reserved. -# -# SPDX-License-Identifier: Apache-2.0 -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -USAGE_INSTRUCTIONS = """ -# Instructions for calling chainlink.py from the command line. This works with -# both local anvil nodes and testnet/mainnet. The accounts and rpc_url are specified -# in the KEY_FILE. All other configuration is in CONFIG_FILE, - -# Deploy the Chainmail contract to the network. Automatically stores contract addresss in ENV_CONFIG_FILE -# where it will be used by other commands. -python chainlink.py deploy - -# register an email at the Chainmail contract specified in file ENV_CONFIG_FILE -# will automatically casefold all input so verification is not case-sensitive. -python chainlink.py register-email -python chainlink.py register-email user@testdomain.com 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 4DD9C7CA778A0BCFCF0A4635294DADB0D448AC5E - -# register the contents of a file to the sender address in CONFIG_FILE -# uses ENV_CONFIG_FILE to determine the contract address -python chainlink.py register-message-file - -# outputs the keccak hash of the email address (lowercase) -python chainlink.py hash-email - -# outputs the keccak hash of a file after replacing all whitespace -python chainlink.py hash-file - -# verifies if the fingerprint and email have been registered (not case sensitive) -python chainlink.py verify - -# verifies if the email message has been registered by sender email address -python chainlink.py verify-file -""" - -from chainmail import verify_signature -import eth_abi -import os -from Crypto.Hash import keccak -import re -import sys -import yaml - -CONFIG_FILE = './config.yaml' -CONFIG = yaml.safe_load(open(CONFIG_FILE, 'r')) -contract_file = CONFIG['chainlink']['contract_file'] -fun_register_email_address = CONFIG['chainlink']['register_email_address'] -fun_register_email_message = CONFIG['chainlink']['register_email_message'] -fun_email_address_info = CONFIG['chainlink']['email_address_info'] -fun_verify_email_message = CONFIG['chainlink']['verify_email_message'] - -KEY_FILE = CONFIG['local_key_file'] -KEY = yaml.safe_load(open(KEY_FILE, 'r')) -owner_private_key = KEY['testnet_account']['private_key'] -sender_private_key = KEY['testnet_sender']['private_key'] -sender_address = KEY['testnet_sender']['address'] -rpc_url = KEY['rpc_url'] -etherscan_api_key = KEY['etherscan_api_key'] - -ENV_CONFIG_FILE = CONFIG['chainlink']['local_env_file'] - -# Saves the state of the local environment to ENV_CONFIG_FILE. -def save_env(chainmail_address): - env = dict() - env['contract_address'] = chainmail_address - file = open(ENV_CONFIG_FILE, 'w') - yaml.dump(env, file) - -# Read ENV_CONFIG_FILE to get address of Chainmail contract -def get_chainmail_address(): - if os.path.exists(ENV_CONFIG_FILE): - env = yaml.safe_load(open(ENV_CONFIG_FILE, 'r')) - chainmail_address = env['contract_address'] - print(f'Chainmail address: {chainmail_address}') - return chainmail_address - else: - print("Cannot get chainmail_address") - return '' - -# Executes the shell command using the OS and returns the output. -# Kills the current process on failure. -def execute_or_die(command): - print(command) - output = os.popen(command).read().strip() - print(output) - if "error" in output.lower(): - print("exit(1) on error") - exit(1) - return output - -# Executes the shell command using the OS and returns the output. -def execute(command): - print(command) - output = os.popen(command).read().strip() - print(output) - return output - -# Returns a dictionary object of the output of `cast send` -def parse_cast_send_output(output): - keys = {'blockHash': 'string', # 0xhex - 'blockNumber': 'int', - 'contract_address': 'string', # 0xhex or blank - 'cumulativeGasUsed': 'int', - 'effectiveGasPrice': 'int', - 'gasUsed': 'int', - 'logs': 'string', - 'logsBloom': 'string', - 'root': 'string', # empty string on anvil - 'status': 'int', - 'transactionHash': 'string', # 0xhex - 'transactionIndex': 'int', - 'type': 'int'} - parsed = {} - for key, format in keys.items(): - found = re.findall(f'{key}\s(.+?)\n', output) - if len(found) > 0: - value = found[0].strip() - if format == 'int': - parsed[key] = int(value, 10) - else: - parsed[key] = found[0].strip() - - # use logs to check for success/failure - if parsed['logs'] == '[]': - parsed['success'] = False - else: - parsed['success'] = True - - print(parsed) - return parsed - -# The shell command `cast send` returns string output that needs to be parsed to determine if the call succeeded. -# Sample usage: -# command = f'cast send {arguments}' -# output = execute_or_die(command) -# if is_cast_and_send_succeed(output): -# foo() -# else: -# bar() -# Returns True or False depending on string output -def is_cast_and_send_succeed(output): - parsed = parse_cast_send_output(output) - return parsed['success'] - -# Returns a hash of the input string using the Ethereum hash function keccak256 -def hash(input): - input_bytes = eth_abi.encode(['string'], [input]) - keccak_hash = keccak.new(digest_bits=256) - keccak_hash.update(input_bytes) - hash = keccak_hash.hexdigest() - print(f'keccak-256: {hash}') - return hash - -# Strips whitespace and replaces with single ' ' prior to hashing -def hash_message(input): - input = input.strip() - input = ' '.join(input.split()) - return hash(input) - -# Returns a keccak hash of the email address using Ethereum hash function. -# The email address is hashed in casefold() to ensure consistency with future -# verification queries. -def hash_email_address(email): - input = email.casefold() - return hash(input) - -# Executes the `forge create` shell command to deploy the contract. The address is -# saved locally in a file for future use. -def deploy(): - # deploy smart contract - enable_verification = '' - if etherscan_api_key != '': - enable_verification = f' --etherscan-api-key {etherscan_api_key} --verify' - command = f'forge create {contract_file}:Chainmail --private-key {owner_private_key} --rpc-url {rpc_url}{enable_verification}' - output = execute_or_die(command) - - # Save address of smart contract in environment for future use. This is important for testing on local anvil - # node because the contract address can change with each test run. - found = re.findall("Deployed to:\s(.*)\s+Transaction", output) - if len(found) == 0: - print(f'You must manually set the environment file .chainmail_env to the contract address.') - exit(0) - chainmail_address = found[0] - save_env(chainmail_address) - print() - print(f'Successfully deployed contract Chainmail to {chainmail_address}.') - -# Registers an email address to the deployed Chainmail contract. Uses the owner in the KEY_FILE -# and the contract address in ENV_CONFIG_FILE when calling cast send. -def register_email(email, sender, fingerprint): - hashed_email = hash_email_address(email) - chainmail_address = get_chainmail_address() - command = f'cast send --private-key {owner_private_key} --rpc-url {rpc_url} {chainmail_address} "{fun_register_email_address}" {hashed_email} {sender} {fingerprint}' - output = execute_or_die(command) - if is_cast_and_send_succeed(output): - print(f'Success: registered {email} as {hashed_email} {sender} {fingerprint}') - else: - print(f'Fail: could not register {email} as {hashed_email} {sender} {fingerprint}') - -# Registers an email message to the deployed Chainmail contract. Uses the sender in the KEY_FILE -# and the contract address in ENV_CONFIG_FILE when calling cast send. -def register_email_message(message): - hashed_message = hash_message(message) - chainmail_address = get_chainmail_address() - command = f'cast send --private-key {sender_private_key} --rpc-url {rpc_url} {chainmail_address} "{fun_register_email_message}" {hashed_message}' - output = execute_or_die(command) - result = parse_cast_send_output(output) - if result['success']: - print(f'Sucessfully registered message {hashed_message} from sender {sender_address}') - - -# Registers an email message to the deployed Chainmail contract. Uses the sender in the KEY_FILE -# and the contract address in ENV_CONFIG_FILE when calling cast send. -def register_email_message_file(filename): - file = open(filename, 'r') - message = file.read() - register_email_message(message) - -# Verifies the fingerprint and email have been registered. -# Uses the contract address in ENV_CONFIG_FILE when calling cast call. -def verify_fingerprint_and_email(fingerprint, email): - hashed_email = hash_email_address(email) - chainmail_address = get_chainmail_address() - command = f'cast call {chainmail_address} --rpc-url {rpc_url} "{fun_email_address_info}" {hashed_email}' - output = execute(command) - - # cast call returns blank output on Error (e.g. wrong contract address) - if output is None or output == '' or output.isspace(): - print(f'Fail: email {email} not registered') - return False - - # output of cast call should be two rows of text with sender address and fingerprint - results = output.split("\n") - if len(results) != 2: - print(f'Fail: could not process blockchain output, assuming email {email} not registered.') - return False - - # returned fingerprint will be 0x if it is not registered - if len(results[1].strip()) <= 2: - print(f'Fail: no fingerprint is registered for {email}.') - return False - - # compare registered fingerprint to function argument - registered_fingerprint = results[1].strip().casefold()[2:] - if registered_fingerprint != fingerprint.casefold(): - print(f'Fail: {email} registered fingerprint {registered_fingerprint} does not match query {fingerprint}.') - return False - - print(f'Success: verified registration for {email} {fingerprint}.') - return True - -# Verifies the email message has been registered. -# Uses the contract address in ENV_CONFIG_FILE when calling cast call. -def verify_email_message(sender_email, message): - hashed_email = hash_email_address(sender_email) - hashed_message = hash_message(message) - chainmail_address = get_chainmail_address() - command = f'cast call {chainmail_address} --rpc-url {rpc_url} "{fun_verify_email_message}" {hashed_email} {hashed_message}' - output = execute(command) - if "false" in output.lower(): - return False - return True - -# Verifies the email message in the file has been registered. -# Uses the contract address in ENV_CONFIG_FILE when calling cast call. -def verify_email_message_file(sender_email, filename): - file = open(filename, 'r') - message = file.read() - return verify_email_message(sender_email, message.strip()) - - -# Processes command line arguments -if __name__ == '__main__': - arglen = len(sys.argv) - if arglen > 1 and sys.argv[1] == 'deploy': - deploy() - elif arglen > 4 and sys.argv[1] == 'register-email': - register_email(sys.argv[2], sys.argv[3], sys.argv[4]) - elif arglen > 2 and sys.argv[1] == 'register-message-file': - register_email_message_file(sys.argv[2]) - elif arglen > 2 and sys.argv[1] == 'hash-email': - hash_email_address(sys.argv[2]) - elif arglen > 2 and sys.argv[1] == 'hash-file': - file = open(sys.argv[2], 'r') - message = file.read() - hash_message(message) - elif arglen > 3 and sys.argv[1] == 'verify': - verify_fingerprint_and_email(fingerprint=sys.argv[2], email=sys.argv[3]) - elif arglen > 3 and sys.argv[1] == 'verify-file': - verify_email_message_file(sys.argv[2], sys.argv[3]) - else: - print(USAGE_INSTRUCTIONS) +# Copyright (c) 2024, Circle Internet Financial, LTD. All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +USAGE_INSTRUCTIONS = """ +# Instructions for calling chainlink.py from the command line. This works with +# both local anvil nodes and testnet/mainnet. The accounts and rpc_url are specified +# in the KEY_FILE. All other configuration is in CONFIG_FILE, + +# Deploy the Chainmail contract to the network. Automatically stores contract addresss in ENV_CONFIG_FILE +# where it will be used by other commands. +python chainlink.py deploy + +# register an email at the Chainmail contract specified in file ENV_CONFIG_FILE +# will automatically casefold all input so verification is not case-sensitive. +python chainlink.py register-email +python chainlink.py register-email user@testdomain.com 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 4DD9C7CA778A0BCFCF0A4635294DADB0D448AC5E + +# register the contents of a file to the sender address in CONFIG_FILE +# uses ENV_CONFIG_FILE to determine the contract address +python chainlink.py register-message-file + +# outputs the keccak hash of the email address (lowercase) +python chainlink.py hash-email + +# outputs the keccak hash of a file after replacing all whitespace +python chainlink.py hash-file + +# verifies if the fingerprint and email have been registered (not case sensitive) +python chainlink.py verify + +# verifies if the email message has been registered by sender email address +python chainlink.py verify-file +""" + +from chainmail import verify_signature +import eth_abi +import os +from Crypto.Hash import keccak +import re +import shlex +import subprocess +import sys +import yaml + + +def _load_yaml(path): + """Load a YAML document, closing the file handle deterministically.""" + with open(path, 'r', encoding='utf-8') as handle: + return yaml.safe_load(handle) + + +CONFIG_FILE = './config.yaml' +CONFIG = _load_yaml(CONFIG_FILE) +contract_file = CONFIG['chainlink']['contract_file'] +fun_register_email_address = CONFIG['chainlink']['register_email_address'] +fun_register_email_message = CONFIG['chainlink']['register_email_message'] +fun_email_address_info = CONFIG['chainlink']['email_address_info'] +fun_verify_email_message = CONFIG['chainlink']['verify_email_message'] + +KEY_FILE = CONFIG['local_key_file'] +KEY = _load_yaml(KEY_FILE) +owner_private_key = KEY['testnet_account']['private_key'] +sender_private_key = KEY['testnet_sender']['private_key'] +sender_address = KEY['testnet_sender']['address'] +rpc_url = KEY['rpc_url'] +etherscan_api_key = KEY['etherscan_api_key'] + +ENV_CONFIG_FILE = CONFIG['chainlink']['local_env_file'] + +# Values that must never appear in stdout/stderr echoes of a command line. +# Commands are logged for operator visibility, and the previous implementation +# printed them verbatim, which wrote the deployer and sender private keys and the +# Etherscan API key into terminal scrollback, CI logs, and shell history. +_SECRETS = tuple( + secret + for secret in (owner_private_key, sender_private_key, etherscan_api_key) + if isinstance(secret, str) and secret +) + + +def redact(text): + """Replace every known secret in ``text`` with a placeholder.""" + if not isinstance(text, str): + return text + for secret in _SECRETS: + text = text.replace(secret, '') + return text + + +# Saves the state of the local environment to ENV_CONFIG_FILE. +def save_env(chainmail_address): + env = dict() + env['contract_address'] = chainmail_address + # `with` guarantees the file is flushed and closed; the previous version left + # the handle to the garbage collector, so the address could be missing from + # the file when a later step read it back. + with open(ENV_CONFIG_FILE, 'w', encoding='utf-8') as file: + yaml.dump(env, file) + +# Read ENV_CONFIG_FILE to get address of Chainmail contract +def get_chainmail_address(): + if os.path.exists(ENV_CONFIG_FILE): + env = _load_yaml(ENV_CONFIG_FILE) + chainmail_address = env['contract_address'] + print(f'Chainmail address: {chainmail_address}') + return chainmail_address + else: + print("Cannot get chainmail_address") + return '' + + +def _run(argv): + """Run ``argv`` without a shell and return (exit_code, stdout+stderr). + + The command is passed as an argument vector, so values interpolated into it + (email addresses, file contents, contract addresses, fingerprints) can no + longer terminate the command and start another one — which was possible while + these commands were built as f-strings and handed to ``os.popen``. + """ + print(redact(' '.join(shlex.quote(arg) for arg in argv))) + completed = subprocess.run( + argv, + capture_output=True, + text=True, + check=False, + ) + output = (completed.stdout + completed.stderr).strip() + print(redact(output)) + return completed.returncode, output + + +# Executes the command and returns the output. +# Kills the current process on failure. +def execute_or_die(argv): + # The process exit status is authoritative. Previously failure was inferred + # from the substring "error" in stdout, which both missed silent failures and + # aborted on success when a payload happened to contain that word. + returncode, output = _run(argv) + if returncode != 0: + print(f'exit(1): command failed with exit status {returncode}') + exit(1) + return output + +# Executes the command and returns the output. +def execute(argv): + _, output = _run(argv) + return output + +# Returns a dictionary object of the output of `cast send` +def parse_cast_send_output(output): + keys = {'blockHash': 'string', # 0xhex + 'blockNumber': 'int', + 'contract_address': 'string', # 0xhex or blank + 'cumulativeGasUsed': 'int', + 'effectiveGasPrice': 'int', + 'gasUsed': 'int', + 'logs': 'string', + 'logsBloom': 'string', + 'root': 'string', # empty string on anvil + 'status': 'int', + 'transactionHash': 'string', # 0xhex + 'transactionIndex': 'int', + 'type': 'int'} + parsed = {} + for key, format in keys.items(): + # Raw string: `\s` is not a valid escape in a regular string literal and + # is a DeprecationWarning that becomes a SyntaxError in future Pythons. + found = re.findall(rf'{re.escape(key)}\s(.+?)\n', output) + if len(found) > 0: + value = found[0].strip() + if format == 'int': + parsed[key] = int(value, 10) + else: + parsed[key] = found[0].strip() + + # Use logs to check for success/failure. `.get` because a truncated or error + # response has no `logs` line at all, which previously raised KeyError instead + # of reporting the call as unsuccessful. + parsed['success'] = parsed.get('logs', '[]') != '[]' + + return parsed + +# The shell command `cast send` returns string output that needs to be parsed to determine if the call succeeded. +# Sample usage: +# command = f'cast send {arguments}' +# output = execute_or_die(command) +# if is_cast_and_send_succeed(output): +# foo() +# else: +# bar() +# Returns True or False depending on string output +def is_cast_and_send_succeed(output): + parsed = parse_cast_send_output(output) + return parsed['success'] + +# Returns a hash of the input string using the Ethereum hash function keccak256 +def hash(input): + input_bytes = eth_abi.encode(['string'], [input]) + keccak_hash = keccak.new(digest_bits=256) + keccak_hash.update(input_bytes) + hash = keccak_hash.hexdigest() + print(f'keccak-256: {hash}') + return hash + +# Strips whitespace and replaces with single ' ' prior to hashing +def hash_message(input): + input = input.strip() + input = ' '.join(input.split()) + return hash(input) + +# Returns a keccak hash of the email address using Ethereum hash function. +# The email address is hashed in casefold() to ensure consistency with future +# verification queries. +def hash_email_address(email): + input = email.casefold() + return hash(input) + +# Executes the `forge create` shell command to deploy the contract. The address is +# saved locally in a file for future use. +def deploy(): + # deploy smart contract + command = [ + 'forge', 'create', f'{contract_file}:Chainmail', + '--private-key', owner_private_key, + '--rpc-url', rpc_url, + ] + if etherscan_api_key != '': + command += ['--etherscan-api-key', etherscan_api_key, '--verify'] + output = execute_or_die(command) + + # Save address of smart contract in environment for future use. This is important for testing on local anvil + # node because the contract address can change with each test run. + found = re.findall(r'Deployed to:\s(.*)\s+Transaction', output) + if len(found) == 0: + print(f'You must manually set the environment file .chainmail_env to the contract address.') + exit(0) + chainmail_address = found[0] + save_env(chainmail_address) + print() + print(f'Successfully deployed contract Chainmail to {chainmail_address}.') + +# Registers an email address to the deployed Chainmail contract. Uses the owner in the KEY_FILE +# and the contract address in ENV_CONFIG_FILE when calling cast send. +def register_email(email, sender, fingerprint): + hashed_email = hash_email_address(email) + chainmail_address = get_chainmail_address() + command = [ + 'cast', 'send', + '--private-key', owner_private_key, + '--rpc-url', rpc_url, + chainmail_address, + fun_register_email_address, + hashed_email, sender, fingerprint, + ] + output = execute_or_die(command) + if is_cast_and_send_succeed(output): + print(f'Success: registered {email} as {hashed_email} {sender} {fingerprint}') + else: + print(f'Fail: could not register {email} as {hashed_email} {sender} {fingerprint}') + +# Registers an email message to the deployed Chainmail contract. Uses the sender in the KEY_FILE +# and the contract address in ENV_CONFIG_FILE when calling cast send. +def register_email_message(message): + hashed_message = hash_message(message) + chainmail_address = get_chainmail_address() + command = [ + 'cast', 'send', + '--private-key', sender_private_key, + '--rpc-url', rpc_url, + chainmail_address, + fun_register_email_message, + hashed_message, + ] + output = execute_or_die(command) + result = parse_cast_send_output(output) + if result['success']: + print(f'Sucessfully registered message {hashed_message} from sender {sender_address}') + else: + print(f'Fail: could not register message {hashed_message} from sender {sender_address}') + + +# Registers an email message to the deployed Chainmail contract. Uses the sender in the KEY_FILE +# and the contract address in ENV_CONFIG_FILE when calling cast send. +def register_email_message_file(filename): + with open(filename, 'r', encoding='utf-8') as file: + message = file.read() + register_email_message(message) + +# Verifies the fingerprint and email have been registered. +# Uses the contract address in ENV_CONFIG_FILE when calling cast call. +def verify_fingerprint_and_email(fingerprint, email): + hashed_email = hash_email_address(email) + chainmail_address = get_chainmail_address() + command = [ + 'cast', 'call', chainmail_address, + '--rpc-url', rpc_url, + fun_email_address_info, + hashed_email, + ] + output = execute(command) + + # cast call returns blank output on Error (e.g. wrong contract address) + if output is None or output == '' or output.isspace(): + print(f'Fail: email {email} not registered') + return False + + # output of cast call should be two rows of text with sender address and fingerprint + results = output.split("\n") + if len(results) != 2: + print(f'Fail: could not process blockchain output, assuming email {email} not registered.') + return False + + # returned fingerprint will be 0x if it is not registered + if len(results[1].strip()) <= 2: + print(f'Fail: no fingerprint is registered for {email}.') + return False + + # compare registered fingerprint to function argument + registered_fingerprint = results[1].strip().casefold()[2:] + if registered_fingerprint != fingerprint.casefold(): + print(f'Fail: {email} registered fingerprint {registered_fingerprint} does not match query {fingerprint}.') + return False + + print(f'Success: verified registration for {email} {fingerprint}.') + return True + +# Verifies the email message has been registered. +# Uses the contract address in ENV_CONFIG_FILE when calling cast call. +def verify_email_message(sender_email, message): + hashed_email = hash_email_address(sender_email) + hashed_message = hash_message(message) + chainmail_address = get_chainmail_address() + command = [ + 'cast', 'call', chainmail_address, + '--rpc-url', rpc_url, + fun_verify_email_message, + hashed_email, hashed_message, + ] + returncode, output = _run(command) + + # Fail closed. The previous logic returned True for anything that did not + # contain the substring "false", so an RPC error, an empty response, a wrong + # contract address, or a missing `cast` binary all reported the message as + # verified. Only an explicit affirmative answer counts as verification. + if returncode != 0: + print('Fail: verification call did not complete successfully.') + return False + + answer = output.strip().casefold() + # `cast call` renders a bool return value as "true"/"false", and an ABI-encoded + # word as 0x00..01 / 0x00..00 depending on the cast version. + if answer == 'true' or (answer.startswith('0x') and set(answer[2:]) <= set('0123456789abcdef') and int(answer, 16) == 1): + return True + + if answer != 'false' and int_or_none(answer) != 0: + print(f'Fail: unrecognised verification response; treating as not verified.') + return False + + +def int_or_none(text): + """Best-effort integer parse used only to classify a hex/decimal response.""" + try: + return int(text, 0) + except (TypeError, ValueError): + return None + +# Verifies the email message in the file has been registered. +# Uses the contract address in ENV_CONFIG_FILE when calling cast call. +def verify_email_message_file(sender_email, filename): + with open(filename, 'r', encoding='utf-8') as file: + message = file.read() + return verify_email_message(sender_email, message.strip()) + + +# Processes command line arguments +if __name__ == '__main__': + arglen = len(sys.argv) + if arglen > 1 and sys.argv[1] == 'deploy': + deploy() + elif arglen > 4 and sys.argv[1] == 'register-email': + register_email(sys.argv[2], sys.argv[3], sys.argv[4]) + elif arglen > 2 and sys.argv[1] == 'register-message-file': + register_email_message_file(sys.argv[2]) + elif arglen > 2 and sys.argv[1] == 'hash-email': + hash_email_address(sys.argv[2]) + elif arglen > 2 and sys.argv[1] == 'hash-file': + with open(sys.argv[2], 'r', encoding='utf-8') as file: + message = file.read() + hash_message(message) + elif arglen > 3 and sys.argv[1] == 'verify': + verify_fingerprint_and_email(fingerprint=sys.argv[2], email=sys.argv[3]) + elif arglen > 3 and sys.argv[1] == 'verify-file': + verify_email_message_file(sys.argv[2], sys.argv[3]) + else: + print(USAGE_INSTRUCTIONS) exit(0) \ No newline at end of file diff --git a/chainmail.py b/chainmail.py index f4ecf65..b6f0b1f 100644 --- a/chainmail.py +++ b/chainmail.py @@ -1,101 +1,125 @@ -# Copyright (c) 2024, Circle Internet Financial, LTD. All rights reserved. -# -# SPDX-License-Identifier: Apache-2.0 -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import base64 -import gnupg -import urllib -import yaml -import zlib - -CONFIG_FILE = 'config.yaml' -CONFIG = yaml.safe_load(open(CONFIG_FILE, 'r')) - -GPG = gnupg.GPG(CONFIG['pgp']['bin']) -GPG.encoding = 'utf-8' - - -""" -This is the main function for sending emails. It performs the following: -1. It adds a fingerprint note to the email_body. -2. Signs the new email_body. -3. Modifies the signature header to include Mailvelope version info. -4. Modifies the signature header to include a verification link. -5. Calls a (dummy) function to actually send the email using an SMTP provider. -The function returns a string with the text of the PGP signed message. -""" -def send_email(email_to, email_from, email_subject, email_body, cc_sender, fingerprint, passphrase, fingerprint_note, pgp_signature_start, new_pgp_signature_start): - fingerprint_note = fingerprint_note.replace("$FINGERPRINT", fingerprint) - content = email_body + fingerprint_note - content = PGP_sign_message(content, fingerprint, passphrase) - verification_url = get_verification_url(content) - new_pgp_signature_start = new_pgp_signature_start + '\nVerify: ' + verification_url + '\n' - content = modify_signature_header(content, pgp_signature_start, new_pgp_signature_start) - email_send_message( - email_to = email_to, - email_from = email_from, - email_subject = email_subject, - email_body = content, - cc_sender = cc_sender) - - return content - -# Sending an email requires connecting to an SMTP server. Users can insert custom -# code here if they want Chainmail to actually send PGP signed email messages. By default, -# Chainmail will simply display the PGP signed email on the webpage in runwebsite.py -def email_send_message(email_to, email_from, email_subject, email_body, cc_sender): - # Insert SMTP provider code here - return True - -# Given a PGP signed email message content, generate a verification URL that directs the user -# to the Chainmail verification page displayed by runwebsite.py. -def get_verification_url(content): - compressed_content = zlib.compress(content.encode('utf8')) - encoded_content = base64.b64encode(compressed_content) - url_safe_content = encoded_content.decode('utf8') - base_url = CONFIG['chainmail']['hostname'] - params = { 'encoded_content' : url_safe_content } - url = base_url + "/verify?" + urllib.parse.urlencode(params) - return url - - -def get_content_string(base64_content): - try: - decoded = base64.b64decode(base64_content) - decompressed = zlib.decompress(decoded) - content = decompressed.decode('utf-8') - except: - return "FAILED TO PARSE CONTENT" - else: - return content - - -def PGP_encrypt_message(): - return "hello world" - - -def PGP_sign_message(data, fingerprint, passphrase): - print(f'Signing with fingerprint {fingerprint}') - signed_data = GPG.sign(data, keyid=fingerprint, passphrase=passphrase, clearsign=True) - return str(signed_data) - - -def modify_signature_header(content, old_header, new_header): - return content.replace(old_header, new_header) - - -def verify_signature(data): - ok = GPG.verify(data) - return ok +# Copyright (c) 2024, Circle Internet Financial, LTD. All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import base64 +import binascii +import gnupg +import urllib +import yaml +import zlib + +CONFIG_FILE = 'config.yaml' +with open(CONFIG_FILE, 'r', encoding='utf-8') as _config_handle: + CONFIG = yaml.safe_load(_config_handle) + +GPG = gnupg.GPG(CONFIG['pgp']['bin']) +GPG.encoding = 'utf-8' + + +""" +This is the main function for sending emails. It performs the following: +1. It adds a fingerprint note to the email_body. +2. Signs the new email_body. +3. Modifies the signature header to include Mailvelope version info. +4. Modifies the signature header to include a verification link. +5. Calls a (dummy) function to actually send the email using an SMTP provider. +The function returns a string with the text of the PGP signed message. +""" +def send_email(email_to, email_from, email_subject, email_body, cc_sender, fingerprint, passphrase, fingerprint_note, pgp_signature_start, new_pgp_signature_start): + fingerprint_note = fingerprint_note.replace("$FINGERPRINT", fingerprint) + content = email_body + fingerprint_note + content = PGP_sign_message(content, fingerprint, passphrase) + verification_url = get_verification_url(content) + new_pgp_signature_start = new_pgp_signature_start + '\nVerify: ' + verification_url + '\n' + content = modify_signature_header(content, pgp_signature_start, new_pgp_signature_start) + email_send_message( + email_to = email_to, + email_from = email_from, + email_subject = email_subject, + email_body = content, + cc_sender = cc_sender) + + return content + +# Sending an email requires connecting to an SMTP server. Users can insert custom +# code here if they want Chainmail to actually send PGP signed email messages. By default, +# Chainmail will simply display the PGP signed email on the webpage in runwebsite.py +def email_send_message(email_to, email_from, email_subject, email_body, cc_sender): + # Insert SMTP provider code here + return True + +# Given a PGP signed email message content, generate a verification URL that directs the user +# to the Chainmail verification page displayed by runwebsite.py. +def get_verification_url(content): + compressed_content = zlib.compress(content.encode('utf8')) + encoded_content = base64.b64encode(compressed_content) + url_safe_content = encoded_content.decode('utf8') + base_url = CONFIG['chainmail']['hostname'] + params = { 'encoded_content' : url_safe_content } + url = base_url + "/verify?" + urllib.parse.urlencode(params) + return url + + +# Upper bound on the inflated size of a verification payload. The compressed +# content arrives in a URL query parameter, i.e. from an untrusted caller, and a +# few hundred bytes of zlib can expand to gigabytes ("decompression bomb"), which +# is a single-request memory exhaustion of the web process. +MAX_DECOMPRESSED_BYTES = 1 * 1024 * 1024 + +# Bound on the base64 input itself, so an oversized request is rejected before +# any decoding work happens. +MAX_ENCODED_BYTES = 256 * 1024 + + +def get_content_string(base64_content): + if base64_content is None or len(base64_content) > MAX_ENCODED_BYTES: + return "FAILED TO PARSE CONTENT" + try: + # `validate=True` rejects non-alphabet characters instead of silently + # discarding them. + decoded = base64.b64decode(base64_content, validate=True) + decompressor = zlib.decompressobj() + # Inflate at most MAX_DECOMPRESSED_BYTES + 1 so an overlong payload can be + # detected without ever materialising it in full. + decompressed = decompressor.decompress(decoded, MAX_DECOMPRESSED_BYTES + 1) + if len(decompressed) > MAX_DECOMPRESSED_BYTES: + return "FAILED TO PARSE CONTENT" + content = decompressed.decode('utf-8') + except (binascii.Error, ValueError, zlib.error, UnicodeDecodeError): + # Specific exceptions only: a bare `except` also swallowed + # KeyboardInterrupt, SystemExit, and genuine programming errors. + return "FAILED TO PARSE CONTENT" + else: + return content + + +def PGP_encrypt_message(): + return "hello world" + + +def PGP_sign_message(data, fingerprint, passphrase): + print(f'Signing with fingerprint {fingerprint}') + signed_data = GPG.sign(data, keyid=fingerprint, passphrase=passphrase, clearsign=True) + return str(signed_data) + + +def modify_signature_header(content, old_header, new_header): + return content.replace(old_header, new_header) + + +def verify_signature(data): + ok = GPG.verify(data) + return ok diff --git a/config.yaml b/config.yaml index c777101..4cec2f6 100644 --- a/config.yaml +++ b/config.yaml @@ -1,64 +1,64 @@ -# Copyright (c) 2024, Circle Internet Financial, LTD. All rights reserved. -# -# SPDX-License-Identifier: Apache-2.0 -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -############################################################ -# Sender information - modify to connect to testnet/mainnet -############################################################ - -# Default sender information used in test_data/alice_private_key.asc. -sender: - email: alice@testuser.com - fingerprint: 'A787FE6F9CC778EEEE9331752819F956FDE452F4' - # Passphrase for protecting PGP key (GnuPG requires a passphrase) - # The file bob_private_key.asc uses the same passphrase. - passphrase: 'test' - -# Location of key file with Ethereum private keys. -# The test key file anvil_keys.yaml uses default anvil keys. -local_key_file: './test_data/anvil_keys.yaml' - - -############################################## -# Test data that does not need to be modified -############################################## - -chainmail: - hostname: 'http://localhost:5000' - test_pgp_email: 'alice@testuser.com' - test_pgp_fingerprint: 'A787FE6F9CC778EEEE9331752819F956FDE452F4' - test_pgp_private_key_passphrase: 'test' - test_pgp_private_key_file: 'test_data/alice_private_key.asc' - cc_sender: false - -pgp: - bin: '/usr/local/bin/gpg' - pgp_signature_start: '-----BEGIN PGP SIGNATURE-----' - new_pgp_signature_start: "-----BEGIN PGP SIGNATURE-----\nVersion: Mailvelope v5.1.0\nComment: https://mailvelope.com" - # fingerprint_note is appended to email message. The variable $FINGERPRINT will be replaced - # with the actual fingerprint of the key used to sign the message. - # The fingerprint_note MUST NOT begin with a ----- to avoid confusing the PGP signature verifier. - fingerprint_note: "\n\nCHAINMAIL FINGERPRINT\nPGP fingerprint: $FINGERPRINT\n\n" - -chainlink: - contract_file: './contracts/Chainmail.sol' - register_email_address: 'registerEmailAddress(bytes32,address,bytes calldata)' - register_email_message: 'registerEmailMessage(bytes32)' - email_address_info: 'emailAddressInfo(bytes32):(address,bytes memory)' - verify_email_message: 'verifyEmailMessage(bytes32,bytes32):(bool)' - # Update .gitignore if you change local_env_file so that it doesn't get checked into GitHub - local_env_file: './.chainmail_env' - +# Copyright (c) 2024, Circle Internet Financial, LTD. All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +############################################################ +# Sender information - modify to connect to testnet/mainnet +############################################################ + +# Default sender information used in test_data/alice_private_key.asc. +sender: + email: alice@testuser.com + fingerprint: 'A787FE6F9CC778EEEE9331752819F956FDE452F4' + # Passphrase for protecting PGP key (GnuPG requires a passphrase) + # The file bob_private_key.asc uses the same passphrase. + passphrase: 'test' + +# Location of key file with Ethereum private keys. +# The test key file anvil_keys.yaml uses default anvil keys. +local_key_file: './test_data/anvil_keys.yaml' + + +############################################## +# Test data that does not need to be modified +############################################## + +chainmail: + hostname: 'http://localhost:5000' + test_pgp_email: 'alice@testuser.com' + test_pgp_fingerprint: 'A787FE6F9CC778EEEE9331752819F956FDE452F4' + test_pgp_private_key_passphrase: 'test' + test_pgp_private_key_file: 'test_data/alice_private_key.asc' + cc_sender: false + +pgp: + bin: '/usr/local/bin/gpg' + pgp_signature_start: '-----BEGIN PGP SIGNATURE-----' + new_pgp_signature_start: "-----BEGIN PGP SIGNATURE-----\nVersion: Mailvelope v5.1.0\nComment: https://mailvelope.com" + # fingerprint_note is appended to email message. The variable $FINGERPRINT will be replaced + # with the actual fingerprint of the key used to sign the message. + # The fingerprint_note MUST NOT begin with a ----- to avoid confusing the PGP signature verifier. + fingerprint_note: "\n\nCHAINMAIL FINGERPRINT\nPGP fingerprint: $FINGERPRINT\n\n" + +chainlink: + contract_file: './contracts/Chainmail.sol' + register_email_address: 'registerEmailAddress(bytes32,address,bytes calldata)' + register_email_message: 'registerEmailMessage(bytes32)' + email_address_info: 'emailAddressInfo(bytes32):(address,bytes memory)' + verify_email_message: 'verifyEmailMessage(bytes32,bytes32):(bool)' + # Update .gitignore if you change local_env_file so that it doesn't get checked into GitHub + local_env_file: './.chainmail_env' + diff --git a/runwebsite.py b/runwebsite.py index 742ceca..cbdedb7 100644 --- a/runwebsite.py +++ b/runwebsite.py @@ -1,174 +1,185 @@ -# Copyright (c) 2024, Circle Internet Financial, LTD. All rights reserved. -# -# SPDX-License-Identifier: Apache-2.0 -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# Usage: this script does not require any arguments. It uses the defaults in the configuration files. -# If using anvil, make sure the KEY_FILE points at anvil, run register-for-demo.py to deploy a test contract, -# and run anvil in a separate window. -# If using testnet, configure the KEY_FILE to point at testnet. -# -# To run the script: -# python runwebsite.py -# -# The script will start a Flask webserver to run the website. The default address is in the CONFIG file. - - -from chainmail import send_email, get_content_string, verify_signature -from chainlink import verify_fingerprint_and_email, verify_email_message -from flask import Flask, request, render_template - -import gnupg -import re -import yaml - -app = Flask(__name__) - -CONFIG_FILE = 'config.yaml' -CONFIG = yaml.safe_load(open(CONFIG_FILE, 'r')) - -# Displays the homepage in home.html -@app.route('/') -def home(): - return render_template('home.html', verified='NONE') - -# Signs an email message, then displays it on the homepage home.html. -# See chainlink.py for instructions to enable sending the email. -@app.route('/send', methods=['POST', 'GET']) -def send(): - form_request = request.form - if 'message' not in form_request: - return render_template('home.html', verified='NONE') - elif 'email_to' not in form_request: - return render_template('home.html', verified='NONE') - elif 'subject' not in form_request: - return render_template('home.html', verified='NONE') - - content = send_email( - email_to = form_request['email_to'], - email_from = CONFIG['sender']['email'], - email_subject = form_request['subject'], - email_body = form_request['message'], - cc_sender= CONFIG['chainmail']['cc_sender'], - fingerprint=CONFIG['sender']['fingerprint'], - passphrase = CONFIG['sender']['passphrase'], - fingerprint_note = CONFIG['pgp']['fingerprint_note'], - pgp_signature_start = CONFIG['pgp']['pgp_signature_start'], - new_pgp_signature_start = CONFIG['pgp']['new_pgp_signature_start']) - - return render_template( - 'home.html', - verified='NONE', - email_sent='True', - email_to = form_request['email_to'], - email_from = CONFIG['sender']['email'], - email_subject = form_request['subject'], - email_body = content, - cc_sender= CONFIG['chainmail']['cc_sender'], - fingerprint=CONFIG['sender']['fingerprint'] - ) - -# Verifies a PGP signed message: -# 1. Checks the PGP signature. -# 2. Check if the message is registered to the Chainmail smart contract. -# 3. Extracts sender email address from PGP signature and checks if it is is registered to the Chainmail smart contract -# Returns a dictionary object with the results of these checks. -def check_message(message): - check = dict() - valid = verify_signature(message) - if valid: - print("valid") - check['valid'] = True - check['fingerprint'] = valid.fingerprint - check['username'] = valid.username - check['creation_date'] = valid.creation_date - - # sender_email can be in format 'user@domain.com' or 'My Full Name ' - found_email = re.findall('\<.+\>', valid.username.strip()) - if len(found_email) > 0: - sender_email = found_email[0][1:-1] - else: - sender_email = valid.username.strip() - - # check if (fingerprint, email) registered with blockchain - if verify_fingerprint_and_email(valid.fingerprint, sender_email): - check['verified'] = 'OK' - check['details'] = f'Signature is valid and username {sender_email} is registered with Ethereum to use this public key.' - else: - check['verified'] = 'UNREGISTERED' - check['details'] = f'The signature is valid, but username {sender_email} not registered with Ethereum to use this public key.' - - # check if sender_email has registered the message with blockchain - if verify_email_message(sender_email, message): - check['registered_message'] = True - check['details'] = check['details'] + ' The email sender has registered the message with Ethereum.' - else: - check['registered_message'] = False - check['details'] = check['details'] + ' The email sender has NOT registered the message with Ethereum.' - - else: - print("invalid") - check['valid'] = False - check['verified'] = 'FAIL' - check['registered_message'] = False - check['details'] = valid.problems - - print(check) - return check - -# Checks a PGP signed email message stored in the file. -# See function check_email_message() above -def check_email_message_file(filename): - file = open(filename, 'r') - message = file.read() - return check_message(message.strip()) - -# Verifies the PGP signed email message. The input can come either from -# the form defined in home.html or the verification URL generated by chainmail.py. -@app.route('/verify', methods=['POST', 'GET']) -def verify(): - form_request = request.form - if 'raw_text' in form_request: - message = form_request['raw_text'] - if not message: - return render_template('home.html', verified='NONE') - else: - encoded_content = request.args.get('encoded_content') - if not encoded_content: - return render_template('home.html', verified='NONE') - message = get_content_string(encoded_content) - - check = check_message(message.strip()) - if check['valid']: - return render_template( - 'home.html', - msg=message, - verified=check['verified'], - fingerprint=check['fingerprint'], - username=check['username'], - creation_date=check['creation_date'], - details=check['details'], - registered_message=check['registered_message']) - else: - return render_template( - 'home.html', - msg=message, - verified=check['verified'], - problems=check['details']) - -# Run the Flask webserver -if __name__ == '__main__': - app.run(debug=True) - - +# Copyright (c) 2024, Circle Internet Financial, LTD. All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Usage: this script does not require any arguments. It uses the defaults in the configuration files. +# If using anvil, make sure the KEY_FILE points at anvil, run register-for-demo.py to deploy a test contract, +# and run anvil in a separate window. +# If using testnet, configure the KEY_FILE to point at testnet. +# +# To run the script: +# python runwebsite.py +# +# The script will start a Flask webserver to run the website. The default address is in the CONFIG file. + + +from chainmail import send_email, get_content_string, verify_signature +from chainlink import verify_fingerprint_and_email, verify_email_message +from flask import Flask, request, render_template + +import gnupg +import os +import re +import yaml + +app = Flask(__name__) + +CONFIG_FILE = 'config.yaml' +with open(CONFIG_FILE, 'r', encoding='utf-8') as _config_handle: + CONFIG = yaml.safe_load(_config_handle) + +# Displays the homepage in home.html +@app.route('/') +def home(): + return render_template('home.html', verified='NONE') + +# Signs an email message, then displays it on the homepage home.html. +# See chainlink.py for instructions to enable sending the email. +@app.route('/send', methods=['POST', 'GET']) +def send(): + form_request = request.form + if 'message' not in form_request: + return render_template('home.html', verified='NONE') + elif 'email_to' not in form_request: + return render_template('home.html', verified='NONE') + elif 'subject' not in form_request: + return render_template('home.html', verified='NONE') + + content = send_email( + email_to = form_request['email_to'], + email_from = CONFIG['sender']['email'], + email_subject = form_request['subject'], + email_body = form_request['message'], + cc_sender= CONFIG['chainmail']['cc_sender'], + fingerprint=CONFIG['sender']['fingerprint'], + passphrase = CONFIG['sender']['passphrase'], + fingerprint_note = CONFIG['pgp']['fingerprint_note'], + pgp_signature_start = CONFIG['pgp']['pgp_signature_start'], + new_pgp_signature_start = CONFIG['pgp']['new_pgp_signature_start']) + + return render_template( + 'home.html', + verified='NONE', + email_sent='True', + email_to = form_request['email_to'], + email_from = CONFIG['sender']['email'], + email_subject = form_request['subject'], + email_body = content, + cc_sender= CONFIG['chainmail']['cc_sender'], + fingerprint=CONFIG['sender']['fingerprint'] + ) + +# Verifies a PGP signed message: +# 1. Checks the PGP signature. +# 2. Check if the message is registered to the Chainmail smart contract. +# 3. Extracts sender email address from PGP signature and checks if it is is registered to the Chainmail smart contract +# Returns a dictionary object with the results of these checks. +def check_message(message): + check = dict() + valid = verify_signature(message) + if valid: + print("valid") + check['valid'] = True + check['fingerprint'] = valid.fingerprint + check['username'] = valid.username + check['creation_date'] = valid.creation_date + + # sender_email can be in format 'user@domain.com' or 'My Full Name ' + # Raw, non-greedy, anchored to the end of the string: the previous + # `'\<.+\>'` pattern was greedy and unanchored, so a username containing + # more than one angle-bracket pair yielded the widest possible span and + # the wrong address was then used for the on-chain registration lookup. + found_email = re.findall(r'<([^<>]+)>\s*$', valid.username.strip()) + if len(found_email) > 0: + sender_email = found_email[0].strip() + else: + sender_email = valid.username.strip() + + # check if (fingerprint, email) registered with blockchain + if verify_fingerprint_and_email(valid.fingerprint, sender_email): + check['verified'] = 'OK' + check['details'] = f'Signature is valid and username {sender_email} is registered with Ethereum to use this public key.' + else: + check['verified'] = 'UNREGISTERED' + check['details'] = f'The signature is valid, but username {sender_email} not registered with Ethereum to use this public key.' + + # check if sender_email has registered the message with blockchain + if verify_email_message(sender_email, message): + check['registered_message'] = True + check['details'] = check['details'] + ' The email sender has registered the message with Ethereum.' + else: + check['registered_message'] = False + check['details'] = check['details'] + ' The email sender has NOT registered the message with Ethereum.' + + else: + print("invalid") + check['valid'] = False + check['verified'] = 'FAIL' + check['registered_message'] = False + check['details'] = valid.problems + + print(check) + return check + +# Checks a PGP signed email message stored in the file. +# See function check_email_message() above +def check_email_message_file(filename): + with open(filename, 'r', encoding='utf-8') as file: + message = file.read() + return check_message(message.strip()) + +# Verifies the PGP signed email message. The input can come either from +# the form defined in home.html or the verification URL generated by chainmail.py. +@app.route('/verify', methods=['POST', 'GET']) +def verify(): + form_request = request.form + if 'raw_text' in form_request: + message = form_request['raw_text'] + if not message: + return render_template('home.html', verified='NONE') + else: + encoded_content = request.args.get('encoded_content') + if not encoded_content: + return render_template('home.html', verified='NONE') + message = get_content_string(encoded_content) + + check = check_message(message.strip()) + if check['valid']: + return render_template( + 'home.html', + msg=message, + verified=check['verified'], + fingerprint=check['fingerprint'], + username=check['username'], + creation_date=check['creation_date'], + details=check['details'], + registered_message=check['registered_message']) + else: + return render_template( + 'home.html', + msg=message, + verified=check['verified'], + problems=check['details']) + +# Run the Flask webserver +if __name__ == '__main__': + # The Werkzeug debugger exposes an interactive Python console on any + # unhandled exception, which is remote code execution for anyone who can + # reach the port. It is now opt-in via CHAINMAIL_DEBUG=1 for local use only, + # instead of being on by default. + debug = os.getenv('CHAINMAIL_DEBUG') == '1' + app.run(debug=debug) + + diff --git a/templates/home.html b/templates/home.html index e0adef5..b9a145f 100644 --- a/templates/home.html +++ b/templates/home.html @@ -1,149 +1,149 @@ - - - -{% extends "layout.html" %} -{% block content %} - -
-
-

Verify Email

-
-
-
- - {% if verified == 'NONE' %} - - {% else %} - - {% endif %} -
- -
-
-
-
- -
-
-

Results

- {% if verified == 'NONE' %} - - {% elif verified == 'OK' or verified == 'UNREGISTERED' %} - - - {% if verified == 'OK' and registered_message %} - - - {% elif verified == 'OK' %} - - - {% else %} - - - {% endif %} - - - - - - - - - - - - - - - - - -
VerifiedTRUEVerifiedTRUEVerifiedValid signature from unregistered address
Username{{ username }}
Fingerprint{{ fingerprint }}
Signature Creation Date{{ creation_date }}
Signature Verification Details{{ details }}
- {% else %} - - - - - - {% for problem in problems %} - - - - - {% endfor %} -
VerifiedSignature verification failed.
Issue{{ problem }}
- {% endif %} -
-
-
-
-

Send Email

-
-
-
- -
- -
- -
- -
- -
- - -
- -
-
-
-
- -
-
-

Email Sent

- {% if email_sent == 'True' %} - - - - - - - - - - - - - - - - - -
To:{{email_to}}
From{{email_from}}
Fingerprint{{ fingerprint }}
Subject{{email_subject}}
- Message -

- - {% endif %} -
-
+ + + +{% extends "layout.html" %} +{% block content %} + +
+
+

Verify Email

+
+
+
+ + {% if verified == 'NONE' %} + + {% else %} + + {% endif %} +
+ +
+
+
+
+ +
+
+

Results

+ {% if verified == 'NONE' %} + + {% elif verified == 'OK' or verified == 'UNREGISTERED' %} + + + {% if verified == 'OK' and registered_message %} + + + {% elif verified == 'OK' %} + + + {% else %} + + + {% endif %} + + + + + + + + + + + + + + + + + +
VerifiedTRUEVerifiedTRUEVerifiedValid signature from unregistered address
Username{{ username }}
Fingerprint{{ fingerprint }}
Signature Creation Date{{ creation_date }}
Signature Verification Details{{ details }}
+ {% else %} + + + + + + {% for problem in problems %} + + + + + {% endfor %} +
VerifiedSignature verification failed.
Issue{{ problem }}
+ {% endif %} +
+
+
+
+

Send Email

+
+
+
+ +
+ +
+ +
+ +
+ +
+ + +
+ +
+
+
+
+ +
+
+

Email Sent

+ {% if email_sent == 'True' %} + + + + + + + + + + + + + + + + + +
To:{{email_to}}
From{{email_from}}
Fingerprint{{ fingerprint }}
Subject{{email_subject}}
+ Message +

+ + {% endif %} +
+
{% endblock %} \ No newline at end of file diff --git a/templates/layout.html b/templates/layout.html index 4093b0d..9f475e9 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -1,37 +1,37 @@ - - - - - - - Chainmail - - - - -
-
-

Chainmail

-
-
- {% block content %} - {% endblock %} - - - + + + + + + + Chainmail + + + + +
+
+

Chainmail

+
+
+ {% block content %} + {% endblock %} + + +