Merge pull request #103 from simple-login/staging

Add support for PGP
This commit is contained in:
Son Nguyen Kim 2020-03-09 14:00:15 +01:00 committed by GitHub
commit 3266a5982a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 427 additions and 68 deletions

View File

@ -38,7 +38,7 @@ def auth_mfa():
s = Signer(FLASK_SECRET) s = Signer(FLASK_SECRET)
try: try:
user_id = int(s.unsign(mfa_key)) user_id = int(s.unsign(mfa_key))
except BadSignature: except Exception:
return jsonify(error="Invalid mfa_key"), 400 return jsonify(error="Invalid mfa_key"), 400
user = User.get(user_id) user = User.get(user_id)

View File

@ -1,5 +1,8 @@
import os import os
import random
import string
import subprocess import subprocess
import tempfile
from uuid import uuid4 from uuid import uuid4
from dotenv import load_dotenv 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") 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, Google, Facebook client id and secrets
GITHUB_CLIENT_ID = os.environ.get("GITHUB_CLIENT_ID") GITHUB_CLIENT_ID = os.environ.get("GITHUB_CLIENT_ID")

View File

@ -41,6 +41,10 @@
🚫 🚫
</span> </span>
{% endif %} {% endif %}
{% if mailbox.pgp_finger_print %}
<span class="cursor" data-toggle="tooltip" data-original-title="PGP Enabled">🗝</span>
{% endif %}
{% if mailbox.id == current_user.default_mailbox_id %} {% if mailbox.id == current_user.default_mailbox_id %}
<div class="badge badge-primary float-right" data-toggle="tooltip" <div class="badge badge-primary float-right" data-toggle="tooltip"
title="When a new random alias is created, it belongs to the default mailbox">Default Mailbox title="When a new random alias is created, it belongs to the default mailbox">Default Mailbox

View File

@ -17,8 +17,19 @@
🚫 🚫
</span> </span>
{% endif %} {% endif %}
{% if mailbox.pgp_finger_print %}
<span class="cursor" data-toggle="tooltip" data-original-title="PGP Enabled">🗝</span>
{% endif %}
</h1> </h1>
{% if not mailbox.verified %}
<div class="alert alert-info">
Mailbox not verified, please check your inbox/spam folder for the verification email.
<br>
To receive the verification email again, you can delete and re-add the mailbox.
</div>
{% endif %}
<!-- Change email --> <!-- Change email -->
<div class="card"> <div class="card">
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
@ -52,6 +63,37 @@
</div> </div>
<!-- END Change email --> <!-- END Change email -->
<!-- Change PGP Public key -->
{% if current_user.can_use_pgp %}
<div class="card">
<form method="post">
<input type="hidden" name="form-name" value="pgp">
<div class="card-body">
<div class="card-title">
Pretty Good Privacy (PGP)
<div class="small-text">
By importing your PGP Public Key into SimpleLogin, all emails sent to {{mailbox.email}} are <b>encrypted</b> with your key.
</div>
</div>
<div class="form-group">
<label class="form-label">PGP Public Key</label>
<textarea name="pgp" class="form-control" rows=10 placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----">{{mailbox.pgp_public_key or ""}}</textarea>
</div>
<button class="btn btn-primary" name="action" value="save">Save</button>
<button class="btn btn-danger float-right" name="action" value="remove">Remove</button>
</div>
</form>
</div>
{% endif %}
<!-- END PGP Public key -->
</div> </div>
{% endblock %} {% endblock %}

View File

@ -20,7 +20,7 @@ def billing():
if request.method == "POST": if request.method == "POST":
if request.form.get("form-name") == "cancel": 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) success = cancel_subscription(sub.subscription_id)
if success: if success:

View File

@ -120,7 +120,11 @@ def mailbox_route():
"success", "success",
) )
return redirect(url_for("dashboard.mailbox_route")) return redirect(
url_for(
"dashboard.mailbox_detail_route", mailbox_id=new_mailbox.id
)
)
return render_template( return render_template(
"dashboard/mailbox.html", "dashboard/mailbox.html",
@ -138,8 +142,9 @@ def mailbox_verify():
try: try:
r_id = int(s.unsign(mailbox_id)) r_id = int(s.unsign(mailbox_id))
except BadSignature: except Exception:
flash("Invalid link. Please delete and re-add your mailbox", "error") flash("Invalid link. Please delete and re-add your mailbox", "error")
return redirect(url_for("dashboard.mailbox_route"))
else: else:
mailbox = Mailbox.get(r_id) mailbox = Mailbox.get(r_id)
mailbox.verified = True mailbox.verified = True
@ -150,4 +155,6 @@ def mailbox_verify():
f"The {mailbox.email} is now verified, you can start creating alias with it", f"The {mailbox.email} is now verified, you can start creating alias with it",
"success", "success",
) )
return redirect(url_for("dashboard.mailbox_route")) return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox.id)
)

View File

@ -14,6 +14,8 @@ from app.extensions import db
from app.log import LOG from app.log import LOG
from app.models import GenEmail, DeletedAlias from app.models import GenEmail, DeletedAlias
from app.models import Mailbox from app.models import Mailbox
from app.pgp_utils import PGPException, load_public_key
from smtplib import SMTPRecipientsRefused
class ChangeEmailForm(FlaskForm): class ChangeEmailForm(FlaskForm):
@ -37,53 +39,92 @@ def mailbox_detail_route(mailbox_id):
else: else:
pending_email = None pending_email = None
if change_email_form.validate_on_submit(): if request.method == "POST":
new_email = change_email_form.email.data if (
if new_email != mailbox.email and not pending_email: request.form.get("form-name") == "update-email"
# check if this email is not already used and change_email_form.validate_on_submit()
if ( ):
email_already_used(new_email) new_email = change_email_form.email.data
or GenEmail.get_by(email=new_email) if new_email != mailbox.email and not pending_email:
or DeletedAlias.get_by(email=new_email) # check if this email is not already used
): if (
flash(f"Email {new_email} already used", "error") email_already_used(new_email)
elif not can_be_used_as_personal_email(new_email): or GenEmail.get_by(email=new_email)
flash("You cannot use this email address as your mailbox", "error") or DeletedAlias.get_by(email=new_email)
else: ):
mailbox.new_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() db.session.commit()
flash("Your PGP public key is removed successfully", "success")
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",
)
return redirect( return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id) url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
) )
@ -122,19 +163,26 @@ def mailbox_confirm_change_route():
try: try:
r_id = int(s.unsign(mailbox_id)) r_id = int(s.unsign(mailbox_id))
except BadSignature: except Exception:
flash("Invalid link", "error") flash("Invalid link", "error")
return redirect(url_for("dashboard.index"))
else: else:
mailbox = Mailbox.get(r_id) 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 # new_email can be None if user cancels change in the meantime
mailbox.verified = True if mailbox and mailbox.new_email:
db.session.commit() mailbox.email = mailbox.new_email
mailbox.new_email = None
LOG.d("Mailbox change %s is verified", mailbox) # mark mailbox as verified if the change request is sent from an unverified mailbox
flash(f"The {mailbox.email} is updated", "success") mailbox.verified = True
return redirect( db.session.commit()
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox.id)
) 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"))

View File

@ -143,6 +143,11 @@ class User(db.Model, ModelMixin, UserMixin):
db.ForeignKey("mailbox.id"), nullable=True, default=None 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) profile_picture = db.relationship(File)
@classmethod @classmethod
@ -920,6 +925,9 @@ class Mailbox(db.Model, ModelMixin):
# used when user wants to update mailbox email # used when user wants to update mailbox email
new_email = db.Column(db.String(256), unique=True) 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): def nb_alias(self):
return GenEmail.filter_by(mailbox_id=self.id).count() return GenEmail.filter_by(mailbox_id=self.id).count()

26
app/pgp_utils.py Normal file
View File

@ -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)

View File

@ -31,13 +31,17 @@ It should contain the following info:
""" """
import time import time
from email import encoders
from email.message import Message from email.message import Message
from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart
from email.parser import Parser from email.parser import Parser
from email.policy import SMTPUTF8 from email.policy import SMTPUTF8
from smtplib import SMTP from smtplib import SMTP
from typing import Optional from typing import Optional
from aiosmtpd.controller import Controller from aiosmtpd.controller import Controller
import gnupg
from app.config import ( from app.config import (
EMAIL_DOMAIN, EMAIL_DOMAIN,
@ -47,6 +51,7 @@ from app.config import (
ADMIN_EMAIL, ADMIN_EMAIL,
SUPPORT_EMAIL, SUPPORT_EMAIL,
POSTFIX_SUBMISSION_TLS, POSTFIX_SUBMISSION_TLS,
GNUPGHOME,
) )
from app.email_utils import ( from app.email_utils import (
get_email_name, get_email_name,
@ -74,6 +79,7 @@ from app.models import (
) )
from app.utils import random_string from app.utils import random_string
from server import create_app from server import create_app
from app import pgp_utils
# fix the database connection leak issue # fix the database connection leak issue
@ -255,6 +261,31 @@ def should_append_alias(msg, alias):
return True 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: def handle_forward(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> str:
"""return *status_code message*""" """return *status_code message*"""
alias = rcpt_to.lower() # alias@SL 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) LOG.d("alias %s cannot be created on-the-fly, return 510", alias)
return "510 Email not exist" 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_email = get_or_create_forward_email(msg["From"], gen_email)
forward_log = ForwardEmailLog.create(forward_id=forward_email.id) forward_log = ForwardEmailLog.create(forward_id=forward_email.id)

View File

@ -109,4 +109,7 @@ FACEBOOK_CLIENT_SECRET=to_fill
# Flask profiler # Flask profiler
# FLASK_PROFILER_PATH=/tmp/flask-profiler.sql # FLASK_PROFILER_PATH=/tmp/flask-profiler.sql
# FLASK_PROFILER_PASSWORD=password # FLASK_PROFILER_PASSWORD=password
# Where to store GPG Keyring
# GNUPGHOME=/tmp/gnupg

30
init_app.py Normal file
View File

@ -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)

View File

@ -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 ###

View File

@ -36,4 +36,5 @@ pyotp
flask_profiler flask_profiler
facebook-sdk facebook-sdk
google-api-python-client google-api-python-client
google-auth-httplib2 google-auth-httplib2
python-gnupg

View File

@ -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 gunicorn==19.9.0 # via -r requirements.in
httplib2==0.17.0 # via google-api-python-client, google-auth-httplib2 httplib2==0.17.0 # via google-api-python-client, google-auth-httplib2
humanfriendly==4.18 # via coloredlogs humanfriendly==4.18 # via coloredlogs
idna-ssl==1.1.0 # via aiohttp idna==2.8 # via requests, yarl
idna==2.8 # via idna-ssl, requests, yarl
importlib-metadata==0.18 # via pluggy, pytest importlib-metadata==0.18 # via pluggy, pytest
ipython-genutils==0.2.0 # via traitlets ipython-genutils==0.2.0 # via traitlets
ipython==7.5.0 # via -r requirements.in 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-dateutil==2.8.0 # via alembic, arrow, botocore, strictyaml
python-dotenv==0.10.3 # via -r requirements.in python-dotenv==0.10.3 # via -r requirements.in
python-editor==1.0.4 # via alembic python-editor==1.0.4 # via alembic
python-gnupg==0.4.5 # via -r requirements.in
raven-aiohttp==0.7.0 # via yacron raven-aiohttp==0.7.0 # via yacron
raven==6.10.0 # via raven-aiohttp, yacron raven==6.10.0 # via raven-aiohttp, yacron
requests-oauthlib==1.2.0 # via -r requirements.in 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 sqlalchemy==1.3.12 # via alembic, flask-sqlalchemy, sqlalchemy-utils
strictyaml==1.0.2 # via yacron strictyaml==1.0.2 # via yacron
traitlets==4.3.2 # via ipython traitlets==4.3.2 # via ipython
typing-extensions==3.7.4.1 # via aiohttp
unidecode==1.0.23 # via -r requirements.in unidecode==1.0.23 # via -r requirements.in
uritemplate==3.0.1 # via google-api-python-client uritemplate==3.0.1 # via google-api-python-client
urllib3==1.25.3 # via botocore, requests, sentry-sdk urllib3==1.25.3 # via botocore, requests, sentry-sdk

View File

@ -131,6 +131,7 @@ def fake_data():
activated=True, activated=True,
is_admin=True, is_admin=True,
otp_secret="base32secret3232", otp_secret="base32secret3232",
can_use_pgp=True,
) )
db.session.commit() db.session.commit()

105
tests/test_pgp_utils.py Normal file
View File

@ -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 != ""