diff --git a/account.py b/account.py index 0b1fa88..f388095 100644 --- a/account.py +++ b/account.py @@ -2,6 +2,9 @@ from handywrapper import api import os import dotenv import requests +import re +import domainLookup +import json dotenv.load_dotenv() @@ -16,6 +19,9 @@ response = hsd.getInfo() def check_account(cookie: str): + if cookie is None: + return False + # Check the account if cookie.count(":") < 1: return False @@ -81,4 +87,85 @@ def getTransactions(account): info = hsw.getWalletTxHistory(account) if 'error' in info: return [] - return info \ No newline at end of file + return info + + +def check_address(address: str, allow_name: bool = True, return_address: bool = False): + # Check if the address is valid + if address.startswith('@'): + # Check if the address is a name + if not allow_name and not return_address: + return 'Invalid address' + elif not allow_name and return_address: + return False + return check_hip2(address[1:]) + + # Check if the address is a valid HNS address + response = requests.post(f"http://x:{APIKEY}@127.0.0.1:12037",json={ + "method": "validateaddress", + "params": [address] + }).json() + if response['error'] is not None: + if return_address: + return False + return 'Invalid address' + + if response['result']['isvalid'] == True: + if return_address: + return address + return 'Valid address' + + if return_address: + return False + return 'Invalid address' + + +def check_hip2(domain: str): + # Check if the domain is valid + domain = domain.lower() + + if re.match(r'^[a-zA-Z0-9\-\.]{1,63}$', domain) is None: + return 'Invalid address' + + address = domainLookup.hip2(domain) + if not check_address(address, False,True): + return 'Hip2: Lookup succeeded but address is invalid' + return address + + + +def send(account,address,amount): + account_name = check_account(account) + password = ":".join(account.split(":")[1:]) + + + # Unlock the account + response = requests.post(f"http://x:{APIKEY}@127.0.0.1:12039/wallet/{account_name}/unlock", + json={"passphrase": password,"timeout": 10}) + + if response.status_code != 200: + return { + "error": "Failed to unlock account" + } + if 'success' not in response.json(): + return { + "error": "Failed to unlock account" + } + + # Send the transaction + response = requests.post(f"http://x:{APIKEY}@127.0.0.1:12039",json={ + "method": "sendtoaddress", + "params": [address,amount] + }) + if response.status_code != 200: + return { + "error": "Failed to send transaction" + } + response = response.json() + if 'error' in response: + return { + "error": json.dumps(response['error']) + } + return { + "tx": response['result'] + } \ No newline at end of file diff --git a/domainLookup.py b/domainLookup.py new file mode 100644 index 0000000..c16eaef --- /dev/null +++ b/domainLookup.py @@ -0,0 +1,136 @@ +import dns.resolver +from cryptography import x509 +from cryptography.hazmat.backends import default_backend +import tempfile +import subprocess +import binascii +import datetime +import dns.asyncresolver +import httpx +from requests_doh import DNSOverHTTPSSession, add_dns_provider, remove_dns_provider + + +def hip2(domain: str): + domain_check = False + try: + # Get the IP + ip = resolve_with_doh(domain) + + # Run the openssl s_client command + s_client_command = ["openssl","s_client","-showcerts","-connect",f"{ip}:443","-servername",domain,] + + s_client_process = subprocess.Popen(s_client_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE) + s_client_output, _ = s_client_process.communicate(input=b"\n") + + certificates = [] + current_cert = "" + for line in s_client_output.split(b"\n"): + current_cert += line.decode("utf-8") + "\n" + if "-----END CERTIFICATE-----" in line.decode("utf-8"): + certificates.append(current_cert) + current_cert = "" + + # Remove anything before -----BEGIN CERTIFICATE----- + certificates = [cert[cert.find("-----BEGIN CERTIFICATE-----"):] for cert in certificates] + + if certificates: + cert = certificates[0] + + with tempfile.NamedTemporaryFile(mode="w", delete=False) as temp_cert_file: + temp_cert_file.write(cert) + temp_cert_file.seek(0) # Move back to the beginning of the temporary file + + tlsa_command = ["openssl","x509","-in",temp_cert_file.name,"-pubkey","-noout","|","openssl","pkey","-pubin","-outform","der","|","openssl","dgst","-sha256","-binary",] + + tlsa_process = subprocess.Popen(" ".join(tlsa_command), shell=True, stdout=subprocess.PIPE) + tlsa_output, _ = tlsa_process.communicate() + + tlsa_server = "3 1 1 " + binascii.hexlify(tlsa_output).decode("utf-8") + + + # Get domains + cert_obj = x509.load_pem_x509_certificate(cert.encode("utf-8"), default_backend()) + + domains = [] + for ext in cert_obj.extensions: + if ext.oid == x509.ExtensionOID.SUBJECT_ALTERNATIVE_NAME: + san_list = ext.value.get_values_for_type(x509.DNSName) + domains.extend(san_list) + + # Extract the common name (CN) from the subject + common_name = cert_obj.subject.get_attributes_for_oid(x509.NameOID.COMMON_NAME) + if common_name: + if common_name[0].value not in domains: + domains.append(common_name[0].value) + + if domains: + if domain in domains: + domain_check = True + else: + # Check if matching wildcard domain exists + for d in domains: + if d.startswith("*"): + if domain.split(".")[1:] == d.split(".")[1:]: + domain_check = True + break + + + expiry_date = cert_obj.not_valid_after + # Check if expiry date is past + if expiry_date < datetime.datetime.now(): + return "Hip2: Certificate is expired" + + + else: + return "Hip2: No certificate found" + + try: + # Check for TLSA record + tlsa = resolve_TLSA_with_doh(domain) + + + if not tlsa: + return "Hip2: TLSA lookup failed" + else: + if tlsa_server == str(tlsa): + if domain_check: + # Get the Hip2 addresss from /.well-known/wallets/HNS + add_dns_provider("HNSDoH", "https://hnsdoh.com/dns-query") + + session = DNSOverHTTPSSession("HNSDoH") + r = session.get(f"https://{domain}/.well-known/wallets/HNS",verify=False) + return r.text + else: + return "Hip2: TLSA record matches certificate, but domain does not match certificate" + + else: + return "Hip2: TLSA record does not match certificate" + + except Exception as e: + return "Hip2: TLSA lookup failed with error: " + str(e) + + + + + # Catch all exceptions + except Exception as e: + return "Hip2: " + str(e) + + +def resolve_with_doh(query_name, doh_url="https://hnsdoh.com/dns-query"): + with httpx.Client() as client: + q = dns.message.make_query(query_name, dns.rdatatype.A) + r = dns.query.https(q, doh_url, session=client) + + ip = r.answer[0][0].address + return ip + +def resolve_TLSA_with_doh(query_name, doh_url="https://hnsdoh.com/dns-query"): + query_name = "_443._tcp." + query_name + with httpx.Client() as client: + q = dns.message.make_query(query_name, dns.rdatatype.TLSA) + r = dns.query.https(q, doh_url, session=client) + + tlsa = r.answer[0][0] + return tlsa + diff --git a/grant.md b/grant.md new file mode 100644 index 0000000..ea0ae21 --- /dev/null +++ b/grant.md @@ -0,0 +1,45 @@ +What have you built previously? +- [HNSHosting](https://hnshosting.au) +- [ShakeCities](https://shakecities.com) +- [FireWallet](https://firewallet.au) +- [Git Profile](https://github.com/nathanwoodburn) + +Project summary +A Handshake wallet web ui. This will be a HSD wallet web ui that will allow users to manage their Handshake domains via a web interface. This will allow users to easily manage their domains without having to use the command line or bob. One benefit of this is that it will allow users to easily manage their domains from their mobile devices that don't have access to any HNS wallet. This could be done in a secure way by only allowing connections on local network devices (in addition to requiring the wallet credentials). + +Features: +- Login with HSD wallet name + password (by default don't show a list of wallets to login to as this could be a security risk) +- View account information in a dashboard + - Available balance + - Total balance + - Pending Transactions + - List of domains + - List of transactions +- Manage domains + - Transfer domains + - Finalize domains + - Edit domains + - Revoke domains (with a warning and requiring the account password) +- Manage wallet + - Send HNS + - Receive HNS +- Auctions + - View bids on domain + - Open auction + - Bid on auction + - Reveal bid + - Redeem bid + - Register domain + +Completion requirements: +- Basic functionality including + - View info + - Send/Receive HNS + - Manage domains + +After the initial version is completed I will be looking to add more features including the above mentioned features. + + +The initial version will be completed in 2-3 weeks with a fully fledged version released later as the features are developed and tested. + +You can contact me at handshake @ nathan.woodburn.au \ No newline at end of file diff --git a/main.py b/main.py index e281a6e..8c7623c 100644 --- a/main.py +++ b/main.py @@ -4,6 +4,7 @@ import dotenv import requests import account as account_module import render +import re dotenv.load_dotenv() @@ -17,6 +18,9 @@ def index(): return redirect("/login") account = account_module.check_account(request.cookies.get("account")) + if not account: + return redirect("/logout") + balance = account_module.getBalance(account) available = balance['available'] total = balance['total'] @@ -27,11 +31,13 @@ def index(): pending = account_module.getPendingTX(account) domains = account_module.getDomains(account) + domain_count = len(domains) domains = render.domains(domains) + return render_template("index.html", account=account, available=available, - total=total, pending=pending, domains=domains) + total=total, pending=pending, domains=domains, domain_count=domain_count) @app.route('/tx') @@ -49,15 +55,93 @@ def transactions(): return render_template("tx.html", account=account, tx=transactions) +@app.route('/send') +def send_page(): + # Check if the user is logged in + if request.cookies.get("account") is None: + return redirect("/login") + + account = account_module.check_account(request.cookies.get("account")) + max = account_module.getBalance(account)['available'] + # Subtract approx fee of 0.02 + max = max - 0.02 + + message = '' + address = '' + amount = '' + + if 'message' in request.args: + message = request.args.get("message") + if 'address' in request.args: + address = request.args.get("address") + if 'amount' in request.args: + amount = request.args.get("amount") + + + return render_template("send.html", account=account,max=max,message=message, + address=address,amount=amount) + +@app.route('/send', methods=["POST"]) +def send(): + if request.cookies.get("account") is None: + return redirect("/login") + + account = account_module.check_account(request.cookies.get("account")) + if not account: + return redirect("/logout") + + # Get the address and amount + address = request.form.get("address") + amount = request.form.get("amount") + + if address is None or amount is None: + return redirect("/send?message=Invalid address or amount&address=" + address + "&amount=" + amount) + + address_check = account_module.check_address(address,True,True) + if not address_check: + return redirect("/send?message=Invalid address&address=" + address + "&amount=" + amount) + + address = address_check + # Check if the amount is valid + if re.match(r"^\d+(\.\d+)?$", amount) is None: + return redirect("/send?message=Invalid amount&address=" + address + "&amount=" + amount) + + # Check if the amount is valid + amount = float(amount) + if amount <= 0: + return redirect("/send?message=Invalid amount&address=" + address + "&amount=" + str(amount)) + + if amount > account_module.getBalance(account)['available'] - 0.02: + return redirect("/send?message=Not enough funds to transfer&address=" + address + "&amount=" + str(amount)) + + # Send the transaction + response = account_module.send(request.cookies.get("account"),address,amount) + if 'error' in response: + return redirect("/send?message=" + response['error'] + "&address=" + address + "&amount=" + str(amount)) + + return redirect("/success?tx=" + response['tx']) + +@app.route('/success') +def success(): + # Check if the user is logged in + if request.cookies.get("account") is None: + return redirect("/login") + + account = account_module.check_account(request.cookies.get("account")) + if not account: + return redirect("/logout") + + tx = request.args.get("tx") + return render_template("success.html", account=account, tx=tx) - - - - - - - +@app.route('/checkaddress') +def check_address(): + address = request.args.get("address") + if address is None: + return jsonify({"result": "Invalid address"}) + + return jsonify({"result": account_module.check_address(address)}) #region Account @app.route('/login') @@ -65,9 +149,8 @@ def login(): if 'message' in request.args: return render_template("login.html", error=request.args.get("message")) - accounts = account_module.getAccounts() - return render_template("login.html", accounts=accounts) + return render_template("login.html") @app.route('/login', methods=["POST"]) def login_post(): @@ -103,6 +186,15 @@ def logout(): def send_assets(path): return send_from_directory('templates/assets', path) +# Try path +@app.route('/') +def try_path(path): + if os.path.isfile("templates/" + path + ".html"): + return render_template(path + ".html") + else: + return page_not_found(404) + + @app.errorhandler(404) def page_not_found(e): account = account_module.check_account(request.cookies.get("account")) diff --git a/requirements.txt b/requirements.txt index f61089b..7843385 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,7 @@ handywrapper flask python-dotenv requests -gunicorn \ No newline at end of file +gunicorn +dnspython +cryptography +requests-doh \ No newline at end of file diff --git a/templates/404.html b/templates/404.html index 6e2048a..a64da57 100644 --- a/templates/404.html +++ b/templates/404.html @@ -5,13 +5,11 @@ Page Not Found - FireWallet - - - - - - - + + + + + @@ -21,13 +19,17 @@