diff --git a/app/dashboard/views/mailbox_detail.py b/app/dashboard/views/mailbox_detail.py index 06527b497..3d9562120 100644 --- a/app/dashboard/views/mailbox_detail.py +++ b/app/dashboard/views/mailbox_detail.py @@ -179,12 +179,53 @@ def mailbox_detail_route(mailbox_id): elif request.form.get("form-name") == "toggle-pgp": if request.form.get("pgp-enabled") == "on": + if not mailbox.disable_smime: + mailbox.disable_smime = True + flash(f"S/MIME is disabled on {mailbox.email}", "warning") mailbox.disable_pgp = False flash(f"PGP is enabled on {mailbox.email}", "success") else: mailbox.disable_pgp = True flash(f"PGP is disabled on {mailbox.email}", "info") + Session.commit() + return redirect( + url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id) + ) + elif request.form.get("form-name") == "smime": + if request.form.get("action") == "save": + if not current_user.is_premium(): + flash("Only premium plan can add S/MIME Key", "warning") + return redirect( + url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id) + ) + + mailbox.smime_public_key = request.form.get("smime") + Session.commit() + flash("Your S/MIME public key is saved successfully", "success") + return redirect( + url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id) + ) + elif request.form.get("action") == "remove": + # Free user can decide to remove their added S/MIME key + mailbox.smime_public_key = None + mailbox.disable_smime = False + Session.commit() + flash("Your S/MIME public key is removed successfully", "success") + return redirect( + url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id) + ) + elif request.form.get("form-name") == "toggle-smime": + if request.form.get("smime-enabled") == "on": + if not mailbox.disable_pgp: + mailbox.disable_pgp = True + flash(f"PGP is disabled on {mailbox.email}", "warning") + mailbox.disable_smime = False + flash(f"S/MIME is enabled on {mailbox.email}", "success") + else: + mailbox.disable_smime = True + flash(f"S/MIME is disabled on {mailbox.email}", "info") + Session.commit() return redirect( url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id) diff --git a/app/models.py b/app/models.py index 69f825e33..4cfe6d051 100644 --- a/app/models.py +++ b/app/models.py @@ -2566,6 +2566,12 @@ class Mailbox(Base, ModelMixin): sa.Boolean, default=False, nullable=False, server_default="0" ) + # smime + smime_public_key = sa.Column(sa.Text, nullable=True) + disable_smime = sa.Column( + sa.Boolean, default=True, nullable=False, server_default="0" + ) + # incremented when a check is failed on the mailbox # alert when the number exceeds a threshold # used in sanity_check() @@ -2588,6 +2594,12 @@ def pgp_enabled(self) -> bool: return False + def smime_enabled(self) -> bool: + if self.smime_public_key and not self.disable_smime: + return True + + return False + def nb_alias(self): return ( AliasMailbox.filter_by(mailbox_id=self.id).count() diff --git a/email_handler.py b/email_handler.py index c5be1995a..39a4073f0 100644 --- a/email_handler.py +++ b/email_handler.py @@ -32,8 +32,10 @@ """ import argparse import email +import smail import time import uuid +from asn1crypto import pem, x509 from email import encoders from email.encoders import encode_noop from email.message import Message @@ -535,6 +537,21 @@ def prepare_pgp_message( return msg +def prepare_smime_message(orig_msg: Message, public_key: str) -> Message: + # clone orig message to avoid modifying it + clone_msg = copy(orig_msg) + + # create certificate object using public key + _, _, der_bytes = pem.unarmor(public_key.encode()) + cert = x509.Certificate.load(der_bytes) + + # encrypt the message + clone_msg = smail.encrypt_message(clone_msg, [cert]) + + # return the message + return clone_msg + + def sign_msg(msg: Message) -> Message: container = MIMEMultipart( "signed", protocol="application/pgp-signature", micalg="pgp-sha256" @@ -908,6 +925,26 @@ def forward_email_to_mailbox( f"""PGP encryption fails with {mailbox.email}'s PGP key""", ) + # create SMIME email if needed + if mailbox.smime_enabled() and user.is_premium(): + LOG.d("Encrypt message using S/MIME for mailbox %s", mailbox) + + try: + msg = prepare_smime_message(msg, mailbox.smime_public_key) + except Exception as exceptasdf: + LOG.w( + "Cannot S/MIME encrypt message %s -> %s. %s %s", + contact, + alias, + mailbox, + user, + ) + LOG.w(exceptasdf) + msg = add_header( + msg, + f"""S/MIME encryption fails with {mailbox.email}'s S/MIME key""", + ) + # add custom header add_or_replace_header(msg, headers.SL_DIRECTION, "Forward") diff --git a/migrations/versions/2023_111521_fb0ab73c1825_.py b/migrations/versions/2023_111521_fb0ab73c1825_.py new file mode 100644 index 000000000..feb548a1b --- /dev/null +++ b/migrations/versions/2023_111521_fb0ab73c1825_.py @@ -0,0 +1,31 @@ +"""empty message + +Revision ID: fb0ab73c1825 +Revises: 4bc54632d9aa +Create Date: 2023-11-15 21:50:40.424160 + +""" +import sqlalchemy_utils +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'fb0ab73c1825' +down_revision = '4bc54632d9aa' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('mailbox', sa.Column('disable_smime', sa.Boolean(), server_default='0', nullable=False)) + op.add_column('mailbox', sa.Column('smime_public_key', sa.Text(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('mailbox', 'smime_public_key') + op.drop_column('mailbox', 'disable_smime') + # ### end Alembic commands ### diff --git a/poetry.lock b/poetry.lock index a1fce36d6..41ca8432b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.0 and should not be changed by hand. [[package]] name = "aiohttp" @@ -211,6 +211,17 @@ files = [ [package.dependencies] python-dateutil = ">=2.7.0" +[[package]] +name = "asn1crypto" +version = "1.5.1" +description = "Fast ASN.1 parser and serializer with definitions for private keys, public keys, certificates, CRL, OCSP, CMS, PKCS#3, PKCS#7, PKCS#8, PKCS#12, PKCS#5, X.509 and TSP" +optional = false +python-versions = "*" +files = [ + {file = "asn1crypto-1.5.1-py2.py3-none-any.whl", hash = "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67"}, + {file = "asn1crypto-1.5.1.tar.gz", hash = "sha256:13ae38502be632115abf8a24cbe5f4da52e3b5231990aff31123c805306ccb9c"}, +] + [[package]] name = "astroid" version = "2.11.6" @@ -1984,6 +1995,20 @@ rsa = ["cryptography"] signals = ["blinker"] signedtoken = ["cryptography", "pyjwt (>=1.0.0)"] +[[package]] +name = "oscrypto" +version = "1.3.0" +description = "TLS (SSL) sockets, key generation, encryption, decryption, signing, verification and KDFs using the OS crypto libraries. Does not require a compiler, and relies on the OS for patching. Works on Windows, OS X and Linux/BSD." +optional = false +python-versions = "*" +files = [ + {file = "oscrypto-1.3.0-py2.py3-none-any.whl", hash = "sha256:2b2f1d2d42ec152ca90ccb5682f3e051fb55986e1b170ebde472b133713e7085"}, + {file = "oscrypto-1.3.0.tar.gz", hash = "sha256:6f5fef59cb5b3708321db7cca56aed8ad7e662853351e7991fcf60ec606d47a4"}, +] + +[package.dependencies] +asn1crypto = ">=1.5.1" + [[package]] name = "packaging" version = "20.4" @@ -2587,6 +2612,24 @@ files = [ {file = "python_gnupg-0.4.6-py2.py3-none-any.whl", hash = "sha256:cba3566e8a8fb7bb417d6897a6e17bfc7f9371052e57eb0057783c07d762a679"}, ] +[[package]] +name = "python-smail" +version = "0.9.0" +description = "Simple S/MIME e-mails with Python3" +optional = false +python-versions = "*" +files = [ + {file = "python-smail-0.9.0.tar.gz", hash = "sha256:e0da2fea2189a8dece2ab1ea78a670ba2f1d025742ea118b7358e5a9ca2f052f"}, + {file = "python_smail-0.9.0-py2-none-any.whl", hash = "sha256:b15e085efcf10813c37bb9fc0c1b0d6564a39b30b91c308cd443201942c718e5"}, +] + +[package.dependencies] +asn1crypto = "*" +oscrypto = "*" + +[package.extras] +test = ["coverage", "flake8", "tox"] + [[package]] name = "pytz" version = "2020.1" @@ -3674,4 +3717,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "8bf71c74c8f4d1afe6b1ab0912702cdb47086474168bed8a9230c398abf349dd" +content-hash = "26e549af9d4b1ee78dff425d13af22f9a9df017bf59a4662a3176a43a01de3ee" diff --git a/pyproject.toml b/pyproject.toml index 0ef202b8b..46f0f20d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,6 +112,7 @@ SQLAlchemy = "1.3.24" redis = "^4.5.3" newrelic-telemetry-sdk = "^0.5.0" aiospamc = "0.10" +python-smail = "^0.9.0" [tool.poetry.dev-dependencies] pytest = "^7.0.0" diff --git a/static/package-lock.json b/static/package-lock.json index 17bc7b4a6..3e74a3d31 100644 --- a/static/package-lock.json +++ b/static/package-lock.json @@ -1,123 +1,169 @@ { "name": "simplelogin", "version": "1.0.0", - "lockfileVersion": 1, + "lockfileVersion": 3, "requires": true, - "dependencies": { - "@sentry/browser": { + "packages": { + "": { + "name": "simplelogin", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@sentry/browser": "^5.30.0", + "bootbox": "^5.5.3", + "font-awesome": "^4.7.0", + "htmx.org": "^1.6.1", + "intro.js": "^2.9.3", + "multiple-select": "^1.5.2", + "parsleyjs": "^2.9.2", + "qrious": "^4.0.2", + "toastr": "^2.1.4", + "vue": "^2.6.14" + } + }, + "node_modules/@sentry/browser": { "version": "5.30.0", "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-5.30.0.tgz", "integrity": "sha512-rOb58ZNVJWh1VuMuBG1mL9r54nZqKeaIlwSlvzJfc89vyfd7n6tQ1UXMN383QBz/MS5H5z44Hy5eE+7pCrYAfw==", - "requires": { + "dependencies": { "@sentry/core": "5.30.0", "@sentry/types": "5.30.0", "@sentry/utils": "5.30.0", "tslib": "^1.9.3" + }, + "engines": { + "node": ">=6" } }, - "@sentry/core": { + "node_modules/@sentry/core": { "version": "5.30.0", "resolved": "https://registry.npmjs.org/@sentry/core/-/core-5.30.0.tgz", "integrity": "sha512-TmfrII8w1PQZSZgPpUESqjB+jC6MvZJZdLtE/0hZ+SrnKhW3x5WlYLvTXZpcWePYBku7rl2wn1RZu6uT0qCTeg==", - "requires": { + "dependencies": { "@sentry/hub": "5.30.0", "@sentry/minimal": "5.30.0", "@sentry/types": "5.30.0", "@sentry/utils": "5.30.0", "tslib": "^1.9.3" + }, + "engines": { + "node": ">=6" } }, - "@sentry/hub": { + "node_modules/@sentry/hub": { "version": "5.30.0", "resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-5.30.0.tgz", "integrity": "sha512-2tYrGnzb1gKz2EkMDQcfLrDTvmGcQPuWxLnJKXJvYTQDGLlEvi2tWz1VIHjunmOvJrB5aIQLhm+dcMRwFZDCqQ==", - "requires": { + "dependencies": { "@sentry/types": "5.30.0", "@sentry/utils": "5.30.0", "tslib": "^1.9.3" + }, + "engines": { + "node": ">=6" } }, - "@sentry/minimal": { + "node_modules/@sentry/minimal": { "version": "5.30.0", "resolved": "https://registry.npmjs.org/@sentry/minimal/-/minimal-5.30.0.tgz", "integrity": "sha512-BwWb/owZKtkDX+Sc4zCSTNcvZUq7YcH3uAVlmh/gtR9rmUvbzAA3ewLuB3myi4wWRAMEtny6+J/FN/x+2wn9Xw==", - "requires": { + "dependencies": { "@sentry/hub": "5.30.0", "@sentry/types": "5.30.0", "tslib": "^1.9.3" + }, + "engines": { + "node": ">=6" } }, - "@sentry/types": { + "node_modules/@sentry/types": { "version": "5.30.0", "resolved": "https://registry.npmjs.org/@sentry/types/-/types-5.30.0.tgz", - "integrity": "sha512-R8xOqlSTZ+htqrfteCWU5Nk0CDN5ApUTvrlvBuiH1DyP6czDZ4ktbZB0hAgBlVcK0U+qpD3ag3Tqqpa5Q67rPw==" + "integrity": "sha512-R8xOqlSTZ+htqrfteCWU5Nk0CDN5ApUTvrlvBuiH1DyP6czDZ4ktbZB0hAgBlVcK0U+qpD3ag3Tqqpa5Q67rPw==", + "engines": { + "node": ">=6" + } }, - "@sentry/utils": { + "node_modules/@sentry/utils": { "version": "5.30.0", "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-5.30.0.tgz", "integrity": "sha512-zaYmoH0NWWtvnJjC9/CBseXMtKHm/tm40sz3YfJRxeQjyzRqNQPgivpd9R/oDJCYj999mzdW382p/qi2ypjLww==", - "requires": { + "dependencies": { "@sentry/types": "5.30.0", "tslib": "^1.9.3" + }, + "engines": { + "node": ">=6" } }, - "bootbox": { + "node_modules/bootbox": { "version": "5.5.3", "resolved": "https://registry.npmjs.org/bootbox/-/bootbox-5.5.3.tgz", - "integrity": "sha512-B4mnm1DYgNHzoNtD7I0L/fixqvya4EEQy5bFF/yNmGI2Eq3WwVVwdfWf3hoF8KS+EaV4f0uIMqtxB1EAZwZPhQ==" + "integrity": "sha512-B4mnm1DYgNHzoNtD7I0L/fixqvya4EEQy5bFF/yNmGI2Eq3WwVVwdfWf3hoF8KS+EaV4f0uIMqtxB1EAZwZPhQ==", + "peerDependencies": { + "bootstrap": "^3.1.0 || ^4.4.0", + "jquery": "^3.5.1", + "popper.js": "^1.16.0" + } }, - "font-awesome": { + "node_modules/font-awesome": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/font-awesome/-/font-awesome-4.7.0.tgz", - "integrity": "sha512-U6kGnykA/6bFmg1M/oT9EkFeIYv7JlX3bozwQJWiiLz6L0w3F5vBVPxHlwyX/vtNq1ckcpRKOB9f2Qal/VtFpg==" + "integrity": "sha512-U6kGnykA/6bFmg1M/oT9EkFeIYv7JlX3bozwQJWiiLz6L0w3F5vBVPxHlwyX/vtNq1ckcpRKOB9f2Qal/VtFpg==", + "engines": { + "node": ">=0.10.3" + } }, - "htmx.org": { + "node_modules/htmx.org": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-1.7.0.tgz", "integrity": "sha512-wIQ3yNq7yiLTm+6BhV7Z8qKKTzEQv9xN/I4QsN5FvdGi69SNWTsSMlhH69HPa1rpZ8zSq1A/e7gTbTySxliP8g==" }, - "intro.js": { + "node_modules/intro.js": { "version": "2.9.3", "resolved": "https://registry.npmjs.org/intro.js/-/intro.js-2.9.3.tgz", "integrity": "sha512-hC+EXWnEuJeA3CveGMat3XHePd2iaXNFJIVfvJh2E9IzBMGLTlhWvPIVHAgKlOpO4lNayCxEqzr4N02VmHFr9Q==" }, - "jquery": { + "node_modules/jquery": { "version": "3.6.4", "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.4.tgz", "integrity": "sha512-v28EW9DWDFpzcD9O5iyJXg3R3+q+mET5JhnjJzQUZMHOv67bpSIHq81GEYpPNZHG+XXHsfSme3nxp/hndKEcsQ==" }, - "multiple-select": { + "node_modules/multiple-select": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/multiple-select/-/multiple-select-1.5.2.tgz", - "integrity": "sha512-sTNNRrjnTtB1b1+HTKcjQ/mjWY7Gvigo9F3C/3oTQCTFEpYzwaRYFPRAOu2SogfA1hEfyJTXjyS1VAbanJMsmA==" + "integrity": "sha512-sTNNRrjnTtB1b1+HTKcjQ/mjWY7Gvigo9F3C/3oTQCTFEpYzwaRYFPRAOu2SogfA1hEfyJTXjyS1VAbanJMsmA==", + "peerDependencies": { + "jquery": "1.9.1 - 3" + } }, - "parsleyjs": { + "node_modules/parsleyjs": { "version": "2.9.2", "resolved": "https://registry.npmjs.org/parsleyjs/-/parsleyjs-2.9.2.tgz", "integrity": "sha512-DKS2XXTjEUZ1BJWUzgXAr+550kFBZrom2WYweubqdV7WzdNC1hjOajZDfeBPoAZMkXumJPlB3v37IKatbiW8zQ==", - "requires": { + "dependencies": { "jquery": ">=1.8.0" } }, - "qrious": { + "node_modules/qrious": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/qrious/-/qrious-4.0.2.tgz", "integrity": "sha512-xWPJIrK1zu5Ypn898fBp8RHkT/9ibquV2Kv24S/JY9VYEhMBMKur1gHVsOiNUh7PHP9uCgejjpZUHUIXXKoU/g==" }, - "toastr": { + "node_modules/toastr": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/toastr/-/toastr-2.1.4.tgz", "integrity": "sha512-LIy77F5n+sz4tefMmFOntcJ6HL0Fv3k1TDnNmFZ0bU/GcvIIfy6eG2v7zQmMiYgaalAiUv75ttFrPn5s0gyqlA==", - "requires": { + "dependencies": { "jquery": ">=1.12.0" } }, - "tslib": { + "node_modules/tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" }, - "vue": { + "node_modules/vue": { "version": "2.6.14", "resolved": "https://registry.npmjs.org/vue/-/vue-2.6.14.tgz", "integrity": "sha512-x2284lgYvjOMj3Za7kqzRcUSxBboHqtgRE2zlos1qWaOye5yUmHn42LB1250NJBLRwEcdrB0JRwyPTEPhfQjiQ==" diff --git a/templates/dashboard/mailbox_detail.html b/templates/dashboard/mailbox_detail.html index 61a2c0595..67d75abe0 100644 --- a/templates/dashboard/mailbox_detail.html +++ b/templates/dashboard/mailbox_detail.html @@ -138,6 +138,53 @@

+ +
+
+
+
+ S/MIME + {% if mailbox.smime_public_key %} + +
+ {{ csrf_form.csrf_token }} + + +
+ {% endif %} +
+
+ By importing your S/MIME Public Key into SimpleLogin, all emails sent to {{ mailbox.email }} are + encrypted with your key. +
+
+ {% if not current_user.is_premium() %} + + + {% endif %} +
+ {{ csrf_form.csrf_token }} +
+ + +
+ + + {% if mailbox.smime_public_key %} + + + {% endif %} +
+
+
+
{{ csrf_form.csrf_token }}