diff --git a/app/api/views/auth_mfa.py b/app/api/views/auth_mfa.py index abb686ac..c53a0b9c 100644 --- a/app/api/views/auth_mfa.py +++ b/app/api/views/auth_mfa.py @@ -38,7 +38,7 @@ def auth_mfa(): s = Signer(FLASK_SECRET) try: user_id = int(s.unsign(mfa_key)) - except BadSignature: + except Exception: return jsonify(error="Invalid mfa_key"), 400 user = User.get(user_id) diff --git a/app/config.py b/app/config.py index 41ac6c3c..a47a7994 100644 --- a/app/config.py +++ b/app/config.py @@ -1,5 +1,8 @@ import os +import random +import string import subprocess +import tempfile from uuid import uuid4 from dotenv import load_dotenv @@ -156,6 +159,17 @@ WORDS_FILE_PATH = get_abs_path( os.environ.get("WORDS_FILE_PATH", "local_data/words_alpha.txt") ) +# Used to generate random email +if os.environ.get("GNUPGHOME"): + GNUPGHOME = get_abs_path(os.environ.get("GNUPGHOME")) +else: + letters = string.ascii_lowercase + random_dir_name = "".join(random.choice(letters) for _ in range(20)) + GNUPGHOME = f"/tmp/{random_dir_name}" + if not os.path.exists(GNUPGHOME): + os.mkdir(GNUPGHOME, mode=0o700) + + print("WARNING: Use a temp directory for GNUPGHOME", GNUPGHOME) # Github, Google, Facebook client id and secrets GITHUB_CLIENT_ID = os.environ.get("GITHUB_CLIENT_ID") diff --git a/app/dashboard/templates/dashboard/mailbox.html b/app/dashboard/templates/dashboard/mailbox.html index 639fd95a..ca32fffc 100644 --- a/app/dashboard/templates/dashboard/mailbox.html +++ b/app/dashboard/templates/dashboard/mailbox.html @@ -41,6 +41,10 @@ 🚫 {% endif %} + {% if mailbox.pgp_finger_print %} + 🗝 + {% endif %} + {% if mailbox.id == current_user.default_mailbox_id %}
Default Mailbox diff --git a/app/dashboard/templates/dashboard/mailbox_detail.html b/app/dashboard/templates/dashboard/mailbox_detail.html index 62cc0e64..437fd070 100644 --- a/app/dashboard/templates/dashboard/mailbox_detail.html +++ b/app/dashboard/templates/dashboard/mailbox_detail.html @@ -17,8 +17,19 @@ 🚫 {% endif %} + {% if mailbox.pgp_finger_print %} + 🗝 + {% endif %} + {% if not mailbox.verified %} +
+ Mailbox not verified, please check your inbox/spam folder for the verification email. +
+ To receive the verification email again, you can delete and re-add the mailbox. +
+ {% endif %} +
@@ -52,6 +63,37 @@
+ + + {% if current_user.can_use_pgp %} +
+ + + +
+
+ Pretty Good Privacy (PGP) +
+ By importing your PGP Public Key into SimpleLogin, all emails sent to {{mailbox.email}} are encrypted with your key. +
+
+ +
+ + + +
+ + + + +
+ + +
+ {% endif %} + +
{% endblock %} diff --git a/app/dashboard/views/billing.py b/app/dashboard/views/billing.py index 2e0d5272..9f8bc183 100644 --- a/app/dashboard/views/billing.py +++ b/app/dashboard/views/billing.py @@ -20,7 +20,7 @@ def billing(): if request.method == "POST": if request.form.get("form-name") == "cancel": - LOG.error(f"User {current_user} cancels their subscription") + LOG.warning(f"User {current_user} cancels their subscription") success = cancel_subscription(sub.subscription_id) if success: diff --git a/app/dashboard/views/mailbox.py b/app/dashboard/views/mailbox.py index b5056be3..330b7ae7 100644 --- a/app/dashboard/views/mailbox.py +++ b/app/dashboard/views/mailbox.py @@ -120,7 +120,11 @@ def mailbox_route(): "success", ) - return redirect(url_for("dashboard.mailbox_route")) + return redirect( + url_for( + "dashboard.mailbox_detail_route", mailbox_id=new_mailbox.id + ) + ) return render_template( "dashboard/mailbox.html", @@ -138,8 +142,9 @@ def mailbox_verify(): try: r_id = int(s.unsign(mailbox_id)) - except BadSignature: + except Exception: flash("Invalid link. Please delete and re-add your mailbox", "error") + return redirect(url_for("dashboard.mailbox_route")) else: mailbox = Mailbox.get(r_id) mailbox.verified = True @@ -150,4 +155,6 @@ def mailbox_verify(): f"The {mailbox.email} is now verified, you can start creating alias with it", "success", ) - return redirect(url_for("dashboard.mailbox_route")) + return redirect( + url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox.id) + ) diff --git a/app/dashboard/views/mailbox_detail.py b/app/dashboard/views/mailbox_detail.py index e81d0606..018514f7 100644 --- a/app/dashboard/views/mailbox_detail.py +++ b/app/dashboard/views/mailbox_detail.py @@ -14,6 +14,8 @@ from app.extensions import db from app.log import LOG from app.models import GenEmail, DeletedAlias from app.models import Mailbox +from app.pgp_utils import PGPException, load_public_key +from smtplib import SMTPRecipientsRefused class ChangeEmailForm(FlaskForm): @@ -37,53 +39,92 @@ def mailbox_detail_route(mailbox_id): else: pending_email = None - if change_email_form.validate_on_submit(): - new_email = change_email_form.email.data - if new_email != mailbox.email and not pending_email: - # check if this email is not already used - if ( - email_already_used(new_email) - or GenEmail.get_by(email=new_email) - or DeletedAlias.get_by(email=new_email) - ): - flash(f"Email {new_email} already used", "error") - elif not can_be_used_as_personal_email(new_email): - flash("You cannot use this email address as your mailbox", "error") - else: - mailbox.new_email = new_email + if request.method == "POST": + if ( + request.form.get("form-name") == "update-email" + and change_email_form.validate_on_submit() + ): + new_email = change_email_form.email.data + if new_email != mailbox.email and not pending_email: + # check if this email is not already used + if ( + email_already_used(new_email) + or GenEmail.get_by(email=new_email) + or DeletedAlias.get_by(email=new_email) + ): + flash(f"Email {new_email} already used", "error") + elif not can_be_used_as_personal_email(new_email): + flash( + "You cannot use this email address as your mailbox", "error", + ) + else: + mailbox.new_email = new_email + db.session.commit() + + s = Signer(MAILBOX_SECRET) + mailbox_id_signed = s.sign(str(mailbox.id)).decode() + verification_url = ( + URL + + "/dashboard/mailbox/confirm_change" + + f"?mailbox_id={mailbox_id_signed}" + ) + + try: + send_email( + new_email, + f"Confirm mailbox change on SimpleLogin", + render( + "transactional/verify-mailbox-change.txt", + user=current_user, + link=verification_url, + mailbox_email=mailbox.email, + mailbox_new_email=new_email, + ), + render( + "transactional/verify-mailbox-change.html", + user=current_user, + link=verification_url, + mailbox_email=mailbox.email, + mailbox_new_email=new_email, + ), + ) + except SMTPRecipientsRefused: + flash( + f"Incorrect mailbox, please recheck {mailbox.email}", + "error", + ) + else: + flash( + f"You are going to receive an email to confirm {new_email}.", + "success", + ) + return redirect( + url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id) + ) + elif request.form.get("form-name") == "pgp": + if not current_user.can_use_pgp: + flash("You cannot use PGP", "error") + return redirect( + url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id) + ) + + if request.form.get("action") == "save": + mailbox.pgp_public_key = request.form.get("pgp") + try: + mailbox.pgp_finger_print = load_public_key(mailbox.pgp_public_key) + except PGPException: + flash("Cannot add the public key, please verify it", "error") + else: + db.session.commit() + flash("Your PGP public key is saved successfully", "success") + return redirect( + url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id) + ) + elif request.form.get("action") == "remove": + mailbox.pgp_public_key = None + mailbox.pgp_finger_print = None db.session.commit() - - s = Signer(MAILBOX_SECRET) - mailbox_id_signed = s.sign(str(mailbox.id)).decode() - verification_url = ( - URL - + "/dashboard/mailbox/confirm_change" - + f"?mailbox_id={mailbox_id_signed}" - ) - - send_email( - new_email, - f"Confirm mailbox change on SimpleLogin", - render( - "transactional/verify-mailbox-change.txt", - user=current_user, - link=verification_url, - mailbox_email=mailbox.email, - mailbox_new_email=new_email, - ), - render( - "transactional/verify-mailbox-change.html", - user=current_user, - link=verification_url, - mailbox_email=mailbox.email, - mailbox_new_email=new_email, - ), - ) - - flash( - f"You are going to receive an email to confirm {new_email}.", - "success", - ) + flash("Your PGP public key is removed successfully", "success") return redirect( url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id) ) @@ -122,19 +163,26 @@ def mailbox_confirm_change_route(): try: r_id = int(s.unsign(mailbox_id)) - except BadSignature: + except Exception: flash("Invalid link", "error") + return redirect(url_for("dashboard.index")) else: mailbox = Mailbox.get(r_id) - mailbox.email = mailbox.new_email - mailbox.new_email = None - # mark mailbox as verified if the change request is sent from an unverified mailbox - mailbox.verified = True - db.session.commit() + # new_email can be None if user cancels change in the meantime + if mailbox and mailbox.new_email: + mailbox.email = mailbox.new_email + mailbox.new_email = None - LOG.d("Mailbox change %s is verified", mailbox) - flash(f"The {mailbox.email} is updated", "success") - return redirect( - url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox.id) - ) + # mark mailbox as verified if the change request is sent from an unverified mailbox + mailbox.verified = True + db.session.commit() + + LOG.d("Mailbox change %s is verified", mailbox) + flash(f"The {mailbox.email} is updated", "success") + return redirect( + url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox.id) + ) + else: + flash("Invalid link", "error") + return redirect(url_for("dashboard.index")) diff --git a/app/models.py b/app/models.py index 10cffbb3..2b2fe6e6 100644 --- a/app/models.py +++ b/app/models.py @@ -143,6 +143,11 @@ class User(db.Model, ModelMixin, UserMixin): db.ForeignKey("mailbox.id"), nullable=True, default=None ) + # feature flag + can_use_pgp = db.Column( + db.Boolean, default=False, nullable=False, server_default="0" + ) + profile_picture = db.relationship(File) @classmethod @@ -920,6 +925,9 @@ class Mailbox(db.Model, ModelMixin): # used when user wants to update mailbox email new_email = db.Column(db.String(256), unique=True) + pgp_public_key = db.Column(db.Text, nullable=True) + pgp_finger_print = db.Column(db.String(512), nullable=True) + def nb_alias(self): return GenEmail.filter_by(mailbox_id=self.id).count() diff --git a/app/pgp_utils.py b/app/pgp_utils.py new file mode 100644 index 00000000..5b661454 --- /dev/null +++ b/app/pgp_utils.py @@ -0,0 +1,26 @@ +import gnupg + +from app.config import GNUPGHOME + +gpg = gnupg.GPG(gnupghome=GNUPGHOME) + + +class PGPException(Exception): + pass + + +def load_public_key(public_key: str) -> str: + """Load a public key into keyring and return the fingerprint. If error, raise Exception""" + import_result = gpg.import_keys(public_key) + try: + return import_result.fingerprints[0] + except Exception as e: + raise PGPException("Cannot load key") from e + + +def encrypt(data: str, fingerprint: str) -> str: + r = gpg.encrypt(data, fingerprint, always_trust=True) + if not r.ok: + raise PGPException("Cannot encrypt") + + return str(r) diff --git a/email_handler.py b/email_handler.py index 4f785bf5..19835135 100644 --- a/email_handler.py +++ b/email_handler.py @@ -31,13 +31,17 @@ It should contain the following info: """ import time +from email import encoders from email.message import Message +from email.mime.application import MIMEApplication +from email.mime.multipart import MIMEMultipart from email.parser import Parser from email.policy import SMTPUTF8 from smtplib import SMTP from typing import Optional from aiosmtpd.controller import Controller +import gnupg from app.config import ( EMAIL_DOMAIN, @@ -47,6 +51,7 @@ from app.config import ( ADMIN_EMAIL, SUPPORT_EMAIL, POSTFIX_SUBMISSION_TLS, + GNUPGHOME, ) from app.email_utils import ( get_email_name, @@ -74,6 +79,7 @@ from app.models import ( ) from app.utils import random_string from server import create_app +from app import pgp_utils # fix the database connection leak issue @@ -255,6 +261,31 @@ def should_append_alias(msg, alias): return True +def prepare_pgp_message(orig_msg: Message, pgp_fingerprint: str): + msg = MIMEMultipart("encrypted", protocol="application/pgp-encrypted") + + # copy all headers from original message except the "Content-Type" + for i in reversed(range(len(orig_msg._headers))): + header_name = orig_msg._headers[i][0].lower() + if header_name != "Content-Type".lower(): + msg[header_name] = orig_msg._headers[i][1] + + first = MIMEApplication( + _subtype="pgp-encrypted", _encoder=encoders.encode_7or8bit, _data="" + ) + first.set_payload("Version: 1") + msg.attach(first) + + second = MIMEApplication("octet-stream", _encoder=encoders.encode_7or8bit) + second.add_header("Content-Disposition", "inline") + # encrypt original message + encrypted_data = pgp_utils.encrypt(orig_msg.as_string(), pgp_fingerprint) + second.set_payload(encrypted_data) + msg.attach(second) + + return msg + + def handle_forward(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> str: """return *status_code message*""" alias = rcpt_to.lower() # alias@SL @@ -267,7 +298,14 @@ def handle_forward(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> str: LOG.d("alias %s cannot be created on-the-fly, return 510", alias) return "510 Email not exist" - mailbox_email = gen_email.mailbox_email() + mailbox = gen_email.mailbox + mailbox_email = mailbox.email + + # create PGP email if needed + if mailbox.pgp_finger_print: + LOG.d("Encrypt message using mailbox %s", mailbox) + msg = prepare_pgp_message(msg, mailbox.pgp_finger_print) + forward_email = get_or_create_forward_email(msg["From"], gen_email) forward_log = ForwardEmailLog.create(forward_id=forward_email.id) diff --git a/example.env b/example.env index 5fc0d855..5a57d3e6 100644 --- a/example.env +++ b/example.env @@ -109,4 +109,7 @@ FACEBOOK_CLIENT_SECRET=to_fill # Flask profiler # FLASK_PROFILER_PATH=/tmp/flask-profiler.sql -# FLASK_PROFILER_PASSWORD=password \ No newline at end of file +# FLASK_PROFILER_PASSWORD=password + +# Where to store GPG Keyring +# GNUPGHOME=/tmp/gnupg diff --git a/init_app.py b/init_app.py new file mode 100644 index 00000000..bf5fc728 --- /dev/null +++ b/init_app.py @@ -0,0 +1,30 @@ +"""Initial loading script""" +from app.models import Mailbox +from app.log import LOG +from app.extensions import db +from app.pgp_utils import load_public_key +from server import create_app + + +def load_pgp_public_keys(app): + """Load PGP public key to keyring""" + with app.app_context(): + for mailbox in Mailbox.query.filter(Mailbox.pgp_public_key != None).all(): + LOG.d("Load PGP key for mailbox %s", mailbox) + fingerprint = load_public_key(mailbox.pgp_public_key) + + # sanity check + if fingerprint != mailbox.pgp_finger_print: + LOG.error( + "fingerprint %s different for mailbox %s", fingerprint, mailbox + ) + mailbox.pgp_finger_print = fingerprint + + db.session.commit() + + +if __name__ == "__main__": + app = create_app() + + with app.app_context(): + load_pgp_public_keys(app) diff --git a/migrations/versions/2020_030813_628a5438295c_.py b/migrations/versions/2020_030813_628a5438295c_.py new file mode 100644 index 00000000..7caad986 --- /dev/null +++ b/migrations/versions/2020_030813_628a5438295c_.py @@ -0,0 +1,33 @@ +"""empty message + +Revision ID: 628a5438295c +Revises: 235355381f53 +Create Date: 2020-03-08 13:07:13.312858 + +""" +import sqlalchemy_utils +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '628a5438295c' +down_revision = '235355381f53' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('mailbox', sa.Column('pgp_finger_print', sa.String(length=512), nullable=True)) + op.add_column('mailbox', sa.Column('pgp_public_key', sa.Text(), nullable=True)) + op.add_column('users', sa.Column('can_use_pgp', sa.Boolean(), server_default='0', nullable=False)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('users', 'can_use_pgp') + op.drop_column('mailbox', 'pgp_public_key') + op.drop_column('mailbox', 'pgp_finger_print') + # ### end Alembic commands ### diff --git a/requirements.in b/requirements.in index 6eabfe21..3c3712c6 100644 --- a/requirements.in +++ b/requirements.in @@ -36,4 +36,5 @@ pyotp flask_profiler facebook-sdk google-api-python-client -google-auth-httplib2 \ No newline at end of file +google-auth-httplib2 +python-gnupg \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 2db4228d..1c67e09c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -49,8 +49,7 @@ google-auth==1.11.2 # via google-api-python-client, google-auth-httplib2 gunicorn==19.9.0 # via -r requirements.in httplib2==0.17.0 # via google-api-python-client, google-auth-httplib2 humanfriendly==4.18 # via coloredlogs -idna-ssl==1.1.0 # via aiohttp -idna==2.8 # via idna-ssl, requests, yarl +idna==2.8 # via requests, yarl importlib-metadata==0.18 # via pluggy, pytest ipython-genutils==0.2.0 # via traitlets ipython==7.5.0 # via -r requirements.in @@ -87,6 +86,7 @@ pytest==4.6.3 # via -r requirements.in python-dateutil==2.8.0 # via alembic, arrow, botocore, strictyaml python-dotenv==0.10.3 # via -r requirements.in python-editor==1.0.4 # via alembic +python-gnupg==0.4.5 # via -r requirements.in raven-aiohttp==0.7.0 # via yacron raven==6.10.0 # via raven-aiohttp, yacron requests-oauthlib==1.2.0 # via -r requirements.in @@ -101,7 +101,6 @@ sqlalchemy-utils==0.36.1 # via -r requirements.in sqlalchemy==1.3.12 # via alembic, flask-sqlalchemy, sqlalchemy-utils strictyaml==1.0.2 # via yacron traitlets==4.3.2 # via ipython -typing-extensions==3.7.4.1 # via aiohttp unidecode==1.0.23 # via -r requirements.in uritemplate==3.0.1 # via google-api-python-client urllib3==1.25.3 # via botocore, requests, sentry-sdk diff --git a/server.py b/server.py index f5418e6e..46abbff9 100644 --- a/server.py +++ b/server.py @@ -131,6 +131,7 @@ def fake_data(): activated=True, is_admin=True, otp_secret="base32secret3232", + can_use_pgp=True, ) db.session.commit() diff --git a/tests/test_pgp_utils.py b/tests/test_pgp_utils.py new file mode 100644 index 00000000..f9cd5f1d --- /dev/null +++ b/tests/test_pgp_utils.py @@ -0,0 +1,105 @@ +from app.pgp_utils import load_public_key, gpg, encrypt + +pubkey = """-----BEGIN PGP PUBLIC KEY BLOCK----- +Version: Keybase OpenPGP v1.0.0 +Comment: https://keybase.io/crypto + +xo0EXlqP9wEEALoJsLHZA5W4yGQf+TIlIuYjj72SGEXbZyvMJxDk89YE8SWHAP+L ++GkyNBfiPidJ1putLBOTDuxjDroDa6zMmjxCORUYdtq35RIDo/raamAaYg32X/TI +3WyL3lgVf7K+VhXntG2V3OfM1r5nt3C1sy8Rsvzbih3p+eHpE3xCImg7ABEBAAHN +FFRlc3QgPHRlc3RAc2wubG9jYWw+wq0EEwEKABcFAl5aj/cCGy8DCwkHAxUKCAIe +AQIXgAAKCRCtrxG3FC3nSGhDA/wMT4PM8pWCsbsGA32SMN0j0MRsmc6KT4BGX8qd +CwTv7s5DvZlkFL9uJQxcKFe+yYpjnrPvW0p81ispj7pVJqUTyx4brZHiWFi/vODz +YyzTXNJvWJOp27G4YzWPeEeSKuGjF1CQScmZJA5luay7mkI5gttw4q3iqJlcDDFq +1sz2486NBF5aj/cBBADA7KbOa8klxOC8Oact0zzc30SCGxtCLuFQCBI/dIrnv2KC +lIbUd+CDlmD+cKCIu7MlrYPhCLF24MYnUXVFDbT3fP8YVy2HZTfk4Q64tj0S17ve +E9H1G1W6FqdDUhMCU1EmJgd8sKOrNOFtz4+b3IHJhtJIoUILDkiMjfUCHmQaqQAR +AQABwsCDBBgBCgAPBQJeWo/3BQkPCZwAAhsuAKgJEK2vEbcULedInSAEGQEKAAYF +Al5aj/cACgkQDjygQt7BuGFtPQQAmzUJqXB4UWo9HPZfutqSU6GElSMwZq1Dlf8S +Stjq7cYK+HSfcyw4wSBMRxMtG2zmbyhWlYTqx3fAAjgE32dBI/Rq8ku60u6SGEiE +egKCcm0lyR1TVUTYEsfjiYD5AmGWng8tTavz1ANdEoE66wGApkETfmTM7hOuQrKm +BjXpmembUQP5AXln8rWuDkeXVXhBa5RR3NgoD/fos2QJ5NxkZdfPmM57EwQkEXKv +S3c5rlvvhIupElSyJkxOzfykNlJewVrLxCicj+JPSt7ly6YlkMQglyevntI46y1l +2Msf0oeQZ3uedURGQiGQalC7nzPFnOARbNffFEJI3cJhcLkr2UFdL0rOjQReWo/3 +AQQA+MJeovqVVVrE1Vsc3M/BuG5ao7xyP1y7YhgmJg3gi8HR7b4/ySJtKnCYAmLg +wwjfCUWed/GZ+3bGw48x8Fmn+6QTPG04j8RUOMUgVt9jc+TxC8VWSvqH1Taho3MK +6ZQpCwXPO0FmWc5ybp0AJzqy2YS4eZwue1WH3zFzjXrOBd0AEQEAAcLAgwQYAQoA +DwUCXlqP9wUJDwmcAAIbLgCoCRCtrxG3FC3nSJ0gBBkBCgAGBQJeWo/3AAoJEFJq +ki+hZCNMgH4EAPiamTuezRtMIEWpjEjYGjpRF+2uj5VmU6N2E6+5Nh73HUKNCVRj +AWeRarplye/CqZyhzPgotDNzAPzE4smo0N0vvc4zi6toqMiO4ODjR313d0y0v4iP ++n576QwpfGw/ddlTEL7Iv28dzdKJArjNc2/jRxefHrAYSzjEunl/GUq+ToQD/izQ +mPo6SWhlODsIy4eR/u3NpKtQQcs40XWLVci6M66ntyl5XmBGgFFu0WHIYeDOnTRc +qL1W5yEYaaJhaEbmNGk3tf26Ns9cTl91S2eylO9nWGOnqFg58jP63TZVR7q3jIq1 +e5DKgszG2Vvye+bbK6qMKmaIXEMhnjw9eZuW6MGf +=yDVI +-----END PGP PUBLIC KEY BLOCK----- +""" + +private_key = """-----BEGIN PGP PRIVATE KEY BLOCK----- +Version: Keybase OpenPGP v1.0.0 +Comment: https://keybase.io/crypto + +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 +U9jtqNTsHrr0XFzA2zno+zcopiZZOoPXcwxLf+pCetjN5EOkpMgqZTtV2nCppQRB +d3ZpsguOO4OVexEW6gWGOuas5+/qa846it9VMo+nlqtLyIAFbj2P02Zk/QrUnPF3 +PEjKDJssrOEnZWlpAdEDfFhC1OrBVlG0lkD1qHDCNO9MTeT2dRMghbFGxlno9z2K +wnnB+Ep4UULuvbh08GsVflQPaA0a59IFDbOzYc7puS5kpJ5fQWwdXZvjNc/jOeQX +BHaLfQKmWYW3pCxs0BqKRhAnZ9E+kkIL6xU6MlJPs/NGO7aAykrFv8BdmBJQ8s00 +9LGlgSUhdEdIsn5h3Kdn0f/7FXXWwsCDBBgBCgAPBQJeWo/3BQkPCZwAAhsuAKgJ +EK2vEbcULedInSAEGQEKAAYFAl5aj/cACgkQUmqSL6FkI0yAfgQA+JqZO57NG0wg +RamMSNgaOlEX7a6PlWZTo3YTr7k2HvcdQo0JVGMBZ5FqumXJ78KpnKHM+Ci0M3MA +/MTiyajQ3S+9zjOLq2ioyI7g4ONHfXd3TLS/iI/6fnvpDCl8bD912VMQvsi/bx3N +0okCuM1zb+NHF58esBhLOMS6eX8ZSr5OhAP+LNCY+jpJaGU4OwjLh5H+7c2kq1BB +yzjRdYtVyLozrqe3KXleYEaAUW7RYchh4M6dNFyovVbnIRhpomFoRuY0aTe1/bo2 +z1xOX3VLZ7KU72dYY6eoWDnyM/rdNlVHureMirV7kMqCzMbZW/J75tsrqowqZohc +QyGePD15m5bowZ8= +=4OSo +-----END PGP PRIVATE KEY BLOCK-----""" + + +def test_load_public_key(): + load_public_key(pubkey) + assert len(gpg.list_keys()) == 1 + + +def test_encrypt(): + fingerprint = load_public_key(pubkey) + secret = encrypt("abcd", fingerprint) + assert secret != ""