forked from Disroot/gpg-lacre
Compare commits
2 commits
main
...
php_update
Author | SHA1 | Date | |
---|---|---|---|
e9ad17e7bc | |||
c6b0f921c9 |
100 changed files with 2584 additions and 5215 deletions
8
.gitignore
vendored
8
.gitignore
vendored
|
@ -1,6 +1,3 @@
|
|||
# Generated project files:
|
||||
test/lacre.db
|
||||
|
||||
*.py[cod]
|
||||
|
||||
# C extensions
|
||||
|
@ -13,6 +10,7 @@ dist
|
|||
build
|
||||
eggs
|
||||
parts
|
||||
bin
|
||||
var
|
||||
sdist
|
||||
develop-eggs
|
||||
|
@ -28,10 +26,10 @@ pip-log.txt
|
|||
.tox
|
||||
nosetests.xml
|
||||
|
||||
# Lacre test files
|
||||
# GPG-Mailgate test files
|
||||
test/logs
|
||||
test/tmp
|
||||
test/lacre.conf
|
||||
test/gpg-mailgate.conf
|
||||
test/keyhome/random_seed
|
||||
|
||||
# Emacs files
|
||||
|
|
|
@ -1,24 +1,22 @@
|
|||
#
|
||||
# lacre
|
||||
# gpg-mailgate
|
||||
#
|
||||
# This file is part of the lacre source code.
|
||||
# This file is part of the gpg-mailgate source code.
|
||||
#
|
||||
# lacre is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
# gpg-mailgate is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# lacre source code is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
# gpg-mailgate source code is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with lacre source code. If not, see <http://www.gnu.org/licenses/>.
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with gpg-mailgate source code. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
"""GnuPG wrapper module."""
|
||||
|
||||
import os
|
||||
import os.path
|
||||
import subprocess
|
||||
|
@ -26,331 +24,144 @@ import shutil
|
|||
import random
|
||||
import string
|
||||
import sys
|
||||
import logging
|
||||
import re
|
||||
import tempfile
|
||||
from email.utils import parseaddr
|
||||
|
||||
|
||||
LINE_FINGERPRINT = 'fpr'
|
||||
LINE_USER_ID = 'uid'
|
||||
LINE_PUBLIC_KEY = 'pub'
|
||||
|
||||
POS_FINGERPRINT = 9
|
||||
POS_UID = 9
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
def build_command(key_home, *args, **kwargs):
|
||||
cmd = ["gpg", '--homedir', key_home] + list(args)
|
||||
return cmd
|
||||
|
||||
RX_CONFIRM = re.compile(br'key "([^"]+)" imported')
|
||||
def private_keys( keyhome ):
|
||||
cmd = build_command(keyhome, '--list-secret-keys', '--with-colons')
|
||||
p = subprocess.Popen( cmd, stdin=None, stdout=subprocess.PIPE, stderr=subprocess.PIPE )
|
||||
p.wait()
|
||||
keys = dict()
|
||||
for line in p.stdout.readlines():
|
||||
if line[0:3] == 'uid' or line[0:3] == 'sec':
|
||||
if ('<' not in line or '>' not in line):
|
||||
continue
|
||||
email = line.split('<')[1].split('>')[0]
|
||||
fingerprint = line.split(':')[4]
|
||||
keys[fingerprint] = email
|
||||
return keys
|
||||
|
||||
def public_keys( keyhome ):
|
||||
cmd = build_command(keyhome, '--list-keys', '--with-colons')
|
||||
p = subprocess.Popen( cmd, stdin=None, stdout=subprocess.PIPE, stderr=subprocess.PIPE )
|
||||
p.wait()
|
||||
|
||||
class EncryptionException(Exception):
|
||||
"""Represents a failure to encrypt a payload.
|
||||
keys = dict()
|
||||
fingerprint = None
|
||||
email = None
|
||||
for line in p.stdout.readlines():
|
||||
line = line.decode('utf-8')
|
||||
if line[0:3] == LINE_FINGERPRINT:
|
||||
fingerprint = line.split(':')[POS_FINGERPRINT]
|
||||
if line[0:3] == LINE_USER_ID:
|
||||
if ('<' not in line or '>' not in line):
|
||||
continue
|
||||
email = line.split('<')[1].split('>')[0]
|
||||
if not (fingerprint is None or email is None):
|
||||
keys[fingerprint] = email
|
||||
fingerprint = None
|
||||
email = None
|
||||
return keys
|
||||
|
||||
Arguments passed to exception constructor:
|
||||
- issue: human-readable explanation of the issue;
|
||||
- recipient: owner of the key;
|
||||
- cause: any additional information, if present;
|
||||
- key: fingerprint of the key.
|
||||
"""
|
||||
pass
|
||||
# confirms a key has a given email address
|
||||
def confirm_key( content, email ):
|
||||
tmpkeyhome = ''
|
||||
|
||||
while True:
|
||||
tmpkeyhome = '/tmp/' + ''.join(random.choice(string.ascii_uppercase + string.digits) for x in range(12))
|
||||
if not os.path.exists(tmpkeyhome):
|
||||
break
|
||||
|
||||
def _build_command(key_home, *args, **kwargs):
|
||||
cmd = ["gpg", '--homedir', key_home]
|
||||
cmd.extend(args)
|
||||
return cmd
|
||||
os.mkdir(tmpkeyhome)
|
||||
localized_env = os.environ.copy()
|
||||
localized_env["LANG"] = "C"
|
||||
p = subprocess.Popen( build_command(tmpkeyhome, '--import', '--batch'), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=localized_env )
|
||||
result = p.communicate(input=content)[1]
|
||||
confirmed = False
|
||||
|
||||
for line in result.split("\n"):
|
||||
if 'imported' in line and '<' in line and '>' in line:
|
||||
if line.split('<')[1].split('>')[0].lower() == email.lower():
|
||||
confirmed = True
|
||||
break
|
||||
else:
|
||||
break # confirmation failed
|
||||
|
||||
def public_keys(keyhome, *, key_id=None):
|
||||
"""List public keys from keyring KEYHOME.
|
||||
|
||||
Returns a dict with fingerprints as keys and email as values."""
|
||||
cmd = _build_command(keyhome, '--list-keys', '--with-colons')
|
||||
if key_id is not None:
|
||||
cmd.append(key_id)
|
||||
|
||||
p = subprocess.Popen(cmd, stdin=None, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
p.wait()
|
||||
|
||||
keys = dict()
|
||||
collected = set()
|
||||
|
||||
fingerprint = None
|
||||
email = None
|
||||
|
||||
for line in p.stdout.readlines():
|
||||
line = line.decode(sys.getdefaultencoding())
|
||||
|
||||
if line[0:3] == LINE_PUBLIC_KEY:
|
||||
# New identity has started, reset state.
|
||||
fingerprint = None
|
||||
email = None
|
||||
|
||||
if line[0:3] == LINE_FINGERPRINT and not fingerprint:
|
||||
fingerprint = _extract_fingerprint(line)
|
||||
|
||||
if line[0:3] == LINE_USER_ID:
|
||||
email = _parse_uid_line(line)
|
||||
|
||||
if fingerprint and email and not email in collected:
|
||||
keys[fingerprint] = email
|
||||
collected.add(email)
|
||||
fingerprint = None
|
||||
email = None
|
||||
|
||||
p.stdout.close()
|
||||
p.stderr.close()
|
||||
return keys
|
||||
|
||||
|
||||
def _extract_fingerprint(line):
|
||||
fpr_line = line.split(':')
|
||||
if len(fpr_line) <= POS_FINGERPRINT:
|
||||
return None
|
||||
else:
|
||||
return fpr_line[POS_FINGERPRINT]
|
||||
|
||||
|
||||
def _parse_uid_line(line: str):
|
||||
userid_line = line.split(':')
|
||||
if len(userid_line) <= POS_UID:
|
||||
return None
|
||||
else:
|
||||
(_, email) = parseaddr(userid_line[POS_UID])
|
||||
return email
|
||||
|
||||
|
||||
def _to_bytes(s) -> bytes:
|
||||
if isinstance(s, str):
|
||||
return bytes(s, sys.getdefaultencoding())
|
||||
else:
|
||||
return s
|
||||
|
||||
|
||||
# Confirms a key has a given email address by importing it into a temporary
|
||||
# keyring. If this operation succeeds and produces a message mentioning the
|
||||
# expected email, a key is confirmed.
|
||||
def confirm_key(content, email: str):
|
||||
"""Verify that the key CONTENT is assigned to identity EMAIL."""
|
||||
content = _to_bytes(content)
|
||||
expected_email = email.lower()
|
||||
|
||||
tmpkeyhome = tempfile.mkdtemp()
|
||||
LOG.debug('Importing into temporary directory: %s', tmpkeyhome)
|
||||
|
||||
result = _import_key(tmpkeyhome, content)
|
||||
confirmed = False
|
||||
|
||||
for line in result.splitlines():
|
||||
LOG.debug('Line from GnuPG: %s', line)
|
||||
found = RX_CONFIRM.search(line)
|
||||
if found:
|
||||
(_, extracted_email) = parseaddr(found.group(1).decode())
|
||||
confirmed = (extracted_email == expected_email)
|
||||
LOG.debug('Confirmed email %s: %s', extracted_email, confirmed)
|
||||
|
||||
# cleanup
|
||||
shutil.rmtree(tmpkeyhome)
|
||||
|
||||
return confirmed
|
||||
|
||||
|
||||
def _import_key(keyhome, content):
|
||||
content = _to_bytes(content)
|
||||
|
||||
# Ensure we get expected output regardless of the system locale.
|
||||
localized_env = os.environ.copy()
|
||||
localized_env["LANG"] = "C"
|
||||
|
||||
p = subprocess.Popen(_build_command(keyhome, '--import', '--batch'), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=localized_env)
|
||||
output = p.communicate(input=content)[1]
|
||||
p.wait()
|
||||
|
||||
return output
|
||||
# cleanup
|
||||
shutil.rmtree(tmpkeyhome)
|
||||
|
||||
return confirmed
|
||||
|
||||
# adds a key and ensures it has the given email address
|
||||
def add_key(keyhome, content):
|
||||
"""Register new key CONTENT in the keyring KEYHOME."""
|
||||
output = _import_key(keyhome, content)
|
||||
def add_key( keyhome, content ):
|
||||
p = subprocess.Popen( build_command(keyhome, '--import', '--batch'), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE )
|
||||
p.communicate(input=content)
|
||||
p.wait()
|
||||
|
||||
email = None
|
||||
for line in output.splitlines():
|
||||
found = RX_CONFIRM.search(line)
|
||||
if found:
|
||||
(_, extracted_email) = parseaddr(found.group(1).decode())
|
||||
email = extracted_email
|
||||
def delete_key( keyhome, email ):
|
||||
from email.utils import parseaddr
|
||||
result = parseaddr(email)
|
||||
|
||||
# Find imported key to get its fingerprint
|
||||
imported = public_keys(keyhome, key_id=email)
|
||||
|
||||
if len(imported.keys()) == 1:
|
||||
fingerprint = list(imported.keys())[0]
|
||||
return fingerprint, imported[fingerprint]
|
||||
else:
|
||||
return None, None
|
||||
|
||||
|
||||
def delete_key(keyhome, email):
|
||||
"""Remove key assigned to identity EMAIL from keyring KEYHOME."""
|
||||
result = parseaddr(email)
|
||||
|
||||
if result[1]:
|
||||
# delete all keys matching this email address
|
||||
p = subprocess.Popen(_build_command(keyhome, '--delete-key', '--batch', '--yes', result[1]), stdin=None, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
p.communicate()
|
||||
p.wait()
|
||||
return True
|
||||
|
||||
LOG.warn('Failed to parse email before deleting key: %s', email)
|
||||
return False
|
||||
if result[1]:
|
||||
# delete all keys matching this email address
|
||||
p = subprocess.Popen( build_command(keyhome, '--delete-key', '--batch', '--yes', result[1]), stdin=None, stdout=subprocess.PIPE, stderr=subprocess.PIPE )
|
||||
p.wait()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
class GPGEncryptor:
|
||||
"""A wrapper for 'gpg -e' command."""
|
||||
def __init__(self, keyhome, recipients = None, charset = None):
|
||||
self._keyhome = keyhome
|
||||
self._message = b''
|
||||
self._recipients = list()
|
||||
self._charset = charset
|
||||
if recipients != None:
|
||||
self._recipients.extend(recipients)
|
||||
|
||||
def __init__(self, keyhome, recipients=None, charset=None):
|
||||
"""Initialise the wrapper."""
|
||||
self._keyhome = keyhome
|
||||
self._message = None
|
||||
self._recipients = list()
|
||||
self._charset = charset
|
||||
if recipients is not None:
|
||||
self._recipients.extend(recipients)
|
||||
def update(self, message):
|
||||
self._message += message
|
||||
|
||||
def update(self, message):
|
||||
"""Append MESSAGE to buffer about to be encrypted."""
|
||||
if self._message is None:
|
||||
self._message = message
|
||||
else:
|
||||
self._message += message
|
||||
def encrypt(self):
|
||||
p = subprocess.Popen( self._command(), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE )
|
||||
encdata = p.communicate(input=self._message)[0]
|
||||
return (encdata, p.returncode)
|
||||
|
||||
def encrypt(self):
|
||||
"""Feed GnuPG with the message."""
|
||||
p = self._popen()
|
||||
encdata, err = p.communicate(input=self._message)
|
||||
if p.returncode != 0:
|
||||
LOG.debug('Errors: %s', err)
|
||||
details = parse_status(err)
|
||||
raise EncryptionException(details['issue'], details['recipient'], details['cause'], details['key'])
|
||||
return (encdata, p.returncode)
|
||||
def _command(self):
|
||||
cmd = build_command(self._keyhome, "--trust-model", "always", "--batch", "--yes", "--pgp7", "--no-secmem-warning", "-a", "-e")
|
||||
|
||||
def _popen(self):
|
||||
if self._charset:
|
||||
return subprocess.Popen(self._command(), stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
encoding=self._charset)
|
||||
else:
|
||||
return subprocess.Popen(self._command(), stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE)
|
||||
# add recipients
|
||||
for recipient in self._recipients:
|
||||
cmd.append("-r")
|
||||
cmd.append(recipient)
|
||||
|
||||
def _command(self):
|
||||
cmd = _build_command(self._keyhome,
|
||||
"--trust-model", "always",
|
||||
"--status-fd", "2",
|
||||
"--batch",
|
||||
"--yes",
|
||||
"--pgp7",
|
||||
"--no-secmem-warning",
|
||||
"-a", "-e")
|
||||
|
||||
# add recipients
|
||||
for recipient in self._recipients:
|
||||
cmd.append("-r")
|
||||
cmd.append(recipient)
|
||||
|
||||
# add on the charset, if set
|
||||
if self._charset:
|
||||
cmd.append("--comment")
|
||||
cmd.append('Charset: ' + self._charset)
|
||||
|
||||
LOG.debug('Built command: %s', cmd)
|
||||
return cmd
|
||||
# add on the charset, if set
|
||||
if self._charset:
|
||||
cmd.append("--comment")
|
||||
cmd.append('Charset: ' + self._charset)
|
||||
|
||||
return cmd
|
||||
|
||||
class GPGDecryptor:
|
||||
"""A wrapper for 'gpg -d' command."""
|
||||
def __init__(self, keyhome):
|
||||
self._keyhome = keyhome
|
||||
self._message = ''
|
||||
|
||||
def __init__(self, keyhome):
|
||||
"""Initialise the wrapper."""
|
||||
self._keyhome = keyhome
|
||||
self._message = ''
|
||||
def update(self, message):
|
||||
self._message += message
|
||||
|
||||
def update(self, message):
|
||||
"""Append encrypted content to be decrypted."""
|
||||
self._message += message
|
||||
def decrypt(self):
|
||||
p = subprocess.Popen( self._command(), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE )
|
||||
decdata = p.communicate(input=self._message)[0]
|
||||
return (decdata, p.returncode)
|
||||
|
||||
def decrypt(self):
|
||||
"""Decrypt the message."""
|
||||
p = subprocess.Popen(self._command(), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
decdata = p.communicate(input=self._message)[0]
|
||||
return (decdata, p.returncode)
|
||||
|
||||
def _command(self):
|
||||
return _build_command(self._keyhome, "--trust-model", "always", "--batch", "--yes", "--no-secmem-warning", "-a", "-d")
|
||||
|
||||
|
||||
STATUS_FD_PREFIX = b'[GNUPG:] '
|
||||
STATUS_FD_PREFIX_LEN = len(STATUS_FD_PREFIX)
|
||||
|
||||
KEY_EXPIRED = b'KEYEXPIRED'
|
||||
KEY_REVOKED = b'KEYREVOKED'
|
||||
NO_RECIPIENTS = b'NO_RECP'
|
||||
INVALID_RECIPIENT = b'INV_RECP'
|
||||
KEY_CONSIDERED = b'KEY_CONSIDERED'
|
||||
NOAVAIL = b'n/a'
|
||||
|
||||
# INV_RECP reason code descriptions.
|
||||
INVALID_RECIPIENT_CAUSES = [
|
||||
'No specific reason given',
|
||||
'Not Found',
|
||||
'Ambiguous specification',
|
||||
'Wrong key usage',
|
||||
'Key revoked',
|
||||
'Key expired',
|
||||
'No CRL known',
|
||||
'CRL too old',
|
||||
'Policy mismatch',
|
||||
'Not a secret key',
|
||||
'Key not trusted',
|
||||
'Missing certificate',
|
||||
'Missing issuer certificate',
|
||||
'Key disabled',
|
||||
'Syntax error in specification'
|
||||
]
|
||||
|
||||
|
||||
def parse_status(status_buffer: str) -> dict:
|
||||
"""Parse --status-fd output and return important information."""
|
||||
return parse_status_lines(status_buffer.splitlines())
|
||||
|
||||
|
||||
def parse_status_lines(lines: list) -> dict:
|
||||
"""Parse --status-fd output and return important information."""
|
||||
result = {'issue': NOAVAIL, 'recipient': NOAVAIL, 'cause': 'Unknown', 'key': NOAVAIL}
|
||||
|
||||
LOG.debug('Processing stderr lines %s', lines)
|
||||
|
||||
for line in lines:
|
||||
LOG.debug('At gnupg stderr line %s', line)
|
||||
if not line.startswith(STATUS_FD_PREFIX):
|
||||
continue
|
||||
|
||||
if line.startswith(KEY_EXPIRED, STATUS_FD_PREFIX_LEN):
|
||||
result['issue'] = 'key expired'
|
||||
elif line.startswith(KEY_REVOKED, STATUS_FD_PREFIX_LEN):
|
||||
result['issue'] = 'key revoked'
|
||||
elif line.startswith(NO_RECIPIENTS, STATUS_FD_PREFIX_LEN):
|
||||
result['issue'] = 'no recipients'
|
||||
elif line.startswith(KEY_CONSIDERED, STATUS_FD_PREFIX_LEN):
|
||||
result['key'] = line.split(b' ')[2]
|
||||
elif line.startswith(INVALID_RECIPIENT, STATUS_FD_PREFIX_LEN):
|
||||
words = line.split(b' ')
|
||||
reason_code = int(words[2])
|
||||
result['recipient'] = words[3]
|
||||
|
||||
if reason_code:
|
||||
result['cause'] = INVALID_RECIPIENT_CAUSES[reason_code]
|
||||
|
||||
return result
|
||||
def _command(self):
|
||||
return build_command(self._keyhome, "--trust-model", "always", "--batch", "--yes", "--no-secmem-warning", "-a", "-d")
|
||||
|
|
227
INSTALL.md
227
INSTALL.md
|
@ -1,214 +1,183 @@
|
|||
# Installation instructions
|
||||
|
||||
## Content
|
||||
|
||||
- General information
|
||||
- Install Lacre
|
||||
- Install [Lacre-Webgate](https://git.disroot.org/Lacre/lacre-webgate)
|
||||
- Install GPG-Mailgate
|
||||
- Install GPG-Mailgate-Web
|
||||
- Install Register-handler
|
||||
|
||||
## General information
|
||||
GPG-Mailgate is divided in 3 main parts: GPG-Mailgate itself, GPG-Mailgate-Web and Register-handler. Some parts of the GPG-Mailgate project depend on other parts of the project. You will find information about these dependencies at the beginning of every installation part.
|
||||
|
||||
Lacre is divided in 3 main parts: Lacre itself, Lacre-Webgate and Register-handler. Some parts of the Lacre project depend on other parts of the project. You will find information about these dependencies at the beginning of every installation part.
|
||||
|
||||
These instructions show you how to set up Lacre in an easy way. If you are a more advanced user, feel free to experiment with the settings. For these instructions a home directory for the user `nobody` is set. Sadly this is an odd workaround but no better solution was found.
|
||||
These instructions show you how to set up GPG-Mailgate in an easy way. If you are a more advanced user, feel free to experiment with the settings. For these instructions a home directory for the user `nobody` is set. Sadly this is an odd workaround but no better solution was found.
|
||||
|
||||
These instructions are based on an installation on an Ubuntu 14.04 LTS virtual machine. For other Linux distributions and other versions these instructions might need to be adapted to your distribution (e.g. installation of packages and used directories).
|
||||
|
||||
## Install Lacre
|
||||
|
||||
## Install GPG-Mailgate
|
||||
### Requirements
|
||||
|
||||
- Python 3.9.
|
||||
- Dependencies listed in [requirements file](https://packaging.python.org/en/latest/tutorials/installing-packages/#requirements-files), `requirements.txt`.
|
||||
- Postfix: installed, configured and tested.
|
||||
- GnuPG: installed, configured and tested (e.g. via command-line).
|
||||
- Python 2.X is already installed (GPG-Mailgate is not Python 3 compatible)
|
||||
- Postfix is already installed and configured. It is recommended that you have already tested your configuration so we can exclude this as a main cause of problems
|
||||
- GnuPG is already installed and configured
|
||||
|
||||
### Installation
|
||||
|
||||
1. Install the dependencies:
|
||||
|
||||
```
|
||||
python -m pip install -r requirements.txt
|
||||
```
|
||||
1. Install the Python-M2Crypto module:
|
||||
|
||||
apt-get install python-m2crypto
|
||||
|
||||
2. Set the home directory for the user `nobody` (sadly this workaround is needed as there is no better solution at this point). If you get an error that the user is currently used by a process, you might need to kill the process manually.
|
||||
|
||||
```
|
||||
usermod -d /var/lacre nobody
|
||||
```
|
||||
|
||||
usermod -d /var/gpgmailgate nobody
|
||||
|
||||
3. Create dedicated directories for storing PGP keys and S/MIME certificates and make the user `nobody` owner of these:
|
||||
|
||||
mkdir -p /var/gpgmailgate/.gnupg
|
||||
mkdir -p /var/gpgmailgate/smime
|
||||
chown -R nobody:nogroup /var/gpgmailgate/
|
||||
|
||||
```
|
||||
install --owner=nobody --group=nogroup -d /var/lacre/ /var/lacre/.gnupg /var/lacre/smime
|
||||
```
|
||||
4. Place the `gpg-mailgate.py` in `/usr/local/bin/`, make the user `nobody` owner of the file and make it executable:
|
||||
|
||||
4. Place the `lacre.py` in `/usr/local/bin/`, make the user `nobody` owner of the file and make it executable:
|
||||
chown nobody:nogroup /usr/local/bin/gpg-mailgate.py
|
||||
chmod u+x /usr/local/bin/gpg-mailgate.py
|
||||
|
||||
```
|
||||
install --owner=nobody --group=nogroup --mode=u=rx lacre.py /usr/local/bin/
|
||||
```
|
||||
5. Place the `GnuPG` directory in `/usr/local/lib/python2.7/dist-packages` (replace 2.7 with your Python 2 version)
|
||||
|
||||
5. Place `GnuPG` and `lacre` directories in `/usr/local/lib/python3.x/` (replace 3.x with your Python version). Make sure they're available for Python `import`s by executing `python -m lacre.admin -h` command.
|
||||
6. Configure `/etc/gpg-mailgate.conf` based on the provided `gpg-mailgate.conf.sample`. Change the settings according to your configuration. If you follow this guide and have a standard configuration for postfix, you don't need to change much.
|
||||
|
||||
```
|
||||
cp -r lacre /usr/local/lib/python3.9/
|
||||
cp -r GnuPG/ /usr/local/lib/python3.9/
|
||||
```
|
||||
7. Add the following to the end of `/etc/postfix/master.cf`
|
||||
|
||||
6. Configure `/etc/lacre.conf` based on the provided `lacre.conf.sample`. Change the settings according to your configuration. If you follow this guide and have a standard configuration for postfix, you don't need to change much.
|
||||
gpg-mailgate unix - n n - - pipe
|
||||
flags= user=nobody argv=/usr/local/bin/gpg-mailgate.py ${recipient}
|
||||
|
||||
7. Configure logging by copying `lacre-logging.conf.sample` to `/etc/lacre-logging.conf` and editing it according to your needs. The path to this file is included in `[logging]` section of `lacre.conf` file, so if you place it somewhere else, make sure to update the path too. See also: Python logging package's [Configuration file format](https://docs.python.org/3/library/logging.config.html#configuration-file-format).
|
||||
127.0.0.1:10028 inet n - n - 10 smtpd
|
||||
-o content_filter=
|
||||
-o receive_override_options=no_unknown_recipient_checks,no_header_body_checks
|
||||
-o smtpd_helo_restrictions=
|
||||
-o smtpd_client_restrictions=
|
||||
-o smtpd_sender_restrictions=
|
||||
-o smtpd_recipient_restrictions=permit_mynetworks,reject
|
||||
-o mynetworks=127.0.0.0/8
|
||||
-o smtpd_authorized_xforward_hosts=127.0.0.0/8
|
||||
|
||||
8. Add the following to the end of `/etc/postfix/master.cf`
|
||||
If you use Postfix versions from 2.5 onwards, it is recommended to change `${recipient}` to `${original_recipient}` in line two of the lines above.
|
||||
|
||||
```
|
||||
lacre unix - n n - - pipe
|
||||
flags= user=nobody argv=/usr/local/bin/lacre.py ${recipient}
|
||||
8. Add the following line to `/etc/postfix/main.cf`
|
||||
|
||||
127. 0. 0. 1:10028 inet n - n - 10 smtpd
|
||||
-o content_filter=
|
||||
-o receive_override_options=no_unknown_recipient_checks,no_header_body_checks
|
||||
-o smtpd_helo_restrictions=
|
||||
-o smtpd_client_restrictions=
|
||||
-o smtpd_sender_restrictions=
|
||||
-o smtpd_recipient_restrictions=permit_mynetworks,reject
|
||||
-o mynetworks=127. 0. 0. 0/8
|
||||
-o smtpd_authorized_xforward_hosts=127. 0. 0. 0/8
|
||||
```
|
||||
content_filter = gpg-mailgate
|
||||
|
||||
If you use Postfix versions from 2.5 onwards, it is recommended to change `${recipient}` to `${original_recipient}` in second line of the snippet above.
|
||||
9. Optional: GPG can automatically download new public keys for automatic signature verification. To enable automatic create the file `/var/gpgmailgate/.gnupg/gpg.conf`. Add the following line to the file:
|
||||
|
||||
9. Add the following line to `/etc/postfix/main.cf`
|
||||
keyserver-options auto-key-retrieve
|
||||
|
||||
```
|
||||
content_filter = lacre
|
||||
```
|
||||
|
||||
10. Optional: GPG can automatically download new public keys for automatic signature verification. To enable automatic create the file `/var/gpgmailgate/.gnupg/gpg.conf`. Add the following line to the file:
|
||||
|
||||
```
|
||||
keyserver-options auto-key-retrieve
|
||||
```
|
||||
|
||||
11. Restart Postfix
|
||||
10. Restart Postfix
|
||||
|
||||
You are now ready to go. To add a public key for encryption just use the following command:
|
||||
|
||||
sudo -u nobody /usr/bin/gpg --homedir=/var/gpgmailgate/.gnupg --import /some/public.key
|
||||
|
||||
```
|
||||
sudo -u nobody /usr/bin/gpg --homedir=/var/gpgmailgate/.gnupg --import /some/public.key
|
||||
```
|
||||
|
||||
- Replace `/some/public.key` with the location of a public key (`/some/public.key` can be deleted after the import).
|
||||
- Confirm that it's working: `sudo -u nobody /usr/bin/gpg --list-keys --homedir=/var/gpgmailgate/.gnupg`
|
||||
|
||||
If you already have a keyring you would like to import into Lacre, you can use `lacre.admin` command-line utility. Read more in [Lacre administration](doc/admin.md).
|
||||
- Replace `/some/public.key` with the location of a public key
|
||||
- `/some/public.key` can be deleted after importation
|
||||
- Confirm that it's working:
|
||||
`sudo -u nobody /usr/bin/gpg --list-keys --homedir=/var/gpgmailgate/.gnupg`
|
||||
|
||||
Please also test your installation before using it.
|
||||
|
||||
Lacre is also able to handle S/MIME certificates for encrypting mails. However, it is best to use it in combination with Register-Handler described later to add new certificates. If you try to add them manually it might fail. The certificates are stored in `/var/gpgmailgate/smime` in PKCS7 format and are named like `User@example.com` (the user part is case sensitive, the domain part should be in lower case).
|
||||
|
||||
#### Additional settings
|
||||
GPG-Mailgate is also able to handle S/MIME certificates for encrypting mails. However, it is best to use it in combination with Register-Handler described later to add new certificates. If you try to add them manually it might fail. The certificates are stored in `/var/gpgmailgate/smime` in PKCS7 format and are named like `User@example.com` (the user part is case sensitive, the domain part should be in lower case).
|
||||
|
||||
####Additional settings
|
||||
Most mail servers do not handle mail addresses case sensitive. If you know that all your recipient mail servers do not care about case sensitivity then you can set `mail_case_insensitive` in the settings to `yes` so looking up PGP keys or S/MIME certificates does also happen case insensitive.
|
||||
If your recipients have problems to decrypt mails encrypted by Lacre they might use a piece of software that does not support PGP/MIME encrypted mails. You can tell Lacre to use the legacy PGP/INLINE format by adding the recipient to the `pgp_style` map in the following format:
|
||||
If your recipients have problems to decrypt mails encrypted by GPG-Mailgate they might use a piece of software that does not support PGP/MIME encrypted mails. You can tell GPG-Mailgate to use the legacy PGP/INLINE format by adding the recipient to the `pgp_style` map in the following format:
|
||||
`User@example.com=inline`
|
||||
|
||||
|
||||
## Install Lacre-Webgate
|
||||
### Mail decryption
|
||||
GPG-Mailgate does not only feature encryption of mails but also decryption of PGP encrypted mails.
|
||||
#### Important notice
|
||||
**Read carefully before setting up and using this functionality!**
|
||||
|
||||
With this functionality you could use GPG-Mailgate to decrypt incoming PGP encrypted mails (it is also capable of decrypting outgoing mails if the necessary key is present). To use this, you need to store your private keys on the server. This means that anyone who is able to obtain admin rights on the server is able to get the private keys stored on the server and is able to decrypt any mail encrypted with the corresponding public key. **If the server gets compromised in any kind and the attacker may have gained access to the server's file system, the keys have to be regarded as compromised as well!** If this happens you have to revoke your keys, notify everyone who has your public key (key servers as well) not to use this key any longer. You also need to create a new key pair for encrypted communication.
|
||||
|
||||
#### Limitations
|
||||
There are two main types of PGP encryption: PGP/MIME and PGP/INLINE. PGP/MIME is standardized while PGP/INLINE isn't completely clear standardized (even though some people claim so). Decrypting PGP/MIME encrypted mails works in most cases while decrypting PGP/INLINE encrypted mails may fail more often. The reason is that most clients are implementing PGP/INLINE in their own way. GPG-Mailgate is able to decrypt mails which are encrypted PGP/INLINE by GPG-Mailgate on the sender's side. Furthermore it should be able to decrypt PGP/INLINE encrypted mails encrypted by Enigmail. For PGP/INLINE the mail's structure may not be preserved due to how PGP/INLINE is implemented on most clients. If you receive a PGP/INLINE encrypted mail that could not be decrypted by GPG-Mailgate you may ask the sender to use PGP/MIME instead. Furthermore file types might get lost when using PGP/INLINE. Due to this limitations decrypting PGP/INLINE encrypted mails is disabled by default. If you want to take the risk you can set `no_inline_dec` to `no` in the `[default]` section. You have been warned.
|
||||
|
||||
#### Setting up decryption
|
||||
You need the recipient's private key for whom you want to decrypt mails. Only unprotected keys are supported. Keys protected by a passphrase could not be used. To add the private key, use the following command:
|
||||
`sudo -u nobody /usr/bin/gpg --homedir=/var/gpgmailgate/.gnupg --import /some/private.key`
|
||||
From now on PGP encrypted mails will be decrypted for the recipients for whom the keys are imported.
|
||||
|
||||
You also can remove a private key by using the following command. Replace `user@example.com` with the user's address for whom you want to remove the key:
|
||||
`sudo -u nobody /usr/bin/gpg --homedir=/var/gpgmailgate/.gnupg --delete-secret-keys user@example.com`
|
||||
|
||||
## Install GPG-Mailgate-Web
|
||||
### Requirements
|
||||
|
||||
- A webserver is installed and reachable
|
||||
- The webserver is able to handle PHP scripts
|
||||
- MySQL is installed
|
||||
- Python 3.x is already installed
|
||||
- Python 2.X is already installed
|
||||
|
||||
### Installation
|
||||
|
||||
All files you need can be found in the
|
||||
[Lacre / lacre-webgate](https://git.disroot.org/Lacre/lacre-webgate/)
|
||||
repository.
|
||||
All files you need can be found in the [gpg-mailgate-web] (gpg-mailgate-web/) directory.
|
||||
|
||||
1. Install the Python-mysqldb and Python-markdown modules:
|
||||
|
||||
```
|
||||
apt-get install python-mysqldb python-markdown
|
||||
```
|
||||
apt-get install python-mysqldb python-markdown
|
||||
|
||||
2. Create a new database for Lacre-Webgate.
|
||||
2. Create a new database for GPG-Mailgate-Web.
|
||||
|
||||
3. Import the schema file `schema.sql` into the newly created database.
|
||||
|
||||
4. Edit the config file located at `/etc/lacre.conf`. Set `enabled = yes` in `[database]` and fill in the necessary settings for the database connection.
|
||||
4. Edit the config file located at `/etc/gpg-mailgate.conf`. Set `enabled = yes` in `[database]` and fill in the necessary settings for the database connection.
|
||||
|
||||
5. Copy the files located in the [public_html](https://git.disroot.org/Lacre/lacre-webgate/src/branch/main/public_html) directory onto your webserver. They can also be placed in a subdirectory on your webserver.
|
||||
5. Copy the files located in the [public_html] (gpg-mailgate-web/public_html) directory onto your webserver. They can also be placed in a subdirectory on your webserver.
|
||||
|
||||
6. On your webserver move the `config.sample.php` file to `config.php` and edit the configuration file.
|
||||
|
||||
7. Create directories for storing email templates:
|
||||
|
||||
mkdir -p /var/gpgmailgate/cron_templates
|
||||
|
||||
8. Copy the templates found in the [cron_templates] (cron_templates/) directory into the newly created directory and transfer ownership:
|
||||
|
||||
```
|
||||
mkdir -p /var/gpgmailgate/cron_templates
|
||||
```
|
||||
chown -R nobody:nogroup /var/gpgmailgate/cron_templates
|
||||
|
||||
8. Copy the templates found in the [cron_templates](cron_templates/) directory into the newly created directory and transfer ownership:
|
||||
9. Copy `cron.py` to `/usr/local/bin/gpgmw-cron.py`. Make it executable and and transfer ownership to `nobody`:
|
||||
|
||||
```
|
||||
chown -R nobody:nogroup /var/gpgmailgate/cron_templates
|
||||
```
|
||||
chown nobody:nogroup /usr/local/bin/gpgmw-cron.py
|
||||
chmod u+x /usr/local/bin/gpgmw-cron.py
|
||||
|
||||
9. Copy `cron.py` to `/usr/local/bin/cron.py`. Make it executable and and transfer ownership to `nobody`:
|
||||
|
||||
```
|
||||
install -u nobody -g nobody -m u+x cron.py /usr/local/bin/lacre-cron.py
|
||||
```
|
||||
|
||||
10. Create `/etc/cron.d/lacre-cron` with contents:
|
||||
`*/3 * * * * nobody /usr/bin/python /usr/local/bin/lacre-cron.py > /dev/null`
|
||||
10. Create `/etc/cron.d/gpgmw` with contents:
|
||||
`*/3 * * * * nobody /usr/bin/python /usr/local/bin/gpgmw-cron.py > /dev/null`
|
||||
for executing the cron job automatically.
|
||||
|
||||
11. Test your installation.
|
||||
|
||||
### Lacre-Webgate as keyserver
|
||||
|
||||
Lacre-Webgate can also be used as a keyserver. For more information have a look at Lacre-Webgate's [README](https://git.disroot.org/Lacre/lacre-webgate/src/branch/main/README.md).
|
||||
### GPG-Mailgate-Web as keyserver
|
||||
GPG-Mailgate-Web can also be used as a keyserver. For more information have a look at GPG-Mailgate-Web's [readme] (gpg-mailgate-web/README).
|
||||
|
||||
## Install Register-handler
|
||||
|
||||
### Requirements
|
||||
|
||||
- Already set up and working Lacre-Webgate. It should be reachable from the machine that will run register-handler
|
||||
- Already set up and working GPG-Mailgate-Web. It should be reachable from the machine that will run register-handler
|
||||
- Postfix is already installed and configured. It is recommended that you have already tested your configuration so we can exclude this as a main cause of problems. Your Postfix configuration should also support aliases
|
||||
|
||||
### Installation
|
||||
|
||||
1. Install the Python-requests module:
|
||||
|
||||
```
|
||||
apt-get install python-requests
|
||||
```
|
||||
|
||||
apt-get install python-requests
|
||||
|
||||
2. Create directories for storing email templates:
|
||||
|
||||
mkdir -p /var/gpgmailgate/register_templates
|
||||
|
||||
3. Copy the templates found in the [register_templates] (register_templates/) directory into the newly created directory and transfer ownership:
|
||||
|
||||
```
|
||||
mkdir -p /var/gpgmailgate/register_templates
|
||||
```
|
||||
|
||||
3. Copy the templates found in the [register_templates](register_templates/) directory into the newly created directory and transfer ownership:
|
||||
|
||||
```
|
||||
chown -R nobody:nogroup /var/gpgmailgate/register_templates
|
||||
```
|
||||
|
||||
chown -R nobody:nogroup /var/gpgmailgate/register_templates
|
||||
|
||||
4. Copy `register-handler.py` to `/usr/local/bin/register-handler.py`. Make it executable and own it to `nobody`:
|
||||
|
||||
```
|
||||
install -u nobody -g nogroup -m a+x register-handler.py /usr/local/bin/
|
||||
```
|
||||
|
||||
5. Edit the config file located at `/etc/lacre.conf`. Set the parameter `webpanel_url` in `[mailregister]` to the url of your Lacre-Webgate panel (the URL should be the same as the one you use to access the panel with your web browser). Also set the parameter `register_email` to the email address you want the user to see when receiving mails from the register-handler (it does not have to be an existing address but it is recommended). Register-handler will send users mails when they are registering S/MIME certificates or when neither a S/MIME certificate nor a PGP key was found in a mail sent to the register-handler.
|
||||
chown nobody:nogroup /usr/local/bin/register-handler.py
|
||||
chmod a+x /usr/local/bin/register-handler.py
|
||||
|
||||
5. Edit the config file located at `/etc/gpg-mailgate.conf`. Set the parameter `webpanel_url` in `[mailregister]` to the url of your GPG-Mailgate-Web panel (the URL should be the same as the one you use to access the panel with your web browser). Also set the parameter `register_email` to the email address you want the user to see when receiving mails from the register-handler (it does not have to be an existing address but it is recommended). Register-handler will send users mails when they are registering S/MIME certificates or when neither a S/MIME certificate nor a PGP key was found in a mail sent to the register-handler.
|
||||
|
||||
6. Add `register: |/usr/local/bin/register-handler.py` to `/etc/aliases`
|
||||
|
||||
|
|
79
Makefile
79
Makefile
|
@ -1,6 +1,5 @@
|
|||
.POSIX:
|
||||
.PHONY: test e2etest unittest crontest daemontest pre-clean clean restore-keyhome
|
||||
.SUFFIXES: .gv .png
|
||||
.PHONY: test unittest pre-clean clean
|
||||
|
||||
#
|
||||
# On systems where Python 3.x binary has a different name, just
|
||||
|
@ -8,94 +7,36 @@
|
|||
#
|
||||
# make test PYTHON=/usr/local/bin/python3.8
|
||||
#
|
||||
# This macro is passed via environment to test/e2e_test.py, where it's
|
||||
# This marco is passed via environment to test/e2e_test.py, where it's
|
||||
# used to compute further commands.
|
||||
#
|
||||
PYTHON = python
|
||||
|
||||
GRAPHVIZ = dot
|
||||
|
||||
#
|
||||
# SQLite database used during tests
|
||||
#
|
||||
# This database stores key queue and identity repository for e2etest,
|
||||
# daemontest, and crontest.
|
||||
#
|
||||
TEST_DB = test/lacre.db
|
||||
|
||||
#
|
||||
# List of graph files
|
||||
#
|
||||
GRAPHS = doc/key-lifecycle.png
|
||||
|
||||
#
|
||||
# Main goal to run all tests.
|
||||
#
|
||||
test: e2etest daemontest unittest crontest
|
||||
|
||||
#
|
||||
# Build graphviz diagrams.
|
||||
#
|
||||
doc: ${GRAPHS}
|
||||
PYTHON = python3
|
||||
|
||||
#
|
||||
# Run a set of end-to-end tests.
|
||||
#
|
||||
# Test scenarios are described and configured by the test/e2e.ini file.
|
||||
# Basically this is just a script that feeds Lacre with known input and checks
|
||||
# whether output meets expectations.
|
||||
# Test scenarios are described and configured by the test/e2e.ini
|
||||
# file. Basically this is just a script that feeds GPG Mailgate with
|
||||
# known input and checks whether output meets expectations.
|
||||
#
|
||||
e2etest: test/tmp test/logs pre-clean restore-keyhome
|
||||
test: test/tmp test/logs pre-clean
|
||||
$(PYTHON) test/e2e_test.py
|
||||
|
||||
#
|
||||
# Run a basic cron-job test.
|
||||
#
|
||||
# We use PYTHONPATH to make sure that cron.py can import GnuPG
|
||||
# package. We also set LACRE_CONFIG env. variable to make sure
|
||||
# it slurps the right config.
|
||||
#
|
||||
crontest: clean-db $(TEST_DB)
|
||||
LACRE_CONFIG=test/lacre-daemon.conf PYTHONPATH=`pwd` \
|
||||
$(PYTHON) webgate-cron.py
|
||||
|
||||
$(TEST_DB):
|
||||
$(PYTHON) test/utils/schema.py $(TEST_DB)
|
||||
|
||||
#
|
||||
# Run an e2e test of Advanced Content Filter.
|
||||
#
|
||||
daemontest: restore-keyhome
|
||||
$(PYTHON) test/daemon_test.py
|
||||
|
||||
# Before running the crontest goal we need to make sure that the
|
||||
# database gets regenerated.
|
||||
clean-db:
|
||||
rm -f $(TEST_DB)
|
||||
|
||||
#
|
||||
# Run unit tests
|
||||
#
|
||||
unittest:
|
||||
LACRE_CONFIG=test/lacre.conf $(PYTHON) -m unittest discover -s test/modules
|
||||
$(PYTHON) -m unittest discover -s test
|
||||
|
||||
pre-clean:
|
||||
rm -fv test/lacre.conf
|
||||
rm -fv test/gpg-mailgate.conf
|
||||
rm -f test/logs/*.log
|
||||
|
||||
restore-keyhome:
|
||||
git restore test/keyhome
|
||||
git restore test/keyhome.other
|
||||
|
||||
test/tmp:
|
||||
mkdir test/tmp
|
||||
|
||||
test/logs:
|
||||
mkdir test/logs
|
||||
|
||||
clean: pre-clean clean-db
|
||||
clean: pre-clean
|
||||
rm -rfv test/tmp test/logs
|
||||
|
||||
# Convert dot source to PNG image.
|
||||
.gv.png:
|
||||
$(GRAPHVIZ) -Tpng $< > ${<:S/.gv/.png/}
|
||||
|
|
65
README.md
65
README.md
|
@ -1,68 +1,51 @@
|
|||
# Lacre Project
|
||||
# GPG Lacre Project
|
||||
|
||||
**Lacre** (wax seal in Portuguese) is an add-on for Postfix that automatically
|
||||
encrypts incoming email before delivering it to recipients' inbox for
|
||||
recipients that have provided their public keys.
|
||||
Fork and continuation of original work of gpg-mailgate project: https://github.com/fkrone/gpg-mailgate
|
||||
|
||||
Lacre is a fork and continuation of the original work on
|
||||
[gpg-mailgate](https://github.com/TheGreatGooo/gpg-mailgate) project. It is
|
||||
still actively developed and should be considered as beta -- with all APIs and
|
||||
internals being subject to change. Please only use this software if you know
|
||||
GnuPG well and accept occasional failures.
|
||||
|
||||
# How it works
|
||||
**GPG Lacre** (wax seal in Portuguese) is a content filter for Postfix that automatically encrypts unencrypted incoming email using PGP or S/MIME for select recipients.
|
||||
This project is the continuation of the work of "gpg-mailgate" on providing open source, GnuPG based email encryption for emails at rest. All incoming emails are automatically encrypted with user's public key before they are saved on the server. It is a server side encryption solution while the control of the encryption keys are fully at the hands of the end-user and private keys are never stored on the server.
|
||||
|
||||
Lacre is a [content filter](https://www.postfix.org/FILTER_README.html). This
|
||||
means, that when Postfix receives a message, it "forwards" that message to
|
||||
Lacre and if Lacre delivers it to a given destination, the message arrives to
|
||||
recipient's inbox.
|
||||
The scope of the project is to improve on the already existing code, provide easy to use key upload system (standalone as well as Roundcube plugin) and key discoverability. Beside providing a solution that is easy to use we will also provide easy to digest material about encryption, how it works and how to make use of it in situations other the just mailbox encryption. Understanding how encryption works is the key to self-determination and is therefore an important part of the project.
|
||||
|
||||
After receiving the message, Lacre does the following:
|
||||
|
||||
1. If message already is encrypted, it just delivers the message immediately.
|
||||
2. Checks the list of recipients, finds their public keys if any were
|
||||
provided.
|
||||
3. Encrypts message if possible.
|
||||
4. Delivers the message.
|
||||
GPG Lacre will be battle tested on the email infrastructure of https://disroot.org (an ethical non-profit service provider).
|
||||
|
||||
---
|
||||
|
||||
Work on this project in 2021 was funded by
|
||||
[NGI Zero PET](https://nlnet.nl/thema/NGIZeroPET.html)
|
||||
for which we are very thankful.
|
||||
The work on this project in 2021 is funded by https://nlnet.nl/thema/NGIZeroPET.html for which we are very thankful.
|
||||
|
||||
The scope of the work for 2021 is:
|
||||
- Rewrite code to python3
|
||||
- Improve standalone key upload website
|
||||
- Provide Roundcube plugin for key management
|
||||
- Improve key server features
|
||||
- Provide webiste with information and tutorials on how to use GPG in general and also **Lacre**
|
||||
- (Optional) provide Autocrypt support
|
||||
|
||||
Made possible thanks to:<br>
|
||||

|
||||
|
||||
---
|
||||
|
||||
# Installation
|
||||
|
||||
For installation instructions, please refer to the included [INSTALL](INSTALL.md) file.
|
||||
For installation instructions, please refer to the included **INSTALL** file.
|
||||
|
||||
---
|
||||
|
||||
# Planned features
|
||||
|
||||
# Features
|
||||
- Correctly displays attachments and general email content; currently will only display first part of multipart messages
|
||||
- Public keys are stored in a dedicated gpg-home-directory
|
||||
- Encrypts both matching incoming and outgoing mail (this means gpg-mailgate can be used to encrypt outgoing mail for software that doesn't support PGP or S/MIME)
|
||||
- Decrypts PGP encrypted mails for present private keys (but no signature check and it does not always work with PGP/INLINE encrypted mails)
|
||||
- Easy installation
|
||||
- People can submit their public key like to any keyserver to gpg-mailgate with the gpg-mailgate-web extension
|
||||
- People can send an S/MIME signed email to register@yourdomain.tld to register their public key
|
||||
- People can send their public OpenPGP key as attachment or inline to register@yourdomain.tld to register it
|
||||
|
||||
See also: [lacre-webgate](https://git.disroot.org/Lacre/lacre-webgate/) -- a
|
||||
web interface allowing any user to upload PGP keys so that emails sent to them
|
||||
from your mail server will be encrypted
|
||||
- gpg-mailgate-web extension is a web interface allowing any user to upload PGP keys so that emails sent to them from your mail server will be encrypted (see gpg-mailgate-web directory for details)
|
||||
- people can submit their public key like to any keyserver to gpg-mailgate with the gpg-mailgate-web extension
|
||||
- people can send an S/MIME signed email to register@yourdomain.tld to register their public key
|
||||
- people can send their public OpenPGP key as attachment or inline to register@yourdomain.tld to register it
|
||||
|
||||
This is forked from the original project at http://code.google.com/p/gpg-mailgate/
|
||||
|
||||
# Authors
|
||||
|
||||
This is a combined work of many developers and contributors. We would like to
|
||||
pay honours to original gpg mailbox developers for making this project happen,
|
||||
and providing solid solution for encryption emails at rest:
|
||||
This is a combined work of many developers and contributors. We would like to pay honours to original gpg mailbox developers for making this project happen, and providing solid solution for encryption emails at rest:
|
||||
|
||||
* mcmaster <mcmaster@aphrodite.hurricanelabs.rsoc>
|
||||
* Igor Rzegocki <ajgon@irgon.com> - [GitHub](https://github.com/ajgon/gpg-mailgate)
|
||||
|
@ -74,4 +57,4 @@ and providing solid solution for encryption emails at rest:
|
|||
* Bruce Markey - [GitHub](https://github.com/TheEd1tor)
|
||||
* Remko Tronçon - [GitHub](https://github.com/remko/phkp/)
|
||||
* Kiritan Flux [GitHub](https://github.com/kflux)
|
||||
* Fabian Krone [GitHub](https://github.com/fkrone/gpg-mailgate)
|
||||
* Fabian Krone [GitHub] (https://github.com/fkrone/gpg-mailgate)
|
||||
|
|
21
bin/lacreadm
21
bin/lacreadm
|
@ -1,21 +0,0 @@
|
|||
#!/bin/sh
|
||||
#
|
||||
# lacre
|
||||
#
|
||||
# This file is part of the lacre source code.
|
||||
#
|
||||
# lacre is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# lacre source code is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with lacre source code. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
python -m lacre.admin $*
|
77
doc/admin.md
77
doc/admin.md
|
@ -1,77 +0,0 @@
|
|||
# Lacre administration
|
||||
|
||||
## Command-line tool
|
||||
|
||||
There's a little tool for administrators. As long as Lacre Python packages
|
||||
are available via `PYTHONPATH`, you can use it like this:
|
||||
|
||||
```sh
|
||||
python -m lacre.admin -h
|
||||
```
|
||||
|
||||
Of course `-h` displays some help.
|
||||
|
||||
**Note:** Help output includes information about the configuration file being
|
||||
in use, which may be useful at times.
|
||||
|
||||
**Note:** You can also use a tiny shell wrapper around this tool, see
|
||||
`bin/lacreadm`.
|
||||
|
||||
|
||||
## Initialising database schema
|
||||
|
||||
If you want to initialise Lacre's database (which is also used by the
|
||||
frontend), run:
|
||||
|
||||
```sh
|
||||
python -m lacre.admin database -i
|
||||
```
|
||||
|
||||
|
||||
## Inspecting key confirmation queue
|
||||
|
||||
To find out how many keys are waiting to be confirmed, run:
|
||||
|
||||
```sh
|
||||
python -m lacre.admin queue
|
||||
```
|
||||
|
||||
To see identities (emails) waiting for confirmation, use `--list` (or `-l`)
|
||||
option:
|
||||
|
||||
```sh
|
||||
python -m lacre.admin queue -l
|
||||
```
|
||||
|
||||
To delete one of these emails, use `--delete` (or `-d`) option:
|
||||
|
||||
```sh
|
||||
python -m lacre.admin queue -d malory@example.org
|
||||
```
|
||||
|
||||
## Inspecting identities registered
|
||||
|
||||
To list all identities, run:
|
||||
|
||||
```sh
|
||||
python -m lacre.admin identities -a
|
||||
```
|
||||
|
||||
To preview a particular identity, run:
|
||||
|
||||
```sh
|
||||
python -m lacre.admin identities -e alice@example.com
|
||||
```
|
||||
|
||||
## Importing identities from existing GnuPG keyring
|
||||
|
||||
If you already have a GnuPG keyring with your users' public keys or for some
|
||||
reason Lacre's identity database needs to be re-populated with identities,
|
||||
there's a command to do that:
|
||||
|
||||
```sh
|
||||
python -m lacre.admin import -d /path/to/gnupg/directory
|
||||
```
|
||||
|
||||
If you want to just re-populate the database, Lacre can remove all identities
|
||||
prior to importing keys -- just add `-r` flag.
|
|
@ -1,45 +0,0 @@
|
|||
# Advanced Filter
|
||||
|
||||
## Postfix Filters
|
||||
|
||||
There are two types of Postfix mail filters: Simple Filters and Advanced
|
||||
Filters. Simple Filters are executed for each incoming email as a new
|
||||
process, which may turn out to be expensive in terms of resources. Advanced
|
||||
Filters work as a mail-processing proxies.
|
||||
|
||||
For detailed documentation, see [FILTER README](https://www.postfix.org/FILTER_README.html).
|
||||
|
||||
## Installation
|
||||
|
||||
Just use the following command to install dependencies:
|
||||
|
||||
```
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Lacre Advanced Filter, also known as daemon, is configured in the `[daemon]`
|
||||
section of configuration file. Two obligatory parameters to be defined there
|
||||
are:
|
||||
|
||||
* `host` -- IP address or a host name;
|
||||
* `port` -- TCP port Lacre should listen on.
|
||||
|
||||
The other very important section is `[relay]`, which by default uses Simple
|
||||
Filter destination. It has to be adjusted for Advanced Filter to work,
|
||||
setting port to `10026`.
|
||||
|
||||
Command to spawn a Lacre daemon process is:
|
||||
|
||||
```
|
||||
LACRE_CONFIG=/etc/lacre.conf PYTHONPATH=... python -m lacre.daemon
|
||||
```
|
||||
|
||||
Two environment variables used here are:
|
||||
|
||||
* `LACRE_CONFIG` (not mandatory) -- path to Lacre configuration,
|
||||
unless it's kept in default location (`/etc/lacre.conf`).
|
||||
* `PYTHONPATH` (not mandatory) -- location of Lacre modules. You can place
|
||||
them below your Python's `site-packages` to be reachable by any other
|
||||
Python software.
|
|
@ -1,76 +0,0 @@
|
|||
digraph key_lifecycle {
|
||||
node [fontname="Helvetica,Arial,sans-serif" fontsize=12 shape=Mrecord]
|
||||
edge [fontname="Helvetica,Arial,sans-serif" fontsize=10]
|
||||
|
||||
start [label="" shape=circle]
|
||||
end [label="" shape=circle]
|
||||
|
||||
// An ASCII-armoured key is stored in lacre_keys table with:
|
||||
//
|
||||
// lacre_keys.confirm = <random string>
|
||||
// lacre_keys.status = 0 (default value)
|
||||
submitted [label="Submitted"]
|
||||
|
||||
// User has confirmed their email.
|
||||
//
|
||||
// lacre_keys.confirm = ''
|
||||
confirmed [label="Email confirmed" color=green4]
|
||||
|
||||
// The key has been imported into GnuPG keyring and an identity has been
|
||||
// created in lacre_identities table.
|
||||
//
|
||||
// lacre_keys.status = 1
|
||||
imported [label="Imported" color=green4]
|
||||
|
||||
// Any old key for this email has been deleted.
|
||||
deleted [label="Previous key\ndeleted"]
|
||||
|
||||
// When a key expires, we only fail to encrypt at the moment.
|
||||
//
|
||||
// See https://git.disroot.org/Disroot/gpg-lacre/issues/148
|
||||
expired [label="Expired" color=red]
|
||||
|
||||
// A key may end up being non-usable in several different ways and this is
|
||||
// a catch-all node to represent them.
|
||||
//
|
||||
// - User hasn't confirmed their email.
|
||||
// - Provided key's email didn't match the one provided in submission form.
|
||||
rejected [label="Key not used,\nremoved from database" color=brown]
|
||||
|
||||
// User submits ASCII-armoured OpenPGP key.
|
||||
start -> submitted [label="user action:\nkey submission" color=green4]
|
||||
|
||||
// The user has clicked the confirmation link.
|
||||
//
|
||||
// - lacre_keys.confirm = ''
|
||||
submitted -> confirmed [label="user action:\nemail confirmation" color=green4]
|
||||
|
||||
// Enough time has passed since submission that we decide to drop the key
|
||||
// from the queue.
|
||||
submitted -> rejected [label="confirmation timed out\nno user action" color=brown]
|
||||
|
||||
// A confirmed key is imported:
|
||||
// - import into GnuPG keyring;
|
||||
// - mark key as accepted (lacre_keys.status = 1);
|
||||
// - update identity database;
|
||||
// - send notification.
|
||||
confirmed -> imported [label="import\n[non-empty key]" color=green4]
|
||||
|
||||
// Empty key is imported.
|
||||
//
|
||||
// Effectively this means key removal and disabling encryption.
|
||||
confirmed -> deleted [label="import\n[empty key]" color=green4]
|
||||
deleted -> end
|
||||
|
||||
// XXX: Import of revokation keys isn't implemented yet.
|
||||
confirmed -> deleted [label="import\n[revokation key]\n(not implemented)" color=gray fontcolor=gray]
|
||||
|
||||
// Key validation fails, the key is not imported.
|
||||
confirmed -> rejected [label="invalid key" color=brown]
|
||||
|
||||
// We don't explicitly make keys expired, but when they expire GnuPG
|
||||
// refuses to encrypt payloads.
|
||||
imported -> expired [label="expiry" color=red fontcolor=red]
|
||||
|
||||
rejected -> end
|
||||
}
|
|
@ -1,25 +1,15 @@
|
|||
# Testing
|
||||
|
||||
First tests have been set up to cover GPG Mailgate with at least basic test
|
||||
that would be easy to run. The tests are called "end-to-end", meaning that we
|
||||
that would be easy to run. The tests are called "end-to-end", meaning that we
|
||||
feed some input to GPG Mailgate and inspect the output.
|
||||
|
||||
## Running tests
|
||||
|
||||
To run tests, use command `make test`.
|
||||
To run tests, use command `make test` or `make unittest`.
|
||||
|
||||
There are 4 types of tests:
|
||||
|
||||
* `make e2etest` -- they cover a complete Lacre flow, from feeding it with
|
||||
an email to accepting its encrypted form;
|
||||
* `make daemontest` -- similar to the original `e2etest` goal, but tests the
|
||||
behaviour of the Lacre Daemon, i.e. key part of the Advanced Content
|
||||
Filter.
|
||||
* `make unittest` -- just small tests of small units of code;
|
||||
* `make crontest` -- execute cron job with a SQLite database.
|
||||
|
||||
E2E tests (`make e2etest`) should produce some helpful logs, so inspect
|
||||
contents of `test/logs` directory if something goes wrong.
|
||||
Tests produce some helpful logs, so inspect contents of `test/logs` directory
|
||||
if something goes wrong.
|
||||
|
||||
If your system's Python binary isn't found in your `$PATH` or you want to use
|
||||
a specific binary, use make's macro overriding: `make test
|
||||
|
@ -27,33 +17,24 @@ PYTHON=/path/to/python`.
|
|||
|
||||
## Key building blocks
|
||||
|
||||
- *Test Script* (`test/e2e_test.py`) and *Daemon Test Script*
|
||||
(`test/daemon_test.py`) that orchestrate the other components. They perform
|
||||
test cases described in the *Test Configuration*. They spawn *Test Mail
|
||||
Relay* and *GPG Mailgate* in appropriate order.
|
||||
- *Test Script* (`test/e2e_test.py`) that orchestrates the other components.
|
||||
It performs test cases described in the *Test Configuration*. It spawns
|
||||
*Test Mail Relay* and *GPG Mailgate* in appropriate order.
|
||||
- *Test Mail Relay* (`test/relay.py`), a simplistic mail daemon that only
|
||||
supports the happy path. It accepts a mail message and prints it to
|
||||
supports the happy path. It accepts a mail message and prints it to
|
||||
stdandard output.
|
||||
- *Test Configuration* (`test/e2e.ini`) specifies test cases: their input,
|
||||
expected results and helpful documentation. It also specifies the port that
|
||||
expected results and helpful documentation. It also specifies the port that
|
||||
the *Test Mail Relay* should listen on.
|
||||
|
||||
## Limitations
|
||||
|
||||
Currently tests only check if the message has been encrypted, without
|
||||
verifying that the correct key has been used. That's because we don't know
|
||||
(yet) how to have a reproducible encrypted message. Option
|
||||
verifying that the correct key has been used. That's because we don't know
|
||||
(yet) how to have a reproducible encrypted message. Option
|
||||
`--faked-system-time` wasn't enough to produce identical output.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
When things go wrong, be sure to study `test/logs/e2e.log` and
|
||||
`test/logs/gpg-mailgate.log` files -- they contain some useful information.
|
||||
|
||||
## Test identities
|
||||
|
||||
There are several identities in test/keyhome and in the test database:
|
||||
|
||||
* alice@disposlab: 1CD245308F0963D038E88357973CF4D9387C44D7
|
||||
* bob@disposlab: 19CF4B47ECC9C47AFA84D4BD96F39FDA0E31BB67
|
||||
* evan@disposlab: 530B1BB2D0CC7971648198BBA4774E507D3AF5BC
|
||||
|
|
70
gpg-mailgate-web/README
Normal file
70
gpg-mailgate-web/README
Normal file
|
@ -0,0 +1,70 @@
|
|||
gpg-mailgate-web
|
||||
----------------
|
||||
|
||||
gpg-mailgate-web is a web interface designed to allow any web user
|
||||
to upload their PGP public key and then have all mail sent via
|
||||
your mail server be encrypted. (Note: this is not meant for email
|
||||
authentication, only encryption.)
|
||||
|
||||
After submitting their key to a web form, the user will be required
|
||||
to confirm their email address. A cron script will register the
|
||||
public key with gpg-mailgate (keyhome_only must be set to no
|
||||
currently, which is the default) after email confirmation. From
|
||||
then on, email to the specified address will be encrypted with
|
||||
the public key.
|
||||
|
||||
gpg-mailgate-web is useful for two purposes: for a transparent
|
||||
PGP encryption layer in front of any web application, or simple as
|
||||
a web interface for gpg-mailgate so that users on your mail server
|
||||
can easily upload and change their PGP keys.
|
||||
|
||||
Note that all processing relating to the mail server is done via the
|
||||
cron script. This means that gpg-mailgate and the gpgmw cron can
|
||||
be installed on a different server from the web server. The MySQL
|
||||
database must be shared between the two applications though.
|
||||
|
||||
1. Installation instructions:
|
||||
|
||||
1) Install gpg-mailgate.
|
||||
2) Create a MySQL database for gpg-mailgate.
|
||||
a) Schema file is located in schema.sql
|
||||
b) Database name and account goes in /etc/gpg-mailgate.conf (and set enabled = yes)
|
||||
3) Copy the contents of public_html to your web directory.
|
||||
4) Move config.sample.php to config.php and edit the configuration file.
|
||||
5) Copy cron.py to /usr/local/bin/gpgmw-cron.py and set up a cron job
|
||||
a) Create /etc/cron.d/gpgmw with the contents:
|
||||
|
||||
*/3 * * * * nobody /usr/bin/python /usr/local/bin/gpgmw-cron.py > /dev/null
|
||||
|
||||
6) Ensure that cron is working and test your new gpg-mailgate-web installation!
|
||||
|
||||
----------------------------------------
|
||||
|
||||
2. Adding rudimentary HKP Keyserver functionality for submitting public keys from the GPG client
|
||||
|
||||
(so far only implemented and tested with lighttpd - basically you just need to make your http server
|
||||
listen on port 11371, redirect it to your gpg-mailgate-web directory and add a rewrite rule to catch
|
||||
'pks/add' in the URI)
|
||||
|
||||
1) add the following lines to your lighttp.conf file and change the path to your gpg-mailgate-web directory
|
||||
|
||||
server.reject-expect-100-with-417 = "disable"
|
||||
|
||||
$SERVER["socket"] == ":11371" {
|
||||
server.document-root = "/var/www/gpgmw"
|
||||
setenv.add-response-header = ( "Via" => "1.1 yourserver.tld:11371 (lighttpd)" )
|
||||
accesslog.filename = "/var/log/lighttpd/hkp-access.log"
|
||||
url.rewrite-once = ( "^/pks/(.*)" => "/index.php?/pks/$1" )
|
||||
}
|
||||
|
||||
2) reload lighttpd: /etc/init.d/lighttpd restart
|
||||
3) in the index.php add the following line after the other required_once(...) lines:
|
||||
|
||||
require_once("include/phphkp.php");
|
||||
|
||||
4) change the constants in the include/phphkp.php file!
|
||||
|
||||
5) check if it works with a GPG client of your choice pushing a public key to your server's
|
||||
domain or IP
|
||||
|
||||
(HTTP request to http://yourserver.tld:11371/pks/add with the public key in a POST variable 'keytext')
|
111
gpg-mailgate-web/cron.py
Normal file
111
gpg-mailgate-web/cron.py
Normal file
|
@ -0,0 +1,111 @@
|
|||
#!/usr/bin/python
|
||||
|
||||
#
|
||||
# gpg-mailgate
|
||||
#
|
||||
# This file is part of the gpg-mailgate source code.
|
||||
#
|
||||
# gpg-mailgate is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# gpg-mailgate source code is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with gpg-mailgate source code. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
from configparser import RawConfigParser
|
||||
import GnuPG
|
||||
import MySQLdb
|
||||
import smtplib
|
||||
import markdown
|
||||
import syslog
|
||||
from email.MIMEText import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
def appendLog(msg):
|
||||
if 'logging' in cfg and 'file' in cfg['logging']:
|
||||
if cfg['logging'].get('file') == "syslog":
|
||||
syslog.syslog(syslog.LOG_INFO | syslog.LOG_MAIL, msg)
|
||||
else:
|
||||
logfile = open(cfg['logging']['file'], 'a')
|
||||
logfile.write(msg + "\n")
|
||||
logfile.close()
|
||||
|
||||
def send_msg( mailsubject, messagefile, recipients = None ):
|
||||
mailbody = file( cfg['cron']['mail_templates'] + "/" + messagefile).read()
|
||||
msg = MIMEMultipart("alternative")
|
||||
|
||||
msg["From"] = cfg['cron']['notification_email']
|
||||
msg["To"] = recipients
|
||||
msg["Subject"] = mailsubject
|
||||
|
||||
msg.attach(MIMEText(mailbody, 'plain'))
|
||||
msg.attach(MIMEText(markdown.markdown(mailbody), 'html'))
|
||||
|
||||
if 'relay' in cfg and 'host' in cfg['relay'] and 'enc_port' in cfg['relay']:
|
||||
relay = (cfg['relay']['host'], int(cfg['relay']['enc_port']))
|
||||
smtp = smtplib.SMTP(relay[0], relay[1])
|
||||
smtp.sendmail( cfg['cron']['notification_email'], recipients, msg.as_string() )
|
||||
else:
|
||||
appendLog("Could not send mail due to wrong configuration")
|
||||
|
||||
# Read configuration from /etc/gpg-mailgate.conf
|
||||
_cfg = RawConfigParser()
|
||||
_cfg.read('/etc/gpg-mailgate.conf')
|
||||
cfg = dict()
|
||||
for sect in _cfg.sections():
|
||||
cfg[sect] = dict()
|
||||
for (name, value) in _cfg.items(sect):
|
||||
cfg[sect][name] = value
|
||||
|
||||
if 'database' in cfg and 'enabled' in cfg['database'] and cfg['database']['enabled'] == 'yes' and 'name' in cfg['database'] and 'host' in cfg['database'] and 'username' in cfg['database'] and 'password' in cfg['database']:
|
||||
connection = MySQLdb.connect(host = cfg['database']['host'], user = cfg['database']['username'], passwd = cfg['database']['password'], db = cfg['database']['name'], port = 3306)
|
||||
cursor = connection.cursor()
|
||||
|
||||
# import keys
|
||||
cursor.execute("SELECT publickey, id, email FROM gpgmw_keys WHERE status = 0 AND confirm = '' LIMIT 100")
|
||||
result_set = cursor.fetchall()
|
||||
|
||||
for row in result_set:
|
||||
# delete any other public keys associated with this confirmed email address
|
||||
cursor.execute("DELETE FROM gpgmw_keys WHERE email = %s AND id != %s", (row[2], row[1],))
|
||||
GnuPG.delete_key(cfg['gpg']['keyhome'], row[2])
|
||||
appendLog('Deleted key for <' + row[2] + '> via import request')
|
||||
|
||||
if row[0].strip(): # we have this so that user can submit blank key to remove any encryption
|
||||
if GnuPG.confirm_key(row[0], row[2]):
|
||||
GnuPG.add_key(cfg['gpg']['keyhome'], row[0]) # import the key to gpg
|
||||
cursor.execute("UPDATE gpgmw_keys SET status = 1 WHERE id = %s", (row[1],)) # mark key as accepted
|
||||
appendLog('Imported key from <' + row[2] + '>')
|
||||
if 'send_email' in cfg['cron'] and cfg['cron']['send_email'] == 'yes':
|
||||
send_msg( "PGP key registration successful", "registrationSuccess.md", row[2] )
|
||||
else:
|
||||
cursor.execute("DELETE FROM gpgmw_keys WHERE id = %s", (row[1],)) # delete key
|
||||
appendLog('Import confirmation failed for <' + row[2] + '>')
|
||||
if 'send_email' in cfg['cron'] and cfg['cron']['send_email'] == 'yes':
|
||||
send_msg( "PGP key registration failed", "registrationError.md", row[2] )
|
||||
else:
|
||||
# delete key so we don't continue processing it
|
||||
cursor.execute("DELETE FROM gpgmw_keys WHERE id = %s", (row[1],))
|
||||
if 'send_email' in cfg['cron'] and cfg['cron']['send_email'] == 'yes':
|
||||
send_msg( "PGP key deleted", "keyDeleted.md", row[2])
|
||||
|
||||
connection.commit()
|
||||
|
||||
# delete keys
|
||||
cursor.execute("SELECT email, id FROM gpgmw_keys WHERE status = 2 LIMIT 100")
|
||||
result_set = cursor.fetchall()
|
||||
|
||||
for row in result_set:
|
||||
GnuPG.delete_key(cfg['gpg']['keyhome'], row[0])
|
||||
cursor.execute("DELETE FROM gpgmw_keys WHERE id = %s", (row[1],))
|
||||
appendLog('Deleted key for <' + row[0] + '>')
|
||||
connection.commit()
|
||||
else:
|
||||
print("Warning: doing nothing since database settings are not configured!")
|
54
gpg-mailgate-web/public_html/config.default.php
Normal file
54
gpg-mailgate-web/public_html/config.default.php
Normal file
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
/*
|
||||
|
||||
gpg-mailgate
|
||||
|
||||
This file is part of the gpg-mailgate source code.
|
||||
|
||||
gpg-mailgate is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
gpg-mailgate source code is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with gpg-mailgate source code. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
*/
|
||||
|
||||
/*
|
||||
DO NOT EDIT THIS FILE!
|
||||
Instead, copy over "config.sample.php" to "config.php" and edit settings there.
|
||||
*/
|
||||
|
||||
$config = array();
|
||||
$config['email_web'] = 'admin@example.com';
|
||||
$config['email_from'] = 'gpg-mailgate-web@example.com';
|
||||
$config['email_subject_requestpgp'] = 'Confirm your email address';
|
||||
$config['site_url'] = 'http://example.com/gpgmw';
|
||||
$config['site_title'] = 'PGP key management';
|
||||
$config['language'] = 'english';
|
||||
$config['debug'] = false;
|
||||
$config['mail_smtp'] = false;
|
||||
$config['mail_smtp_host'] = 'localhost';
|
||||
$config['mail_smtp_port'] = 25;
|
||||
$config['mail_smtp_username'] = 'gpgmw';
|
||||
$config['mail_smtp_password'] = '';
|
||||
$config['db_name'] = 'gpgmw';
|
||||
$config['db_host'] = 'localhost';
|
||||
$config['db_username'] = 'gpgmw';
|
||||
$config['db_password'] = '';
|
||||
$config['pgpverify_enable'] = false;
|
||||
$config['pgpverify_tmpdir'] = '/tmp';
|
||||
$config['pgpverify_allowblank'] = true;
|
||||
$config['lock_time_initial'] = array('requestpgp' => 10);
|
||||
$config['lock_count_overload'] = array('requestpgp' => 3);
|
||||
$config['lock_time_overload'] = array('requestpgp' => 900);
|
||||
$config['lock_time_reset'] = 300;
|
||||
$config['lock_time_max'] = 3600;
|
||||
|
||||
?>
|
119
gpg-mailgate-web/public_html/config.sample.php
Normal file
119
gpg-mailgate-web/public_html/config.sample.php
Normal file
|
@ -0,0 +1,119 @@
|
|||
<?php
|
||||
/*
|
||||
|
||||
gpg-mailgate
|
||||
|
||||
This file is part of the gpg-mailgate source code.
|
||||
|
||||
gpg-mailgate is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
gpg-mailgate source code is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with gpg-mailgate source code. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
*/
|
||||
|
||||
//
|
||||
// GENERAL SITE SETTINGS
|
||||
//
|
||||
|
||||
//web team contact
|
||||
// this email address will be displayed if there is a database error
|
||||
$config['email_web'] = 'admin@example.com';
|
||||
|
||||
//address to send emails from
|
||||
$config['email_from'] = 'gpg-mailgate-web@example.com';
|
||||
|
||||
//this will be used as the subject when a user requests to add a PGP key
|
||||
$config['email_subject_requestpgp'] = 'Confirm your email address';
|
||||
|
||||
//site URL, without trailing slash
|
||||
$config['site_url'] = 'http://example.com/gpgmw';
|
||||
|
||||
//title of the website (displayed on home page)
|
||||
$config['site_title'] = 'PGP key management';
|
||||
|
||||
//language file to use (see language subdirectory)
|
||||
$config['language'] = 'english';
|
||||
|
||||
//whether debug mode should be enabled
|
||||
$config['debug'] = false;
|
||||
|
||||
//
|
||||
// MAIL SETTINGS
|
||||
//
|
||||
|
||||
//whether to send mail through SMTP (instead of PHP mail function)
|
||||
$config['mail_smtp'] = false;
|
||||
|
||||
//SMTP settings, if mail_smtp is enabled
|
||||
//this requires Net_SMTP from http://pear.php.net/package/Net_SMTP/ to be installed
|
||||
$config['mail_smtp_host'] = 'localhost';
|
||||
$config['mail_smtp_port'] = 25;
|
||||
$config['mail_smtp_username'] = 'gpgmw';
|
||||
$config['mail_smtp_password'] = '';
|
||||
|
||||
//
|
||||
// DATABASE SETTINGS
|
||||
//
|
||||
|
||||
//database name (MySQL only); or see include/dbconnect.php
|
||||
$config['db_name'] = 'gpgmw';
|
||||
|
||||
//database host
|
||||
$config['db_host'] = 'localhost';
|
||||
|
||||
//database username
|
||||
$config['db_username'] = 'gpgmw';
|
||||
|
||||
//database password
|
||||
$config['db_password'] = '';
|
||||
|
||||
//
|
||||
// PGP VERIFICATION SETTINGS
|
||||
//
|
||||
|
||||
//whether to enable immediate verification of PGP keys
|
||||
// keys will always be verified with the email address in our cron job
|
||||
// but this will enable verification from the web interface before email confirmation
|
||||
//for this to work, Crypt_GPG from http://pear.php.net/Crypt_GPG must be installed
|
||||
// (as well as any of its dependencies), and pgpverify_tmpdir must be set
|
||||
$config['pgpverify_enable'] = false;
|
||||
|
||||
//a temporary directory to use for PGP verification, without trailing slash
|
||||
// gpgmw will create subdirectories from here to use as temporary gpg home directories
|
||||
// these directories will (should) be deleted immediately after use
|
||||
$config['pgpverify_tmpdir'] = '/tmp';
|
||||
|
||||
//whether to allow blank "keys"
|
||||
// this is useful to allow users to delete their key from the keystore
|
||||
// if they no longer want encryption
|
||||
$config['pgpverify_allowblank'] = true;
|
||||
|
||||
//
|
||||
// LOCK SETTINGS
|
||||
//
|
||||
|
||||
//the time in seconds a user must wait before trying again; otherwise they get locked out (count not increased)
|
||||
$config['lock_time_initial'] = array('requestpgp' => 10);
|
||||
|
||||
//the number of tries a user has (that passes the lock_time_initial test) before being locked by overload (extended duration)
|
||||
$config['lock_count_overload'] = array('requestpgp' => 3);
|
||||
|
||||
//the time that overloads last
|
||||
$config['lock_time_overload'] = array('requestpgp' => 900);
|
||||
|
||||
//time after which locks no longer apply, assuming the lock isn't active
|
||||
$config['lock_time_reset'] = 300;
|
||||
|
||||
//max time to store locks in the database; this way we can clear old locks with one function
|
||||
$config['lock_time_max'] = 3600;
|
||||
|
||||
?>
|
41
gpg-mailgate-web/public_html/confirm.php
Normal file
41
gpg-mailgate-web/public_html/confirm.php
Normal file
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
/*
|
||||
|
||||
gpg-mailgate
|
||||
|
||||
This file is part of the gpg-mailgate source code.
|
||||
|
||||
gpg-mailgate is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
gpg-mailgate source code is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with gpg-mailgate source code. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
*/
|
||||
|
||||
require_once("include/config.php");
|
||||
require_once("include/language.php");
|
||||
require_once("include/common.php");
|
||||
require_once("include/dbconnect.php");
|
||||
require_once("include/pgp.php");
|
||||
|
||||
if(isset($_REQUEST['email']) && isset($_REQUEST['confirm'])) {
|
||||
$result = confirmPGP($_REQUEST['email'], $_REQUEST['confirm']);
|
||||
|
||||
if($result === true) {
|
||||
get_page("home", array('message' => $lang['confirm_success']));
|
||||
} else {
|
||||
get_page("home", array('message' => $lang['confirm_fail_general']));
|
||||
}
|
||||
} else {
|
||||
get_page("home");
|
||||
}
|
||||
|
||||
?>
|
273
gpg-mailgate-web/public_html/include/common.php
Normal file
273
gpg-mailgate-web/public_html/include/common.php
Normal file
|
@ -0,0 +1,273 @@
|
|||
<?php
|
||||
/*
|
||||
|
||||
gpg-mailgate
|
||||
|
||||
This file is part of the gpg-mailgate source code.
|
||||
|
||||
gpg-mailgate is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
gpg-mailgate source code is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with gpg-mailgate source code. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
*/
|
||||
|
||||
function string_begins_with($string, $search)
|
||||
{
|
||||
return (strncmp($string, $search, strlen($search)) == 0);
|
||||
}
|
||||
|
||||
function boolToString($bool) {
|
||||
return $bool ? 'true' : 'false';
|
||||
}
|
||||
|
||||
//returns an absolute path to the include directory
|
||||
function includePath() {
|
||||
$self = __FILE__;
|
||||
$lastSlash = strrpos($self, "/");
|
||||
return substr($self, 0, $lastSlash + 1);
|
||||
}
|
||||
|
||||
//returns a relative path to the gpg-mailgate-web web root directory, without trailing slash
|
||||
function basePath() {
|
||||
$commonPath = __FILE__;
|
||||
$requestPath = $_SERVER['SCRIPT_FILENAME'];
|
||||
|
||||
//count the number of slashes
|
||||
// number of .. needed for include level is numslashes(request) - numslashes(common)
|
||||
// then add one more to get to base
|
||||
$commonSlashes = substr_count($commonPath, '/');
|
||||
$requestSlashes = substr_count($requestPath, '/');
|
||||
$numParent = $requestSlashes - $commonSlashes + 1;
|
||||
|
||||
$basePath = ".";
|
||||
for($i = 0; $i < $numParent; $i++) {
|
||||
$basePath .= "/..";
|
||||
}
|
||||
|
||||
return $basePath;
|
||||
}
|
||||
|
||||
function uid($length) {
|
||||
$characters = "0123456789abcdefghijklmnopqrstuvwxyz";
|
||||
$string = "";
|
||||
|
||||
for ($p = 0; $p < $length; $p++) {
|
||||
$string .= $characters[secure_random() % strlen($characters)];
|
||||
}
|
||||
|
||||
return $string;
|
||||
}
|
||||
|
||||
function get_page($page, $args = array()) {
|
||||
//let pages use some variables
|
||||
extract($args);
|
||||
$config = $GLOBALS['config'];
|
||||
$lang = $GLOBALS['lang'];
|
||||
|
||||
$basePath = basePath();
|
||||
|
||||
$themePath = $basePath . "/theme";
|
||||
$themePageInclude = "$themePath/$page.php";
|
||||
|
||||
if(file_exists("$themePath/header.php")) {
|
||||
include("$themePath/header.php");
|
||||
}
|
||||
|
||||
if(file_exists($themePageInclude)) {
|
||||
include($themePageInclude);
|
||||
}
|
||||
|
||||
if(file_exists("$themePath/footer.php")) {
|
||||
include("$themePath/footer.php");
|
||||
}
|
||||
}
|
||||
|
||||
function isAscii($str) {
|
||||
return 0 == preg_match('/[^\x00-\x7F]/', $str);
|
||||
}
|
||||
|
||||
//returns random number from 0 to 2^24
|
||||
function secure_random() {
|
||||
return hexdec(bin2hex(secure_random_bytes(3)));
|
||||
}
|
||||
|
||||
function recursiveDelete($dirPath) {
|
||||
foreach(
|
||||
new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator(
|
||||
$dirPath, FilesystemIterator::SKIP_DOTS
|
||||
),
|
||||
RecursiveIteratorIterator::CHILD_FIRST
|
||||
)
|
||||
as $path) {
|
||||
$path->isFile() ? unlink($path->getPathname()) : rmdir($path->getPathname());
|
||||
}
|
||||
|
||||
rmdir($dirPath);
|
||||
}
|
||||
|
||||
function gpgmw_mail($subject, $body, $to) { //returns true=ok, false=notok
|
||||
$config = $GLOBALS['config'];
|
||||
$from = filter_var($config['email_from'], FILTER_SANITIZE_EMAIL);
|
||||
$to = filter_var($to, FILTER_SANITIZE_EMAIL);
|
||||
|
||||
if($to === false || $from === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if(isset($config['mail_smtp']) && $config['mail_smtp']) {
|
||||
require_once "Mail.php";
|
||||
|
||||
$host = $config['mail_smtp_host'];
|
||||
$port = $config['mail_smtp_port'];
|
||||
$username = $config['mail_smtp_username'];
|
||||
$password = $config['mail_smtp_password'];
|
||||
$headers = array ('From' => $from,
|
||||
'To' => $to,
|
||||
'Subject' => $subject,
|
||||
'Content-Type' => 'text/plain');
|
||||
$smtp = Mail::factory('smtp',
|
||||
array ('host' => $host,
|
||||
'port' => $port,
|
||||
'auth' => true,
|
||||
'username' => $username,
|
||||
'password' => $password));
|
||||
|
||||
$mail = $smtp->send($to, $headers, $body);
|
||||
|
||||
if (PEAR::isError($mail)) {
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
$headers = "From: $from\r\n";
|
||||
$headers .= "Content-type: text/plain\r\n";
|
||||
return mail($to, $subject, $body, $headers);
|
||||
}
|
||||
}
|
||||
|
||||
//secure_random_bytes from https://github.com/GeorgeArgyros/Secure-random-bytes-in-PHP
|
||||
/*
|
||||
* The function is providing, at least at the systems tested :),
|
||||
* $len bytes of entropy under any PHP installation or operating system.
|
||||
* The execution time should be at most 10-20 ms in any system.
|
||||
*/
|
||||
function secure_random_bytes($len = 10) {
|
||||
|
||||
/*
|
||||
* Our primary choice for a cryptographic strong randomness function is
|
||||
* openssl_random_pseudo_bytes.
|
||||
*/
|
||||
$SSLstr = '4'; // http://xkcd.com/221/
|
||||
if (function_exists('openssl_random_pseudo_bytes') &&
|
||||
(version_compare(PHP_VERSION, '5.3.4') >= 0 ||
|
||||
substr(PHP_OS, 0, 3) !== 'WIN'))
|
||||
{
|
||||
$SSLstr = openssl_random_pseudo_bytes($len, $strong);
|
||||
if ($strong)
|
||||
return $SSLstr;
|
||||
}
|
||||
|
||||
/*
|
||||
* If mcrypt extension is available then we use it to gather entropy from
|
||||
* the operating system's PRNG. This is better than reading /dev/urandom
|
||||
* directly since it avoids reading larger blocks of data than needed.
|
||||
* Older versions of mcrypt_create_iv may be broken or take too much time
|
||||
* to finish so we only use this function with PHP 5.3 and above.
|
||||
*/
|
||||
if (function_exists('mcrypt_create_iv') &&
|
||||
(version_compare(PHP_VERSION, '5.3.0') >= 0 ||
|
||||
substr(PHP_OS, 0, 3) !== 'WIN'))
|
||||
{
|
||||
$str = mcrypt_create_iv($len, MCRYPT_DEV_URANDOM);
|
||||
if ($str !== false)
|
||||
return $str;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* No build-in crypto randomness function found. We collect any entropy
|
||||
* available in the PHP core PRNGs along with some filesystem info and memory
|
||||
* stats. To make this data cryptographically strong we add data either from
|
||||
* /dev/urandom or if its unavailable, we gather entropy by measuring the
|
||||
* time needed to compute a number of SHA-1 hashes.
|
||||
*/
|
||||
$str = '';
|
||||
$bits_per_round = 2; // bits of entropy collected in each clock drift round
|
||||
$msec_per_round = 400; // expected running time of each round in microseconds
|
||||
$hash_len = 20; // SHA-1 Hash length
|
||||
$total = $len; // total bytes of entropy to collect
|
||||
|
||||
$handle = @fopen('/dev/urandom', 'rb');
|
||||
if ($handle && function_exists('stream_set_read_buffer'))
|
||||
@stream_set_read_buffer($handle, 0);
|
||||
|
||||
do
|
||||
{
|
||||
$bytes = ($total > $hash_len)? $hash_len : $total;
|
||||
$total -= $bytes;
|
||||
|
||||
//collect any entropy available from the PHP system and filesystem
|
||||
$entropy = rand() . uniqid(mt_rand(), true) . $SSLstr;
|
||||
$entropy .= implode('', @fstat(@fopen( __FILE__, 'r')));
|
||||
$entropy .= memory_get_usage();
|
||||
if ($handle)
|
||||
{
|
||||
$entropy .= @fread($handle, $bytes);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Measure the time that the operations will take on average
|
||||
for ($i = 0; $i < 3; $i ++)
|
||||
{
|
||||
$c1 = microtime(true);
|
||||
$var = sha1(mt_rand());
|
||||
for ($j = 0; $j < 50; $j++)
|
||||
{
|
||||
$var = sha1($var);
|
||||
}
|
||||
$c2 = microtime(true);
|
||||
$entropy .= $c1 . $c2;
|
||||
}
|
||||
|
||||
// Based on the above measurement determine the total rounds
|
||||
// in order to bound the total running time.
|
||||
$rounds = (int)($msec_per_round*50 / (int)(($c2-$c1)*1000000));
|
||||
|
||||
// Take the additional measurements. On average we can expect
|
||||
// at least $bits_per_round bits of entropy from each measurement.
|
||||
$iter = $bytes*(int)(ceil(8 / $bits_per_round));
|
||||
for ($i = 0; $i < $iter; $i ++)
|
||||
{
|
||||
$c1 = microtime();
|
||||
$var = sha1(mt_rand());
|
||||
for ($j = 0; $j < $rounds; $j++)
|
||||
{
|
||||
$var = sha1($var);
|
||||
}
|
||||
$c2 = microtime();
|
||||
$entropy .= $c1 . $c2;
|
||||
}
|
||||
|
||||
}
|
||||
// We assume sha1 is a deterministic extractor for the $entropy variable.
|
||||
$str .= sha1($entropy, true);
|
||||
} while ($len > strlen($str));
|
||||
|
||||
if ($handle)
|
||||
@fclose($handle);
|
||||
|
||||
return substr($str, 0, $len);
|
||||
}
|
||||
|
||||
?>
|
31
gpg-mailgate-web/public_html/include/config.php
Normal file
31
gpg-mailgate-web/public_html/include/config.php
Normal file
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
/*
|
||||
|
||||
gpg-mailgate
|
||||
|
||||
This file is part of the gpg-mailgate source code.
|
||||
|
||||
gpg-mailgate is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
gpg-mailgate source code is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with gpg-mailgate source code. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
*/
|
||||
|
||||
require_once(dirname(__FILE__) . '/../config.default.php');
|
||||
|
||||
if(file_exists(dirname(__FILE__) . '/../config.php')) {
|
||||
require_once(dirname(__FILE__) . '/../config.php');
|
||||
} else {
|
||||
die("Server configuration error: config.php does not exist.");
|
||||
}
|
||||
|
||||
?>
|
76
gpg-mailgate-web/public_html/include/dbconnect.php
Normal file
76
gpg-mailgate-web/public_html/include/dbconnect.php
Normal file
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
/*
|
||||
|
||||
gpg-mailgate
|
||||
|
||||
This file is part of the gpg-mailgate source code.
|
||||
|
||||
gpg-mailgate is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
gpg-mailgate source code is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with gpg-mailgate source code. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
*/
|
||||
|
||||
function dieDatabaseError($ex = NULL) {
|
||||
global $config;
|
||||
|
||||
if($ex == NULL) {
|
||||
$pre = "Encountered database error.";
|
||||
} else {
|
||||
$pre = "Encountered database error: " . $ex->getMessage() . ".";
|
||||
}
|
||||
|
||||
die($pre . " If this is unexpected, consider <a href=\"mailto:{$config['email_web']}\">reporting it to our web team</a>. Otherwise, <a href=\"/\">click here to return to the home page.</a>");
|
||||
}
|
||||
|
||||
try {
|
||||
$database = new PDO('mysql:host=' . $config['db_host'] . ';dbname=' . $config['db_name'], $config['db_username'], $config['db_password'], array(PDO::ATTR_EMULATE_PREPARES => false, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION));
|
||||
} catch(PDOException $ex) {
|
||||
dieDatabaseError($ex);
|
||||
}
|
||||
|
||||
function databaseQuery($command, $array = array(), $assoc = false) {
|
||||
global $database;
|
||||
|
||||
if(!is_array($array)) {
|
||||
dieDatabaseError();
|
||||
}
|
||||
|
||||
try {
|
||||
$query = $database->prepare($command);
|
||||
|
||||
if(!$query) {
|
||||
print_r($database->errorInfo());
|
||||
dieDatabaseError();
|
||||
}
|
||||
|
||||
//set fetch mode depending on parameter
|
||||
if($assoc) {
|
||||
$query->setFetchMode(PDO::FETCH_ASSOC);
|
||||
} else {
|
||||
$query->setFetchMode(PDO::FETCH_NUM);
|
||||
}
|
||||
|
||||
$success = $query->execute($array);
|
||||
|
||||
if(!$success) {
|
||||
print_r($query->errorInfo());
|
||||
dieDatabaseError();
|
||||
}
|
||||
|
||||
return $query;
|
||||
} catch(PDOException $ex) {
|
||||
dieDatabaseError($ex);
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
85
gpg-mailgate-web/public_html/include/gpg.php
Normal file
85
gpg-mailgate-web/public_html/include/gpg.php
Normal file
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
/*
|
||||
|
||||
gpg-mailgate
|
||||
|
||||
This file is part of the gpg-mailgate source code.
|
||||
|
||||
gpg-mailgate is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
gpg-mailgate source code is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with gpg-mailgate source code. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
*/
|
||||
|
||||
//uses gpg to verify that a key belongs to a given email address
|
||||
function verifyPGPKey($content, $email) {
|
||||
global $config;
|
||||
|
||||
//allow blank "keys" if this is set
|
||||
//this means that encryption for $email will be disabled by the cron if it
|
||||
// was enabled originally
|
||||
if($config['pgpverify_allowblank'] && trim($content) == '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
require_once("Crypt/GPG.php");
|
||||
|
||||
//try to create a random subdirectory of $config['pgpverify_tmpdir']
|
||||
do {
|
||||
$path = $config['pgpverify_tmpdir'] . '/' . uid(16);
|
||||
} while(file_exists($path));
|
||||
|
||||
$result = @mkdir($path);
|
||||
|
||||
if($result === false) {
|
||||
if($config['debug']) {
|
||||
die("Failed to create directory [" . $path . "] for PGP verification.");
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$gpg = new Crypt_GPG(array('homedir' => $path));
|
||||
|
||||
//import the key to our GPG temp directory
|
||||
try {
|
||||
$gpg->importKey($content);
|
||||
} catch(Crypt_GPG_NoDataException $e) {
|
||||
//user supplied an invalid key!
|
||||
recursiveDelete($path);
|
||||
return false;
|
||||
}
|
||||
|
||||
//verify the email address matches
|
||||
$keys = $gpg->getKeys();
|
||||
|
||||
if(count($keys) != 1) {
|
||||
if($config['debug']) {
|
||||
die("Error in PGP verification: key count is " . count($keys) . "!");
|
||||
} else {
|
||||
recursiveDelete($path);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$userIds = $keys[0]->getUserIds();
|
||||
|
||||
if(count($userIds) != 1 || strtolower($userIds[0]->getEmail()) != strtolower($email)) {
|
||||
recursiveDelete($path);
|
||||
return false;
|
||||
}
|
||||
|
||||
recursiveDelete($path);
|
||||
return true;
|
||||
}
|
||||
|
||||
?>
|
0
gpg-mailgate-web/public_html/include/index.html
Normal file
0
gpg-mailgate-web/public_html/include/index.html
Normal file
25
gpg-mailgate-web/public_html/include/language.php
Normal file
25
gpg-mailgate-web/public_html/include/language.php
Normal file
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
/*
|
||||
|
||||
gpg-mailgate
|
||||
|
||||
This file is part of the gpg-mailgate source code.
|
||||
|
||||
gpg-mailgate is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
gpg-mailgate source code is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with gpg-mailgate source code. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
*/
|
||||
|
||||
require_once(dirname(__FILE__) . '/../language/' . $config['language'] . '.php');
|
||||
|
||||
?>
|
124
gpg-mailgate-web/public_html/include/lock.php
Normal file
124
gpg-mailgate-web/public_html/include/lock.php
Normal file
|
@ -0,0 +1,124 @@
|
|||
<?php
|
||||
/*
|
||||
|
||||
gpg-mailgate
|
||||
|
||||
This file is part of the gpg-mailgate source code.
|
||||
|
||||
gpg-mailgate is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
gpg-mailgate source code is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with gpg-mailgate source code. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
*/
|
||||
|
||||
//lock.php is basic spam-submit prevention
|
||||
//lock_time_initial, lock_time_overload, lock_count_overload, lock_time_reset, and lock_time_max should be defined in $config
|
||||
|
||||
//returns boolean: true=proceed, false=lock up; the difference between this and lockAction is that this can be used for repeated tasks, like admin
|
||||
// then, only if action was unsuccessful would lockAction be called
|
||||
function checkLock($action) {
|
||||
global $config;
|
||||
$lock_time_initial = $config['lock_time_initial'];
|
||||
$lock_time_overload = $config['lock_time_overload'];
|
||||
$lock_count_overload = $config['lock_count_overload'];
|
||||
$lock_time_reset = $config['lock_time_reset'];
|
||||
$lock_time_max = $config['lock_time_max'];
|
||||
|
||||
if(!isset($lock_time_initial[$action])) {
|
||||
return true; //well we can't do anything...
|
||||
}
|
||||
|
||||
$ip = $_SERVER['REMOTE_ADDR'];
|
||||
|
||||
$result = databaseQuery("SELECT id, time, num FROM gpgmw_locks WHERE ip = ? AND action = ?", array($ip, $action), true);
|
||||
if($row = $result->fetch()) {
|
||||
$id = $row['id'];
|
||||
$time = $row['time'];
|
||||
$count = $row['num']; //>=0 count means it's a regular initial lock; -1 count means overload lock
|
||||
|
||||
if($count >= 0) {
|
||||
if(time() <= $time + $lock_time_initial[$action]) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
if(time() <= $time + $lock_time_overload[$action]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
//returns boolean: true=proceed, false=lock up
|
||||
function lockAction($action) {
|
||||
global $config;
|
||||
$lock_time_initial = $config['lock_time_initial'];
|
||||
$lock_time_overload = $config['lock_time_overload'];
|
||||
$lock_count_overload = $config['lock_count_overload'];
|
||||
$lock_time_reset = $config['lock_time_reset'];
|
||||
$lock_time_max = $config['lock_time_max'];
|
||||
|
||||
if(!isset($lock_time_initial[$action])) {
|
||||
return true; //well we can't do anything...
|
||||
}
|
||||
|
||||
$ip = $_SERVER['REMOTE_ADDR'];
|
||||
$replace_id = -1;
|
||||
|
||||
//first find records with ip/action
|
||||
$result = databaseQuery("SELECT id, time, num FROM gpgmw_locks WHERE ip = ? AND action = ?", array($ip, $action), true);
|
||||
if($row = $result->fetch()) {
|
||||
$id = $row['id'];
|
||||
$time = $row['time'];
|
||||
$count = $row['num']; //>=0 count means it's a regular initial lock; -1 count means overload lock
|
||||
|
||||
if($count >= 0) {
|
||||
if(time() <= $time + $lock_time_initial[$action]) {
|
||||
return false;
|
||||
} else if(time() > $time + $lock_time_reset) {
|
||||
//this entry is old, but use it to replace
|
||||
$replace_id = $id;
|
||||
} else {
|
||||
//increase the count; maybe initiate an OVERLOAD
|
||||
$count = $count + 1;
|
||||
if($count >= $lock_count_overload[$action]) {
|
||||
databaseQuery("UPDATE gpgmw_locks SET num = '-1', time = ? WHERE ip = ?", array(time(), $ip));
|
||||
return false;
|
||||
} else {
|
||||
databaseQuery("UPDATE gpgmw_locks SET num = ?, time = ? WHERE ip = ?", array($count, time(), $ip));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if(time() <= $time + $lock_time_overload[$action]) {
|
||||
return false;
|
||||
} else {
|
||||
//their overload is over, so this entry is old
|
||||
$replace_id = $id;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
databaseQuery("INSERT INTO gpgmw_locks (ip, time, action, num) VALUES (?, ?, ?, '1')", array($ip, time(), $action));
|
||||
}
|
||||
|
||||
if($replace_id != -1) {
|
||||
databaseQuery("UPDATE gpgmw_locks SET num = '1', time = ? WHERE id = ?", array(time(), $replace_id));
|
||||
}
|
||||
|
||||
//some housekeeping
|
||||
$delete_time = time() - $lock_time_max;
|
||||
databaseQuery("DELETE FROM gpgmw_locks WHERE time <= ?", array($delete_time));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
?>
|
100
gpg-mailgate-web/public_html/include/pgp.php
Normal file
100
gpg-mailgate-web/public_html/include/pgp.php
Normal file
|
@ -0,0 +1,100 @@
|
|||
<?php
|
||||
/*
|
||||
|
||||
gpg-mailgate
|
||||
|
||||
This file is part of the gpg-mailgate source code.
|
||||
|
||||
gpg-mailgate is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
gpg-mailgate source code is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with gpg-mailgate source code. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
*/
|
||||
|
||||
//returns true on success or error message on failure
|
||||
function requestPGP($email, $key) {
|
||||
require_once(includePath() . "/lock.php");
|
||||
global $config, $lang;
|
||||
|
||||
if(!checkLock('requestpgp')) {
|
||||
return $lang['submit_error_trylater'];
|
||||
}
|
||||
|
||||
if(!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
return $lang['submit_error_bademail'];
|
||||
}
|
||||
|
||||
if(strlen($email) > 256 || strlen($key) > 1024 * 32) {
|
||||
return $lang['submit_error_toolong'];
|
||||
}
|
||||
|
||||
if(!isAscii($key)) {
|
||||
return $lang['submit_error_nonascii'];
|
||||
}
|
||||
|
||||
//housekeeping
|
||||
databaseQuery("DELETE FROM gpgmw_keys WHERE time < DATE_SUB(NOW(), INTERVAL 48 HOUR) AND confirm != '' AND status = 0");
|
||||
|
||||
//if we already have an unaccepted key for this user, only replace if one day has elapsed since the last request
|
||||
// this may prevent spam
|
||||
$result = databaseQuery("SELECT HOUR(TIMEDIFF(time, NOW())), id FROM gpgmw_keys WHERE email = ? AND status = 0", array($email));
|
||||
|
||||
if($row = $result->fetch()) {
|
||||
if($row[0] < 24) {
|
||||
return $lang['submit_error_alreadyqueue'];
|
||||
} else {
|
||||
databaseQuery('DELETE FROM gpgmw_keys WHERE id = ?', array($row[1]));
|
||||
}
|
||||
}
|
||||
|
||||
//if PGP key verification is enabled, do it
|
||||
if($config['pgpverify_enable']) {
|
||||
require_once(includePath() . "/gpg.php");
|
||||
|
||||
if(!verifyPGPKey($key, $email)) {
|
||||
return $lang['submit_error_badkey'];
|
||||
}
|
||||
}
|
||||
|
||||
//well, it looks good, let's submit it
|
||||
lockAction('requestpgp');
|
||||
$confirm = uid(32);
|
||||
$confirm_link = "{$config['site_url']}/confirm.php?email=" . urlencode($email) . "&confirm=$confirm";
|
||||
$result = gpgmw_mail($config['email_subject_requestpgp'], sprintf($lang['mail_confirm'], $confirm_link), $email);
|
||||
|
||||
if(!$result) {
|
||||
return $lang['submit_error_emailfail'];
|
||||
}
|
||||
|
||||
databaseQuery("INSERT INTO gpgmw_keys (email, publickey, confirm) VALUES (?, ?, ?)", array($email, $key, $confirm));
|
||||
return true;
|
||||
}
|
||||
|
||||
//returns false on failure or true on success
|
||||
function confirmPGP($email, $confirm) {
|
||||
require_once(includePath() . "/lock.php");
|
||||
|
||||
if(!lockAction('confirmpgp')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = databaseQuery("SELECT id FROM gpgmw_keys WHERE confirm = ? AND email = ?", array($confirm, $email));
|
||||
|
||||
if($row = $result->fetch()) {
|
||||
databaseQuery("UPDATE gpgmw_keys SET confirm = '' WHERE id = ?", array($row[0]));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
?>
|
55
gpg-mailgate-web/public_html/include/phphkp.php
Normal file
55
gpg-mailgate-web/public_html/include/phphkp.php
Normal file
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
*
|
||||
* HKP Keyserver Interface for submitting public keys
|
||||
* to the gpg-mailgate-web database directly from
|
||||
* an OpenPGP client
|
||||
*
|
||||
* loosely based on ElTramo's phkp code
|
||||
* http://el-tramo.be/software/phkp
|
||||
*
|
||||
* 2014 by Kiritan Flux
|
||||
*
|
||||
* Licensed under the GNU General Public License.
|
||||
*
|
||||
* check the README for necessary prerequisites
|
||||
*
|
||||
*/
|
||||
|
||||
//! OpenPGP client command
|
||||
$PGP_COMMAND="gpg";
|
||||
|
||||
//! A dir where the PHP script has write access
|
||||
$PGP_HOME="/var/gpg/.phkp";
|
||||
|
||||
//! The maximum size (in characters) of a submitted key.
|
||||
//! Set to '0' to disable receiving of keys, and '-1' for no limit.
|
||||
$MAX_KEYSIZE=102400;
|
||||
|
||||
|
||||
if (preg_match("/pks\/add/",$_SERVER['REQUEST_URI']))
|
||||
{
|
||||
if ($MAX_KEYSIZE == -1 || strlen($_POST['keytext']) <= $MAX_KEYSIZE)
|
||||
{
|
||||
//write key into temporary file
|
||||
file_put_contents( "$PGP_HOME/tmp", $_POST['keytext'] );
|
||||
//run gpg --with-fingerprint to retreive information about the key from the keyfile
|
||||
$result = shell_exec("$PGP_COMMAND --homedir $PGP_HOME --with-fingerprint $PGP_HOME/tmp");
|
||||
//extract email addresses from the information
|
||||
$pattern = '/[a-z0-9_\-\+]+@[a-z0-9\-]+\.([a-z]{2,3})(?:\.[a-z]{2})?/i';
|
||||
preg_match_all($pattern, $result, $matches);
|
||||
//for each email address assigned to the key, put intformation into the DB and send confirmation emails
|
||||
foreach($matches[0] as $match)
|
||||
{
|
||||
//echo $match.': '.$_POST['keytext'];
|
||||
requestPGP($match, $_POST['keytext']);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
header("HTTP/1.0 403 Forbidden");
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
43
gpg-mailgate-web/public_html/index.php
Normal file
43
gpg-mailgate-web/public_html/index.php
Normal file
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
/*
|
||||
|
||||
gpg-mailgate
|
||||
|
||||
This file is part of the gpg-mailgate source code.
|
||||
|
||||
gpg-mailgate is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
gpg-mailgate source code is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with gpg-mailgate source code. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
*/
|
||||
|
||||
require_once("include/config.php");
|
||||
require_once("include/language.php");
|
||||
require_once("include/common.php");
|
||||
require_once("include/dbconnect.php");
|
||||
require_once("include/pgp.php");
|
||||
require_once("include/phphkp.php");
|
||||
|
||||
|
||||
if(isset($_POST['email']) && isset($_POST['key'])) {
|
||||
$result = requestPGP($_POST['email'], $_POST['key']);
|
||||
|
||||
if($result === true) {
|
||||
get_page("home", array('message' => $lang['submit_success']));
|
||||
} else {
|
||||
get_page("home", array('message' => $result));
|
||||
}
|
||||
} else {
|
||||
get_page("home");
|
||||
}
|
||||
|
||||
?>
|
47
gpg-mailgate-web/public_html/language/english.php
Normal file
47
gpg-mailgate-web/public_html/language/english.php
Normal file
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
/*
|
||||
|
||||
gpg-mailgate
|
||||
|
||||
This file is part of the gpg-mailgate source code.
|
||||
|
||||
gpg-mailgate is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
gpg-mailgate source code is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with gpg-mailgate source code. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
*/
|
||||
|
||||
$lang = array();
|
||||
|
||||
$lang['home_text'] = 'Use the form below to submit an ASCII-armored PGP public key. After submission, you will receive an email asking you to confirm your email address. Once confirmation is completed, mail sent to your email address via our mail server will be encrypted with your PGP public key.';
|
||||
$lang['home_footer'] = '<a href="https://github.com/uakfdotb/gpg-mailgate">gpg-mailgate and gpg-mailgate-web</a> are released under the <a href="https://www.gnu.org/licenses/lgpl-3.0.txt">GNU LGPL</a>.';
|
||||
$lang['home_emaildesc'] = 'Your email address (must match key)';
|
||||
$lang['home_keydesc'] = 'ASCII-armored PGP public key';
|
||||
$lang['home_submitkey'] = 'Submit key';
|
||||
|
||||
$lang['submit_success'] = 'Key submission successful. Please check your email to confirm your email address.';
|
||||
$lang['submit_error_trylater'] = 'Error: please wait a bit before trying again.';
|
||||
$lang['submit_error_bademail'] = 'Error: invalid email address.';
|
||||
$lang['submit_error_toolong'] = 'Error: email address or key too long.';
|
||||
$lang['submit_error_nonascii'] = 'Error: only keys encoded with ASCII armor are accepted (gpg --armor).';
|
||||
$lang['submit_error_alreadyqueue'] = 'Error: there is already a key in the queue for this email address; please wait twenty-four hours between submitting keys, or confirm the previous key and then resubmit.';
|
||||
$lang['submit_error_badkey'] = 'Error: your key does not appear to be valid (ensure ASCII armor is enabled and that the email address entered matches the email address of the key).';
|
||||
$lang['submit_error_emailfail'] = 'Error: failed to send email.';
|
||||
$lang['submit_error_bademail'] = 'Error: invalid email address.';
|
||||
$lang['submit_error_bademail'] = 'Error: invalid email address.';
|
||||
|
||||
$lang['confirm_success'] = 'Your email address has been confirmed successfully. Within a few minutes, emails from our mail server to you should be encrypted with your PGP public key.';
|
||||
$lang['confirm_fail_general'] = 'Error: failed to confirm any email address. You may have already confirmed the address, or you may have the wrong confirmation key.';
|
||||
|
||||
$lang['mail_confirm'] = "Please confirm your email address to complete the submission process. You can do so by clicking the link below\n\n%s\n\nThanks,\ngpg-mailgate-web";
|
||||
|
||||
?>
|
0
gpg-mailgate-web/public_html/language/index.html
Normal file
0
gpg-mailgate-web/public_html/language/index.html
Normal file
25
gpg-mailgate-web/public_html/theme/footer.php
Normal file
25
gpg-mailgate-web/public_html/theme/footer.php
Normal file
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
/*
|
||||
|
||||
gpg-mailgate
|
||||
|
||||
This file is part of the gpg-mailgate source code.
|
||||
|
||||
gpg-mailgate is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
gpg-mailgate source code is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with gpg-mailgate source code. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
*/
|
||||
?>
|
||||
|
||||
</body>
|
||||
</html>
|
28
gpg-mailgate-web/public_html/theme/header.php
Normal file
28
gpg-mailgate-web/public_html/theme/header.php
Normal file
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
/*
|
||||
|
||||
gpg-mailgate
|
||||
|
||||
This file is part of the gpg-mailgate source code.
|
||||
|
||||
gpg-mailgate is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
gpg-mailgate source code is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with gpg-mailgate source code. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
*/
|
||||
?>
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<title>gpg-mailgate-web</title>
|
||||
</head>
|
||||
<body>
|
46
gpg-mailgate-web/public_html/theme/home.php
Normal file
46
gpg-mailgate-web/public_html/theme/home.php
Normal file
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
/*
|
||||
|
||||
gpg-mailgate
|
||||
|
||||
This file is part of the gpg-mailgate source code.
|
||||
|
||||
gpg-mailgate is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
gpg-mailgate source code is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with gpg-mailgate source code. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
*/
|
||||
?>
|
||||
|
||||
<h1><?= $config['site_title'] ?></h1>
|
||||
|
||||
<? if(!empty($message)) { ?>
|
||||
<p><b><i><?= htmlspecialchars($message) ?></i></b></p>
|
||||
<? } ?>
|
||||
|
||||
<p><?= $lang['home_text'] ?></p>
|
||||
|
||||
<form method="POST">
|
||||
<table>
|
||||
<tr>
|
||||
<td><?= $lang['home_emaildesc'] ?></td>
|
||||
<td><input type="text" name="email" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><?= $lang['home_keydesc'] ?></td>
|
||||
<td><textarea name="key" rows="10" cols="80"></textarea></td>
|
||||
</tr>
|
||||
</table>
|
||||
<input type="submit" value="<?= $lang['home_submitkey'] ?>" />
|
||||
</form>
|
||||
|
||||
<p><?= $lang['home_footer'] ?></p>
|
0
gpg-mailgate-web/public_html/theme/index.html
Normal file
0
gpg-mailgate-web/public_html/theme/index.html
Normal file
10
gpg-mailgate-web/schema.sql
Normal file
10
gpg-mailgate-web/schema.sql
Normal file
|
@ -0,0 +1,10 @@
|
|||
-- confirm is empty once an email address has been confirmed, and otherwise is the confirmation key
|
||||
-- status
|
||||
-- initializes to 0
|
||||
-- is set to 1 after a public key with (confirm='', status=0) has been imported
|
||||
-- is set to 2 if a key should be deleted (will be deleted based on email address)
|
||||
-- publickey is the ASCII-armored PGP public key; can be cleared to save space if status > 0
|
||||
CREATE TABLE gpgmw_keys (id INT NOT NULL PRIMARY KEY AUTO_INCREMENT, email VARCHAR(256), publickey TEXT, confirm VARCHAR(32), status INT NOT NULL DEFAULT 0, time TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||
|
||||
-- see include/lock.php for documentation
|
||||
CREATE TABLE gpgmw_locks (id INT NOT NULL PRIMARY KEY AUTO_INCREMENT, ip VARCHAR(16), time INT, action VARCHAR(16), num INT);
|
136
gpg-mailgate.conf.sample
Normal file
136
gpg-mailgate.conf.sample
Normal file
|
@ -0,0 +1,136 @@
|
|||
[default]
|
||||
# Whether gpg-mailgate should add a header after it has processed an email
|
||||
# This may be useful for debugging purposes
|
||||
add_header = yes
|
||||
|
||||
# Whether we should only encrypt emails if they are explicitly defined in
|
||||
# the key mappings below ([enc_keymap] section)
|
||||
# This means gpg-mailgate won't automatically detect PGP recipients for encrypting
|
||||
enc_keymap_only = no
|
||||
|
||||
# Whether we should only decrypt emails if they are explicitly defined in
|
||||
# the key mappings below ([dec_keymap] section)
|
||||
# This means gpg-mailgate won't automatically detect PGP recipients for decrypting
|
||||
dec_keymap_only = no
|
||||
|
||||
# If dec_keymap_only is set to yes and recipients have private keys present for decrypting
|
||||
# but are not on in the keymap, this can cause that mails for them will be
|
||||
# encrypted. Set this to no if you want this behaviour.
|
||||
failsave_dec = yes
|
||||
|
||||
# Convert encrypted text/plain email to MIME-attached encrypt style.
|
||||
# (Default is to use older inline-style PGP encoding.)
|
||||
mime_conversion = yes
|
||||
|
||||
# RFC 2821 defines that the user part (User@domain.tld) of a mail address should be treated case sensitive.
|
||||
# However, in the real world this is ignored very often. This option disables the RFC 2821
|
||||
# compatibility so both the user part and the domain part are treated case insensitive.
|
||||
# Disabling the compatibility is more convenient to users. So if you know that your
|
||||
# recipients all ignore the RFC you could this to yes.
|
||||
mail_case_insensitive = no
|
||||
|
||||
# This setting disables PGP/INLINE decryption completely. However,
|
||||
# PGP/MIME encrypted mails will still be decrypted if possible. PGP/INLINE
|
||||
# decryption has to be seen as experimental and could have some negative
|
||||
# side effects. So if you want to take the risk set this to no.
|
||||
no_inline_dec = yes
|
||||
|
||||
# Here you can define a regex for which the gateway should try to decrypt mails.
|
||||
# It could be used to define that decryption should be used for a wider range of
|
||||
# mail addresses e.g. a whole domain. No key is needed here. It is even active if
|
||||
# dec_keymap is set to yes. If this feature should be disabled, don't leave it blank.
|
||||
# Set it to None. For further regex information please have a look at
|
||||
# https://docs.python.org/2/library/re.html
|
||||
dec_regex = None
|
||||
|
||||
[gpg]
|
||||
# the directory where gpg-mailgate public keys are stored
|
||||
# (see INSTALL for details)
|
||||
keyhome = /var/gpgmailgate/.gnupg
|
||||
|
||||
[smime]
|
||||
# the directory for the S/MIME certificate files
|
||||
cert_path = /var/gpgmailgate/smime
|
||||
|
||||
[mailregister]
|
||||
# settings for the register-handler
|
||||
register_email = register@yourdomain.tld
|
||||
mail_templates = /var/gpgmailgate/register_templates
|
||||
# URL to webpanel. The server should be able to reach it
|
||||
webpanel_url = http://yourdomain.tld
|
||||
|
||||
[cron]
|
||||
# settings for the gpgmw cron job
|
||||
send_email = yes
|
||||
notification_email = gpg-mailgate@yourdomain.tld
|
||||
mail_templates = /var/gpgmailgate/cron_templates
|
||||
|
||||
[logging]
|
||||
# For logging to syslog. 'file = syslog', otherwise use path to the file.
|
||||
file = syslog
|
||||
verbose = yes
|
||||
|
||||
[relay]
|
||||
# the relay settings to use for Postfix
|
||||
# gpg-mailgate will submit email to this relay after it is done processing
|
||||
# unless you alter the default Postfix configuration, you won't have to modify this
|
||||
host = 127.0.0.1
|
||||
port = 10028
|
||||
# This is the default port of postfix. It is used to send some
|
||||
# mails through the GPG-Mailgate so they are encrypted
|
||||
enc_port = 25
|
||||
|
||||
# Set this option to yes to use TLS for SMTP Servers which require TLS.
|
||||
starttls = no
|
||||
|
||||
[database]
|
||||
# uncomment the settings below if you want
|
||||
# to read keys from a gpg-mailgate-web database
|
||||
enabled = yes
|
||||
name = gpgmw
|
||||
host = localhost
|
||||
username = gpgmw
|
||||
password = password
|
||||
|
||||
[enc_keymap]
|
||||
# You can find these by running the following command:
|
||||
# gpg --list-keys --keyid-format long user@example.com
|
||||
# Which will return output similar to:
|
||||
# pub 1024D/AAAAAAAAAAAAAAAA 2007-10-22
|
||||
# uid Joe User <user@example.com>
|
||||
# sub 2048g/BBBBBBBBBBBBBBBB 2007-10-22
|
||||
# You want the AAAAAAAAAAAAAAAA not BBBBBBBBBBBBBBBB.
|
||||
#you@domain.tld = 12345678
|
||||
|
||||
[enc_domain_keymap]
|
||||
# This seems to be similar to the [enc_keymap] section. However, you
|
||||
# can define default keys for a domain here. Entries in the enc_keymap
|
||||
# and individual keys stored on the system have a higher priority than
|
||||
# the default keys specified here.
|
||||
#
|
||||
#
|
||||
# You can find these by running the following command:
|
||||
# gpg --list-keys --keyid-format long user@example.com
|
||||
# Which will return output similar to:
|
||||
# pub 1024D/AAAAAAAAAAAAAAAA 2007-10-22
|
||||
# uid Joe User <user@example.com>
|
||||
# sub 2048g/BBBBBBBBBBBBBBBB 2007-10-22
|
||||
# You want the AAAAAAAAAAAAAAAA not BBBBBBBBBBBBBBBB.
|
||||
#domain.tld = 12345678
|
||||
|
||||
[dec_keymap]
|
||||
# You can find these by running the following command:
|
||||
# gpg --list-secret-keys --keyid-format long user@example.com
|
||||
# Which will return output similar to:
|
||||
# sec 1024D/AAAAAAAAAAAAAAAA 2007-10-22
|
||||
# uid Joe User <user@example.com>
|
||||
# ssb 2048g/BBBBBBBBBBBBBBBB 2007-10-22
|
||||
# You want the AAAAAAAAAAAAAAAA not BBBBBBBBBBBBBBBB.
|
||||
#you@domain.tld = 12345678
|
||||
|
||||
[pgp_style]
|
||||
# Here a PGP style (inline or PGP/MIME) could be defined for recipients.
|
||||
# This overwrites the setting mime_conversion for the defined recipients.
|
||||
# Valid entries are inline and mime
|
||||
# If an entry is not valid, the setting mime_conversion is used as fallback.
|
||||
#you@domian.tld = mime
|
466
gpg-mailgate.py
Executable file
466
gpg-mailgate.py
Executable file
|
@ -0,0 +1,466 @@
|
|||
#!/usr/bin/python
|
||||
|
||||
#
|
||||
# gpg-mailgate
|
||||
#
|
||||
# This file is part of the gpg-mailgate source code.
|
||||
#
|
||||
# gpg-mailgate is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# gpg-mailgate source code is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with gpg-mailgate source code. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
from configparser import RawConfigParser
|
||||
from email.mime.base import MIMEBase
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
import copy
|
||||
import email
|
||||
import email.message
|
||||
import email.utils
|
||||
import GnuPG
|
||||
import os
|
||||
import re
|
||||
import smtplib
|
||||
import sys
|
||||
import syslog
|
||||
import traceback
|
||||
|
||||
|
||||
# imports for S/MIME
|
||||
from M2Crypto import BIO, Rand, SMIME, X509
|
||||
from email.mime.message import MIMEMessage
|
||||
|
||||
# Environment variable name we read to retrieve configuration path. This is to
|
||||
# enable non-root users to set up and run GPG Mailgate and to make the software
|
||||
# testable.
|
||||
CONFIG_PATH_ENV = "GPG_MAILGATE_CONFIG"
|
||||
|
||||
# Read configuration from /etc/gpg-mailgate.conf
|
||||
_cfg = RawConfigParser()
|
||||
_cfg.read(os.getenv(CONFIG_PATH_ENV, '/etc/gpg-mailgate.conf'))
|
||||
cfg = dict()
|
||||
for sect in _cfg.sections():
|
||||
cfg[sect] = dict()
|
||||
for (name, value) in _cfg.items(sect):
|
||||
cfg[sect][name] = value
|
||||
|
||||
def log( msg ):
|
||||
if 'logging' in cfg and 'file' in cfg['logging']:
|
||||
if cfg['logging'].get('file') == "syslog":
|
||||
syslog.syslog(syslog.LOG_INFO | syslog.LOG_MAIL, msg)
|
||||
else:
|
||||
logfile = open(cfg['logging']['file'], 'a')
|
||||
logfile.write(msg + "\n")
|
||||
logfile.close()
|
||||
|
||||
verbose = 'logging' in cfg and 'verbose' in cfg['logging'] and cfg['logging'].get('verbose') == 'yes'
|
||||
|
||||
# Read e-mail from stdin
|
||||
raw = sys.stdin.read()
|
||||
raw_message = email.message_from_string( raw )
|
||||
from_addr = raw_message['From']
|
||||
to_addrs = sys.argv[1:]
|
||||
|
||||
def gpg_encrypt( raw_message, recipients ):
|
||||
|
||||
if not get_bool_from_cfg('gpg', 'keyhome'):
|
||||
log("No valid entry for gpg keyhome. Encryption aborted.")
|
||||
return recipients
|
||||
|
||||
keys = GnuPG.public_keys( cfg['gpg']['keyhome'] )
|
||||
for fingerprint in keys:
|
||||
keys[fingerprint] = sanitize_case_sense(keys[fingerprint])
|
||||
|
||||
gpg_to = list()
|
||||
ungpg_to = list()
|
||||
|
||||
for to in recipients:
|
||||
|
||||
# Check if recipient is in keymap
|
||||
if get_bool_from_cfg('enc_keymap', to):
|
||||
log("Encrypt keymap has key '%s'" % cfg['enc_keymap'][to] )
|
||||
# Check we've got a matching key!
|
||||
if cfg['enc_keymap'][to] in keys:
|
||||
gpg_to.append( (to, cfg['enc_keymap'][to]) )
|
||||
continue
|
||||
else:
|
||||
log("Key '%s' in encrypt keymap not found in keyring for email address '%s'." % (cfg['enc_keymap'][to], to))
|
||||
|
||||
# Check if key in keychain is present
|
||||
if to in keys.values() and not get_bool_from_cfg('default', 'enc_keymap_only', 'yes'):
|
||||
gpg_to.append( (to, to) )
|
||||
continue
|
||||
|
||||
# Check if there is a default key for the domain
|
||||
splitted_to = to.split('@')
|
||||
if len(splitted_to) > 1:
|
||||
domain = splitted_to[1]
|
||||
if get_bool_from_cfg('enc_domain_keymap', domain):
|
||||
log("Encrypt domain keymap has key '%s'" % cfg['enc_dec_keymap'][domain] )
|
||||
# Check we've got a matching key!
|
||||
if cfg['enc_domain_keymap'][domain] in keys:
|
||||
log("Using default domain key for recipient '%s'" % to)
|
||||
gpg_to.append( (to, cfg['enc_domain_keymap'][domain]) )
|
||||
continue
|
||||
else:
|
||||
log("Key '%s' in encrypt domain keymap not found in keyring for email address '%s'." % (cfg['enc_domain_keymap'][domain], to))
|
||||
|
||||
# At this point no key has been found
|
||||
if verbose:
|
||||
log("Recipient (%s) not in PGP domain list for encrypting." % to)
|
||||
ungpg_to.append(to)
|
||||
|
||||
if gpg_to != list():
|
||||
log("Encrypting email to: %s" % ' '.join( x[0] for x in gpg_to ))
|
||||
|
||||
# Getting PGP style for recipient
|
||||
gpg_to_smtp_mime = list()
|
||||
gpg_to_cmdline_mime = list()
|
||||
|
||||
gpg_to_smtp_inline = list()
|
||||
gpg_to_cmdline_inline = list()
|
||||
|
||||
for rcpt in gpg_to:
|
||||
# Checking pre defined styles in settings first
|
||||
if get_bool_from_cfg('pgp_style', rcpt[0], 'mime'):
|
||||
gpg_to_smtp_mime.append(rcpt[0])
|
||||
gpg_to_cmdline_mime.extend(rcpt[1].split(','))
|
||||
elif get_bool_from_cfg('pgp_style', rcpt[0], 'inline'):
|
||||
gpg_to_smtp_inline.append(rcpt[0])
|
||||
gpg_to_cmdline_inline.extend(rcpt[1].split(','))
|
||||
else:
|
||||
# Log message only if an unknown style is defined
|
||||
if get_bool_from_cfg('pgp_style', rcpt[0]):
|
||||
log("Style %s for recipient %s is not known. Use default as fallback." % (cfg['pgp_style'][rcpt[0]], rcpt[0]))
|
||||
|
||||
# If no style is in settings defined for recipient, use default from settings
|
||||
if get_bool_from_cfg('default', 'mime_conversion', 'yes'):
|
||||
gpg_to_smtp_mime.append(rcpt[0])
|
||||
gpg_to_cmdline_mime.extend(rcpt[1].split(','))
|
||||
else:
|
||||
gpg_to_smtp_inline.append(rcpt[0])
|
||||
gpg_to_cmdline_inline.extend(rcpt[1].split(','))
|
||||
|
||||
if gpg_to_smtp_mime != list():
|
||||
# Encrypt mail with PGP/MIME
|
||||
raw_message_mime = copy.deepcopy(raw_message)
|
||||
|
||||
if get_bool_from_cfg('default', 'add_header', 'yes'):
|
||||
raw_message_mime['X-GPG-Mailgate'] = 'Encrypted by GPG Mailgate'
|
||||
|
||||
if 'Content-Transfer-Encoding' in raw_message_mime:
|
||||
raw_message_mime.replace_header('Content-Transfer-Encoding', '8BIT')
|
||||
else:
|
||||
raw_message_mime['Content-Transfer-Encoding'] = '8BIT'
|
||||
|
||||
encrypted_payloads = encrypt_all_payloads_mime( raw_message_mime, gpg_to_cmdline_mime )
|
||||
raw_message_mime.set_payload( encrypted_payloads )
|
||||
|
||||
send_msg( raw_message_mime.as_string(), gpg_to_smtp_mime )
|
||||
|
||||
if gpg_to_smtp_inline != list():
|
||||
# Encrypt mail with PGP/INLINE
|
||||
raw_message_inline = copy.deepcopy(raw_message)
|
||||
|
||||
if get_bool_from_cfg('default', 'add_header', 'yes'):
|
||||
raw_message_inline['X-GPG-Mailgate'] = 'Encrypted by GPG Mailgate'
|
||||
|
||||
if 'Content-Transfer-Encoding' in raw_message_inline:
|
||||
raw_message_inline.replace_header('Content-Transfer-Encoding', '8BIT')
|
||||
else:
|
||||
raw_message_inline['Content-Transfer-Encoding'] = '8BIT'
|
||||
|
||||
encrypted_payloads = encrypt_all_payloads_inline( raw_message_inline, gpg_to_cmdline_inline )
|
||||
raw_message_inline.set_payload( encrypted_payloads )
|
||||
|
||||
send_msg( raw_message_inline.as_string(), gpg_to_smtp_inline )
|
||||
|
||||
return ungpg_to
|
||||
|
||||
def encrypt_all_payloads_inline( message, gpg_to_cmdline ):
|
||||
|
||||
# This breaks cascaded MIME messages. Blame PGP/INLINE.
|
||||
encrypted_payloads = list()
|
||||
if isinstance(message.get_payload(), str):
|
||||
return encrypt_payload( message, gpg_to_cmdline ).get_payload()
|
||||
|
||||
for payload in message.get_payload():
|
||||
if( isinstance(payload.get_payload(), list) ):
|
||||
encrypted_payloads.extend( encrypt_all_payloads_inline( payload, gpg_to_cmdline ) )
|
||||
else:
|
||||
encrypted_payloads.append( encrypt_payload( payload, gpg_to_cmdline ) )
|
||||
|
||||
return encrypted_payloads
|
||||
|
||||
def encrypt_all_payloads_mime( message, gpg_to_cmdline ):
|
||||
|
||||
# Convert a plain text email into PGP/MIME attachment style. Modeled after enigmail.
|
||||
submsg1 = email.message.Message()
|
||||
submsg1.set_payload("Version: 1\n")
|
||||
submsg1.set_type("application/pgp-encrypted")
|
||||
submsg1.set_param('PGP/MIME version identification', "", 'Content-Description' )
|
||||
|
||||
submsg2 = email.message.Message()
|
||||
submsg2.set_type("application/octet-stream")
|
||||
submsg2.set_param('name', "encrypted.asc")
|
||||
submsg2.set_param('OpenPGP encrypted message', "", 'Content-Description' )
|
||||
submsg2.set_param('inline', "", 'Content-Disposition' )
|
||||
submsg2.set_param('filename', "encrypted.asc", 'Content-Disposition' )
|
||||
|
||||
if isinstance(message.get_payload(), str):
|
||||
# WTF! It seems to swallow the first line. Not sure why. Perhaps
|
||||
# it's skipping an imaginary blank line someplace. (ie skipping a header)
|
||||
# Workaround it here by prepending a blank line.
|
||||
# This happens only on text only messages.
|
||||
additionalSubHeader=""
|
||||
if 'Content-Type' in message and not message['Content-Type'].startswith('multipart'):
|
||||
additionalSubHeader="Content-Type: "+message['Content-Type']+"\n"
|
||||
submsg2.set_payload(additionalSubHeader+"\n" +message.get_payload(decode=True))
|
||||
check_nested = True
|
||||
else:
|
||||
processed_payloads = generate_message_from_payloads(message)
|
||||
submsg2.set_payload(processed_payloads.as_string())
|
||||
check_nested = False
|
||||
|
||||
message.preamble = "This is an OpenPGP/MIME encrypted message (RFC 2440 and 3156)"
|
||||
|
||||
# Use this just to generate a MIME boundary string.
|
||||
junk_msg = MIMEMultipart()
|
||||
junk_str = junk_msg.as_string() # WTF! Without this, get_boundary() will return 'None'!
|
||||
boundary = junk_msg.get_boundary()
|
||||
|
||||
# This also modifies the boundary in the body of the message, ie it gets parsed.
|
||||
if 'Content-Type' in message:
|
||||
message.replace_header('Content-Type', "multipart/encrypted; protocol=\"application/pgp-encrypted\";\nboundary=\"%s\"\n" % boundary)
|
||||
else:
|
||||
message['Content-Type'] = "multipart/encrypted; protocol=\"application/pgp-encrypted\";\nboundary=\"%s\"\n" % boundary
|
||||
|
||||
return [ submsg1, encrypt_payload(submsg2, gpg_to_cmdline, check_nested) ]
|
||||
|
||||
def encrypt_payload( payload, gpg_to_cmdline, check_nested = True ):
|
||||
|
||||
raw_payload = payload.get_payload(decode=True)
|
||||
if check_nested and b"-----BEGIN PGP MESSAGE-----" in raw_payload and b"-----END PGP MESSAGE-----" in raw_payload:
|
||||
if verbose:
|
||||
log("Message is already pgp encrypted. No nested encryption needed.")
|
||||
return payload
|
||||
|
||||
# No check is needed for cfg['gpg']['keyhome'] as this is already done in method gpg_encrypt
|
||||
gpg = GnuPG.GPGEncryptor( cfg['gpg']['keyhome'], gpg_to_cmdline, payload.get_content_charset() )
|
||||
gpg.update( raw_payload )
|
||||
encrypted_data, returncode = gpg.encrypt()
|
||||
if verbose:
|
||||
log("Return code from encryption=%d (0 indicates success)." % returncode)
|
||||
if returncode != 0:
|
||||
log("Encrytion failed with return code %d. Encryption aborted." % returncode)
|
||||
return payload
|
||||
|
||||
payload.set_payload( encrypted_data )
|
||||
isAttachment = payload.get_param( 'attachment', None, 'Content-Disposition' ) is not None
|
||||
|
||||
if isAttachment:
|
||||
filename = payload.get_filename()
|
||||
if filename:
|
||||
pgpFilename = filename + ".pgp"
|
||||
if not (payload.get('Content-Disposition') is None):
|
||||
payload.set_param( 'filename', pgpFilename, 'Content-Disposition' )
|
||||
if not (payload.get('Content-Type') is None) and not (payload.get_param( 'name' ) is None):
|
||||
payload.set_param( 'name', pgpFilename )
|
||||
if not (payload.get('Content-Transfer-Encoding') is None):
|
||||
payload.replace_header( 'Content-Transfer-Encoding', "7bit" )
|
||||
|
||||
return payload
|
||||
|
||||
def smime_encrypt( raw_message, recipients ):
|
||||
|
||||
if not get_bool_from_cfg('smime', 'cert_path'):
|
||||
log("No valid path for S/MIME certs found in config file. S/MIME encryption aborted.")
|
||||
return recipients
|
||||
|
||||
cert_path = cfg['smime']['cert_path']+"/"
|
||||
s = SMIME.SMIME()
|
||||
sk = X509.X509_Stack()
|
||||
smime_to = list()
|
||||
unsmime_to = list()
|
||||
|
||||
for addr in recipients:
|
||||
cert_and_email = get_cert_for_email(addr, cert_path)
|
||||
|
||||
if not (cert_and_email is None):
|
||||
(to_cert, normal_email) = cert_and_email
|
||||
if verbose:
|
||||
log("Found cert " + to_cert + " for " + addr + ": " + normal_email)
|
||||
smime_to.append(addr)
|
||||
x509 = X509.load_cert(to_cert, format=X509.FORMAT_PEM)
|
||||
sk.push(x509)
|
||||
else:
|
||||
unsmime_to.append(addr)
|
||||
|
||||
if smime_to != list():
|
||||
s.set_x509_stack(sk)
|
||||
s.set_cipher(SMIME.Cipher('aes_192_cbc'))
|
||||
p7 = s.encrypt( BIO.MemoryBuffer( raw_message.as_string() ) )
|
||||
# Output p7 in mail-friendly format.
|
||||
out = BIO.MemoryBuffer()
|
||||
out.write('From: ' + from_addr + '\n')
|
||||
out.write('To: ' + raw_message['To'] + '\n')
|
||||
if raw_message['Cc']:
|
||||
out.write('Cc: ' + raw_message['Cc'] + '\n')
|
||||
if raw_message['Bcc']:
|
||||
out.write('Bcc: ' + raw_message['Bcc'] + '\n')
|
||||
if raw_message['Subject']:
|
||||
out.write('Subject: '+ raw_message['Subject'] + '\n')
|
||||
|
||||
if get_bool_from_cfg('default', 'add_header', 'yes'):
|
||||
out.write('X-GPG-Mailgate: Encrypted by GPG Mailgate\n')
|
||||
|
||||
s.write(out, p7)
|
||||
|
||||
if verbose:
|
||||
log("Sending message from " + from_addr + " to " + str(smime_to))
|
||||
|
||||
send_msg(out.read(), smime_to)
|
||||
if unsmime_to != list():
|
||||
if verbose:
|
||||
log("Unable to find valid S/MIME certificates for " + str(unsmime_to))
|
||||
|
||||
return unsmime_to
|
||||
|
||||
def get_cert_for_email( to_addr, cert_path ):
|
||||
|
||||
files_in_directory = os.listdir(cert_path)
|
||||
for filename in files_in_directory:
|
||||
file_path = os.path.join(cert_path, filename)
|
||||
if not os.path.isfile(file_path):
|
||||
continue
|
||||
|
||||
if get_bool_from_cfg('default', 'mail_case_insensitive', 'yes'):
|
||||
if filename.lower() == to_addr:
|
||||
return (file_path, to_addr)
|
||||
else:
|
||||
if filename == to_addr:
|
||||
return (file_path, to_addr)
|
||||
# support foo+ignore@bar.com -> foo@bar.com
|
||||
multi_email = re.match('^([^\+]+)\+([^@]+)@(.*)$', to_addr)
|
||||
if multi_email:
|
||||
fixed_up_email = "%s@%s" % (multi_email.group(1), multi_email.group(3))
|
||||
if verbose:
|
||||
log("Multi-email %s converted to %s" % (to_addr, fixed_up_email))
|
||||
return get_cert_for_email(fixed_up_email)
|
||||
|
||||
return None
|
||||
|
||||
def get_bool_from_cfg( section, key = None, evaluation = None ):
|
||||
|
||||
if not (key is None) and not (evaluation is None):
|
||||
return section in cfg and cfg[section].get(key) == evaluation
|
||||
|
||||
elif not (key is None) and (evaluation is None):
|
||||
return section in cfg and not (cfg[section].get(key) is None)
|
||||
|
||||
else:
|
||||
return section in cfg
|
||||
|
||||
def sanitize_case_sense( address ):
|
||||
|
||||
if get_bool_from_cfg('default', 'mail_case_insensitive', 'yes'):
|
||||
address = address.lower()
|
||||
else:
|
||||
if isinstance(address, str):
|
||||
sep = '@'
|
||||
else:
|
||||
sep = b'@'
|
||||
splitted_address = address.split(sep)
|
||||
if len(splitted_address) > 1:
|
||||
address = splitted_address[0] + sep + splitted_address[1].lower()
|
||||
|
||||
return address
|
||||
|
||||
def generate_message_from_payloads( payloads, message = None ):
|
||||
|
||||
if message == None:
|
||||
message = email.mime.multipart.MIMEMultipart(payloads.get_content_subtype())
|
||||
|
||||
for payload in payloads.get_payload():
|
||||
if( isinstance(payload.get_payload(), list) ):
|
||||
message.attach(generate_message_from_payloads(payload))
|
||||
else:
|
||||
message.attach(payload)
|
||||
|
||||
return message
|
||||
|
||||
def get_first_payload( payloads ):
|
||||
|
||||
if payloads.is_multipart():
|
||||
return get_first_payload(payloads.get_payload(0))
|
||||
else:
|
||||
return payloads
|
||||
|
||||
def send_msg( message, recipients ):
|
||||
|
||||
recipients = [_f for _f in recipients if _f]
|
||||
if recipients:
|
||||
if not (get_bool_from_cfg('relay', 'host') and get_bool_from_cfg('relay', 'port')):
|
||||
log("Missing settings for relay. Sending email aborted.")
|
||||
return None
|
||||
log("Sending email to: <%s>" % '> <'.join( recipients ))
|
||||
relay = (cfg['relay']['host'], int(cfg['relay']['port']))
|
||||
smtp = smtplib.SMTP(relay[0], relay[1])
|
||||
if 'relay' in cfg and 'starttls' in cfg['relay'] and cfg['relay']['starttls'] == 'yes':
|
||||
smtp.starttls()
|
||||
smtp.sendmail( from_addr, recipients, message )
|
||||
else:
|
||||
log("No recipient found")
|
||||
|
||||
def sort_recipients( raw_message, from_addr, to_addrs ):
|
||||
|
||||
recipients_left = list()
|
||||
for recipient in to_addrs:
|
||||
recipients_left.append(sanitize_case_sense(recipient))
|
||||
|
||||
# There is no need for nested encryption
|
||||
first_payload = get_first_payload(raw_message)
|
||||
if first_payload.get_content_type() == 'application/pkcs7-mime':
|
||||
if verbose:
|
||||
log("Message is already encrypted with S/MIME. Encryption aborted.")
|
||||
send_msg(raw_message.as_string(), recipients_left)
|
||||
return
|
||||
|
||||
first_payload = first_payload.get_payload(decode=True)
|
||||
if b"-----BEGIN PGP MESSAGE-----" in first_payload and b"-----END PGP MESSAGE-----" in first_payload:
|
||||
if verbose:
|
||||
log("Message is already encrypted as PGP/INLINE. Encryption aborted.")
|
||||
send_msg(raw_message.as_string(), recipients_left)
|
||||
return
|
||||
|
||||
if raw_message.get_content_type() == 'multipart/encrypted':
|
||||
if verbose:
|
||||
log("Message is already encrypted. Encryption aborted.")
|
||||
send_msg(raw_message.as_string(), recipients_left)
|
||||
return
|
||||
|
||||
# Encrypt mails for recipients with known public PGP keys
|
||||
recipients_left = gpg_encrypt(raw_message, recipients_left)
|
||||
if recipients_left == list():
|
||||
return
|
||||
|
||||
# Encrypt mails for recipients with known S/MIME certificate
|
||||
recipients_left = smime_encrypt(raw_message, recipients_left)
|
||||
if recipients_left == list():
|
||||
return
|
||||
|
||||
# Send out mail to recipients which are left
|
||||
send_msg(raw_message.as_string(), recipients_left)
|
||||
|
||||
|
||||
# Let's start
|
||||
sort_recipients(raw_message, from_addr, to_addrs)
|
|
@ -1,57 +0,0 @@
|
|||
# Example configuration for Lacre logging. If you don't intend to change the
|
||||
# log format, you can just keep this file unchanged.
|
||||
|
||||
# HANDLERS:
|
||||
#
|
||||
# Two main targets for log entries are defined here: syslog and a plain text
|
||||
# log file. They are available as "handlers" named "syslog" and "lacrelog"
|
||||
# respectively.
|
||||
|
||||
[loggers]
|
||||
keys=root
|
||||
|
||||
[logger_root]
|
||||
level=NOTSET
|
||||
# Comma-separated handler names, see HANDLERS note at the top.
|
||||
handlers=syslog
|
||||
|
||||
[handlers]
|
||||
# Comma-separated handler names, see HANDLERS note at the top.
|
||||
keys=syslog
|
||||
|
||||
[formatters]
|
||||
keys=postfixfmt
|
||||
|
||||
#
|
||||
# By default, include messages from all log levels up to DEBUG.
|
||||
# However, productive systems may use something less verbose, like
|
||||
# WARN or even ERROR.
|
||||
#
|
||||
[handler_lacrelog]
|
||||
class=FileHandler
|
||||
level=DEBUG
|
||||
formatter=postfixfmt
|
||||
args=('test/logs/lacre.log', 'a+')
|
||||
|
||||
# You may want to change the second argument (handlers.SysLogHandler.LOG_MAIL)
|
||||
# to change the syslog facility used to record messages from Lacre.
|
||||
#
|
||||
# Options you can consider are "localX" facilities, available under names from
|
||||
# handlers.SysLogHandler.LOG_LOCAL0 to handlers.SysLogHandler.LOG_LOCAL7.
|
||||
#
|
||||
# Please refer to your syslog configuration for details on how to separate
|
||||
# records from different facilities.
|
||||
[handler_syslog]
|
||||
class=handlers.SysLogHandler
|
||||
level=INFO
|
||||
formatter=postfixfmt
|
||||
args=('/dev/log', handlers.SysLogHandler.LOG_MAIL)
|
||||
|
||||
#
|
||||
# Default Postfix log format.
|
||||
#
|
||||
[formatter_postfixfmt]
|
||||
format=%(asctime)s %(name)s[%(process)d]: %(message)s
|
||||
datefmt=%b %e %H:%M:%S
|
||||
style=%
|
||||
validate=True
|
|
@ -1,181 +0,0 @@
|
|||
[default]
|
||||
# Whether lacre should add a header after it has processed an email
|
||||
# This may be useful for debugging purposes
|
||||
add_header = yes
|
||||
|
||||
# Whether we should only encrypt emails if they are explicitly defined in
|
||||
# the key mappings below ([enc_keymap] section)
|
||||
# This means lacre won't automatically detect PGP recipients for encrypting
|
||||
enc_keymap_only = no
|
||||
|
||||
# Convert encrypted text/plain email to MIME-attached encrypt style.
|
||||
# (Default is to use older inline-style PGP encoding.)
|
||||
mime_conversion = yes
|
||||
|
||||
# RFC 2821 defines that the user part (User@domain.tld) of a mail address should be treated case sensitive.
|
||||
# However, in the real world this is ignored very often. This option disables the RFC 2821
|
||||
# compatibility so both the user part and the domain part are treated case insensitive.
|
||||
# Disabling the compatibility is more convenient to users. So if you know that your
|
||||
# recipients all ignore the RFC you could this to yes.
|
||||
mail_case_insensitive = no
|
||||
|
||||
[gpg]
|
||||
# the directory where lacre public keys are stored
|
||||
# (see INSTALL for details)
|
||||
#
|
||||
# Note that this directory should be accessible only for the Lacre user,
|
||||
# i.e. have mode 700.
|
||||
keyhome = /var/lacre/.gnupg
|
||||
|
||||
[smime]
|
||||
# the directory for the S/MIME certificate files
|
||||
cert_path = /var/lacre/smime
|
||||
|
||||
[mailregister]
|
||||
# settings for the register-handler
|
||||
register_email = register@yourdomain.tld
|
||||
mail_templates = /var/lacre/register_templates
|
||||
|
||||
# URL to webpanel. Upon receiving an email with a key, register-handler
|
||||
# uploads it to the web panel.
|
||||
webpanel_url = http://yourdomain.tld
|
||||
|
||||
[cron]
|
||||
# settings for the cron job
|
||||
send_email = yes
|
||||
notification_email = lacre@yourdomain.tld
|
||||
mail_templates = /var/lacre/cron_templates
|
||||
|
||||
[logging]
|
||||
# path to the logging configuration; see documentation for details:
|
||||
# https://docs.python.org/3/library/logging.config.html#logging-config-fileformat
|
||||
config = /etc/lacre-logging.conf
|
||||
|
||||
[daemon]
|
||||
# Advanced Content Filter section.
|
||||
#
|
||||
# Advanced filters differ from Simple ones by providing a daemon that handles
|
||||
# requests, instead of starting a new process each time a message arrives.
|
||||
host = 127.0.0.1
|
||||
port = 10025
|
||||
|
||||
# Maximum size (in bytes) of message body, i.e. data provided after DATA
|
||||
# message. Following value comes from aiosmtpd module's default for this
|
||||
# setting.
|
||||
max_data_bytes = 33554432
|
||||
|
||||
# Sometimes it may make sense to log additional information from mail headers.
|
||||
# This should never be PII, but information like encoding, content types, etc.
|
||||
log_headers = no
|
||||
|
||||
# Sometimes we might fail to load keys and need to choose between delivering
|
||||
# in cleartext or not delivering. The default is to deliver cleartext, but
|
||||
# administrators can make this decision on their own.
|
||||
bounce_on_keys_missing = no
|
||||
|
||||
[relay]
|
||||
# the relay settings to use for Postfix
|
||||
# lacre will submit email to this relay after it is done processing
|
||||
# unless you alter the default Postfix configuration, you won't have to modify this
|
||||
host = 127.0.0.1
|
||||
port = 10028
|
||||
# This is the default port of postfix. It is used to send some
|
||||
# mails through the Lacre so they are encrypted
|
||||
enc_port = 25
|
||||
|
||||
# Set this option to yes to use TLS for SMTP Servers which require TLS.
|
||||
starttls = no
|
||||
|
||||
[smtp]
|
||||
# Options when smtp auth is required to send out emails
|
||||
enabled = false
|
||||
username = lacre
|
||||
password = changeme
|
||||
host = yourdomain.tld
|
||||
port = 587
|
||||
starttls = true
|
||||
|
||||
[database]
|
||||
# edit the settings below if you want to read keys from a
|
||||
# lacre-webgate database other than SQLite
|
||||
enabled = yes
|
||||
url = sqlite:///test.db
|
||||
|
||||
# Pooling mode: pessimistic or optimistic (required parameter).
|
||||
#
|
||||
# - Pessimistic disconnect-handling: pre_ping. Connection pool will try using
|
||||
# connection before it executes a SQL query to find out if the connection is
|
||||
# still alive. If not, it'll just establish a new connection.
|
||||
#
|
||||
# - Optimistic distonnect-handling: just avoid using connections after some
|
||||
# time.
|
||||
#
|
||||
pooling_mode = optimistic
|
||||
|
||||
# For a MySQL database "lacre", user "lacre" and password "password",
|
||||
# use the following URL:
|
||||
#
|
||||
#url = mysql://lacre:password@localhost/lacre
|
||||
#
|
||||
# For other RDBMS backends, see:
|
||||
# https://docs.sqlalchemy.org/en/14/core/engines.html#database-urls
|
||||
|
||||
# Number of seconds after which an idle connection is recycled. This is
|
||||
# useful with MySQL servers. This is only used with pooling_mode=optimistic.
|
||||
# For more information, see:
|
||||
# https://docs.sqlalchemy.org/en/14/core/engines.html#sqlalchemy.create_engine.params.pool_recycle
|
||||
#max_connection_age = 3600
|
||||
|
||||
# Number of connections stored in the pool.
|
||||
#pool_size = 5
|
||||
|
||||
# If the pool size is not enough for current traffic, some connections can be
|
||||
# made and closed after use, to avoid pool growth and connection rejections.
|
||||
#max_overflow = 10
|
||||
|
||||
# Number of hours we will wait for the user to confirm their email. Cron-job
|
||||
# will delete items older than this number of hours. Default: 1h.
|
||||
#max_queue_hours = 1
|
||||
|
||||
[enc_keymap]
|
||||
# You can find these by running the following command:
|
||||
# gpg --list-keys --keyid-format long user@example.com
|
||||
# Which will return output similar to:
|
||||
# pub 1024D/AAAAAAAAAAAAAAAA 2007-10-22
|
||||
# uid Joe User <user@example.com>
|
||||
# sub 2048g/BBBBBBBBBBBBBBBB 2007-10-22
|
||||
# You want the AAAAAAAAAAAAAAAA not BBBBBBBBBBBBBBBB.
|
||||
#you@domain.tld = 12345678
|
||||
|
||||
[enc_domain_keymap]
|
||||
# This seems to be similar to the [enc_keymap] section. However, you
|
||||
# can define default keys for a domain here. Entries in the enc_keymap
|
||||
# and individual keys stored on the system have a higher priority than
|
||||
# the default keys specified here.
|
||||
#
|
||||
#
|
||||
# You can find these by running the following command:
|
||||
# gpg --list-keys --keyid-format long user@example.com
|
||||
# Which will return output similar to:
|
||||
# pub 1024D/AAAAAAAAAAAAAAAA 2007-10-22
|
||||
# uid Joe User <user@example.com>
|
||||
# sub 2048g/BBBBBBBBBBBBBBBB 2007-10-22
|
||||
# You want the AAAAAAAAAAAAAAAA not BBBBBBBBBBBBBBBB.
|
||||
#domain.tld = 12345678
|
||||
|
||||
[dec_keymap]
|
||||
# You can find these by running the following command:
|
||||
# gpg --list-secret-keys --keyid-format long user@example.com
|
||||
# Which will return output similar to:
|
||||
# sec 1024D/AAAAAAAAAAAAAAAA 2007-10-22
|
||||
# uid Joe User <user@example.com>
|
||||
# ssb 2048g/BBBBBBBBBBBBBBBB 2007-10-22
|
||||
# You want the AAAAAAAAAAAAAAAA not BBBBBBBBBBBBBBBB.
|
||||
#you@domain.tld = 12345678
|
||||
|
||||
[pgp_style]
|
||||
# Here a PGP style (inline or PGP/MIME) could be defined for recipients.
|
||||
# This overwrites the setting mime_conversion for the defined recipients.
|
||||
# Valid entries are inline and mime
|
||||
# If an entry is not valid, the setting mime_conversion is used as fallback.
|
||||
#you@domian.tld = mime
|
80
lacre.py
80
lacre.py
|
@ -1,80 +0,0 @@
|
|||
#!/usr/bin/python
|
||||
#
|
||||
# lacre
|
||||
#
|
||||
# This file is part of the lacre source code.
|
||||
#
|
||||
# lacre is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# lacre source code is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with lacre source code. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
import email
|
||||
from email.policy import SMTPUTF8
|
||||
import sys
|
||||
import logging
|
||||
|
||||
import lacre
|
||||
import lacre.config as conf
|
||||
from lacre.stats import time_logger
|
||||
|
||||
conf.load_config()
|
||||
lacre.init_logging(conf.get_item('logging', 'config'))
|
||||
|
||||
# This has to be executed *after* logging initialisation.
|
||||
import lacre.core as core
|
||||
from lacre.lazymessage import LazyMessage
|
||||
|
||||
LOG = logging.getLogger('lacre.py')
|
||||
|
||||
def main():
|
||||
with time_logger('Message delivery', LOG):
|
||||
missing_params = conf.validate_config()
|
||||
config_file = conf.config_source()
|
||||
|
||||
if missing_params:
|
||||
LOG.error(f"Aborting delivery! Following mandatory config parameters are missing in {config_file!r}: {missing_params}")
|
||||
sys.exit(lacre.EX_CONFIG)
|
||||
|
||||
delivered = False
|
||||
raw_message = None
|
||||
|
||||
# Read recipients from the command-line
|
||||
to_addrs = sys.argv[1:]
|
||||
|
||||
# Read e-mail from stdin, parse it
|
||||
raw = sys.stdin.read()
|
||||
raw_message = email.message_from_string(raw, policy=SMTPUTF8)
|
||||
from_addr = raw_message['From']
|
||||
|
||||
lmessage = LazyMessage(to_addrs, lambda: raw_message)
|
||||
|
||||
try:
|
||||
# Let's start
|
||||
core.deliver_message(raw_message, from_addr, to_addrs)
|
||||
delivered = True
|
||||
except:
|
||||
LOG.exception('Could not handle message')
|
||||
|
||||
if not delivered:
|
||||
# It seems we weren't able to deliver the message. In case it was
|
||||
# some silly message-encoding issue that shouldn't bounce the
|
||||
# message, we just try recoding the message body and delivering it.
|
||||
try:
|
||||
from_addr = raw_message['From']
|
||||
core.failover_delivery(raw_message, to_addrs, from_addr)
|
||||
except:
|
||||
LOG.exception('Failover delivery failed too')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
|
@ -1,51 +0,0 @@
|
|||
"""Lacre --- the Postfix mail filter encrypting incoming email
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.config
|
||||
|
||||
# Following structure configures logging iff a file-based configuration cannot
|
||||
# be performed. It only sets up a syslog handler, so that the admin has at
|
||||
# least some basic information.
|
||||
FAIL_OVER_LOGGING_CONFIG = {
|
||||
'version': 1,
|
||||
'formatters': {
|
||||
'sysfmt': {
|
||||
'format': '%(asctime)s %(module)s %(message)s',
|
||||
'datefmt': '%Y-%m-%d %H:%M:%S'
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'syslog': {
|
||||
'class': 'logging.handlers.SysLogHandler',
|
||||
'level': 'INFO',
|
||||
'formatter': 'sysfmt'
|
||||
},
|
||||
'lacrelog': {
|
||||
'class': 'logging.FileHandler',
|
||||
'level': 'INFO',
|
||||
'formatter': 'sysfmt',
|
||||
'filename': 'lacre.log'
|
||||
}
|
||||
},
|
||||
'root': {
|
||||
'level': 'INFO',
|
||||
'handlers': ['syslog', 'lacrelog']
|
||||
}
|
||||
}
|
||||
|
||||
# Exit code taken from <sysexits.h>:
|
||||
EX_UNAVAILABLE = 69
|
||||
EX_TEMPFAIL = 75
|
||||
EX_CONFIG = 78
|
||||
|
||||
|
||||
def init_logging(config_filename):
|
||||
if config_filename is not None:
|
||||
logging.config.fileConfig(config_filename)
|
||||
logging.captureWarnings(True)
|
||||
logging.info('Configured from %s', config_filename)
|
||||
else:
|
||||
logging.config.dictConfig(FAIL_OVER_LOGGING_CONFIG)
|
||||
logging.captureWarnings(True)
|
||||
logging.warning('Lacre logging configuration missing, using syslog as default')
|
|
@ -1,63 +0,0 @@
|
|||
class KeyCache:
|
||||
"""A store for OpenPGP keys.
|
||||
|
||||
Key case is sanitised while loading from GnuPG if so
|
||||
configured. See mail_case_insensitive parameter in section
|
||||
[default].
|
||||
"""
|
||||
|
||||
def __init__(self, keys: dict = None):
|
||||
"""Initialise an empty cache.
|
||||
|
||||
With keyring_dir given, set location of the directory from which keys should be loaded.
|
||||
"""
|
||||
self._keys = keys
|
||||
|
||||
def __getitem__(self, fingerpring):
|
||||
"""Look up email assigned to the given fingerprint."""
|
||||
return self._keys[fingerpring]
|
||||
|
||||
def __setitem__(self, fingerprint, email):
|
||||
"""Assign an email to a fingerpring, overwriting it if it was already present."""
|
||||
self._keys[fingerprint] = email
|
||||
|
||||
def __contains__(self, fingerprint):
|
||||
"""Check if the given fingerprint is assigned to an email."""
|
||||
# This method has to be present for KeyCache to be a dict substitute.
|
||||
# See mailgate, function _identify_gpg_recipients.
|
||||
return fingerprint in self._keys
|
||||
|
||||
def has_email(self, email):
|
||||
"""Check if cache contains a key assigned to the given email."""
|
||||
return email in self._keys.values()
|
||||
|
||||
def __repr__(self):
|
||||
"""Return text representation of this object."""
|
||||
details = ' '.join(self._keys.keys())
|
||||
return '<KeyCache %s>' % (details)
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._keys.keys())
|
||||
|
||||
def emails(self):
|
||||
return { email: fingerprint for (fingerprint, email) in self._keys.items() }
|
||||
|
||||
|
||||
class KeyRing:
|
||||
"""Contract to be implemented by a key-store (a.k.a. keyring)."""
|
||||
|
||||
def freeze_identities(self) -> KeyCache:
|
||||
"""Return a static, async-safe copy of the identity map."""
|
||||
raise NotImplementedError('KeyRing.load not implemented')
|
||||
|
||||
def register_or_update(self, email: str, key_id: str):
|
||||
"""Add a new (email,key) pair to the keystore."""
|
||||
raise NotImplementedError('KeyRing.register_or_update not implemented')
|
||||
|
||||
def post_init_hook(self):
|
||||
"""Lets the keyring perform additional operations following its initialisation."""
|
||||
pass
|
||||
|
||||
def shutdown(self):
|
||||
"""Lets the keyring perform operations prior to shutting down."""
|
||||
pass
|
168
lacre/admin.py
168
lacre/admin.py
|
@ -1,168 +0,0 @@
|
|||
"""Lacre administrative tool.
|
||||
|
||||
This is a command-line tool expected to be run by a person who knows what they
|
||||
are doing. Also, please read the docs first.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
import GnuPG
|
||||
import lacre
|
||||
import lacre.config as conf
|
||||
|
||||
conf.load_config()
|
||||
lacre.init_logging(conf.get_item('logging', 'config'))
|
||||
|
||||
import lacre.repositories as repo
|
||||
import lacre.dbschema as db
|
||||
|
||||
if __name__ == '__main__':
|
||||
LOG = logging.getLogger('lacre.admin')
|
||||
else:
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _no_database():
|
||||
print('Database unavailable or not configured properly')
|
||||
sys.exit(lacre.EX_CONFIG)
|
||||
|
||||
|
||||
def sub_db(args):
|
||||
"""Sub-command to manipulate database."""
|
||||
LOG.debug('Database operations ahead')
|
||||
|
||||
if args.init:
|
||||
eng = repo.init_engine(conf.get_item('database', 'url'))
|
||||
LOG.warning('Initialising database schema with engine: %s', eng)
|
||||
print('Creating database tables')
|
||||
db.create_tables(eng)
|
||||
|
||||
|
||||
def sub_queue(args):
|
||||
"""Sub-command to inspect queue contents."""
|
||||
LOG.debug('Inspecting queue...')
|
||||
|
||||
eng = repo.init_engine(conf.get_item('database', 'url'))
|
||||
queue = repo.KeyConfirmationQueue(engine=eng)
|
||||
|
||||
if args.delete:
|
||||
queue.delete_key_by_email(args.delete)
|
||||
elif args.list:
|
||||
for k in queue.fetch_keys():
|
||||
print(f'- {k.id}: {k.email}')
|
||||
elif args.to_delete:
|
||||
for k in queue.fetch_keys_to_delete():
|
||||
print(f'- {k.id}: {k.email}')
|
||||
else:
|
||||
cnt = queue.count_keys()
|
||||
if cnt is None:
|
||||
_no_database()
|
||||
|
||||
print(f'Keys in the queue: {cnt}')
|
||||
|
||||
|
||||
def sub_identities(args):
|
||||
"""Sub-command to inspect identity database."""
|
||||
LOG.debug('Inspecting identities...')
|
||||
|
||||
eng = repo.init_engine(conf.get_item('database', 'url'))
|
||||
identities = repo.IdentityRepository(engine=eng)
|
||||
|
||||
all_identities = identities.freeze_identities()
|
||||
if all_identities is None:
|
||||
_no_database()
|
||||
|
||||
if args.email:
|
||||
all_rev = all_identities.emails()
|
||||
print('-', args.email, all_rev[args.email])
|
||||
else:
|
||||
for id_ in all_identities:
|
||||
print('-', all_identities[id_], id_)
|
||||
|
||||
|
||||
def sub_import(args):
|
||||
"""Sub-command to import all identities known to GnuPG into Lacre database."""
|
||||
LOG.debug('Importing identities...')
|
||||
source_dir = args.homedir or conf.get_item('gpg', 'keyhome')
|
||||
|
||||
public = GnuPG.public_keys(source_dir)
|
||||
|
||||
eng = repo.init_engine(conf.get_item('database', 'url'))
|
||||
identities = repo.IdentityRepository(engine=eng)
|
||||
|
||||
if args.reload:
|
||||
identities.delete_all()
|
||||
|
||||
total = 0
|
||||
for (fingerprint, email) in public.items():
|
||||
LOG.debug('Importing %s - %s', email, fingerprint)
|
||||
identities.register_or_update(email, fingerprint)
|
||||
total += 1
|
||||
|
||||
LOG.debug('Imported %d identities', total)
|
||||
print(f'Imported {total} identities')
|
||||
|
||||
|
||||
def main():
|
||||
missing = conf.validate_config()
|
||||
if missing:
|
||||
LOG.error('Missing configuration parameters: %s', missing)
|
||||
print('Insufficient configuration, aborting.')
|
||||
sys.exit(lacre.EX_CONFIG)
|
||||
|
||||
general_conf = conf.config_source()
|
||||
log_conf = conf.get_item('logging', 'config')
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog='lacre.admin',
|
||||
description='Lacre Admin\'s best friend',
|
||||
epilog=f'Config read from {general_conf}. For diagnostic info, see {log_conf}'
|
||||
)
|
||||
|
||||
sub_commands = parser.add_subparsers(help='Sub-commands', required=True)
|
||||
|
||||
cmd_db = sub_commands.add_parser('database',
|
||||
help='',
|
||||
aliases=['db']
|
||||
)
|
||||
cmd_db.add_argument('-i', '--init', action='store_true',
|
||||
help='Initialise database schema')
|
||||
cmd_db.set_defaults(operation=sub_db)
|
||||
|
||||
cmd_import = sub_commands.add_parser('import',
|
||||
help='Load identities from GnuPG directory to Lacre database'
|
||||
)
|
||||
cmd_import.add_argument('-d', '--homedir', default=False,
|
||||
help='specify GnuPG directory (default: use configured dir.)')
|
||||
cmd_import.add_argument('-r', '--reload', action='store_true',
|
||||
help='delete all keys from database before importing')
|
||||
cmd_import.set_defaults(operation=sub_import)
|
||||
|
||||
cmd_queue = sub_commands.add_parser('queue',
|
||||
help='Inspect key queue',
|
||||
aliases=['q']
|
||||
)
|
||||
cmd_queue.add_argument('-D', '--delete',
|
||||
help='delete specified email from the queue')
|
||||
cmd_queue.add_argument('-l', '--list', action='store_true',
|
||||
help='list keys in the queue')
|
||||
cmd_queue.add_argument('-d', '--to-delete', action='store_true',
|
||||
help='list keys to be deleted')
|
||||
cmd_queue.set_defaults(operation=sub_queue)
|
||||
|
||||
cmd_identities = sub_commands.add_parser('identities',
|
||||
help='Inspect identity database',
|
||||
aliases=['id']
|
||||
)
|
||||
cmd_identities.add_argument('-e', '--email', help='look up a single email')
|
||||
cmd_identities.set_defaults(operation=sub_identities)
|
||||
|
||||
user_request = parser.parse_args()
|
||||
|
||||
user_request.operation(user_request)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
191
lacre/config.py
191
lacre/config.py
|
@ -1,191 +0,0 @@
|
|||
"""Lacre configuration.
|
||||
|
||||
Routines defined here are responsible for processing and validating
|
||||
configuration.
|
||||
"""
|
||||
|
||||
from enum import Enum, auto
|
||||
from configparser import RawConfigParser
|
||||
from collections import namedtuple
|
||||
|
||||
import os
|
||||
|
||||
|
||||
# Environment variable name we read to retrieve configuration path. This is to
|
||||
# enable non-root users to set up and run Lacre and to make the software
|
||||
# testable.
|
||||
CONFIG_PATH_ENV = "LACRE_CONFIG"
|
||||
|
||||
# List of mandatory configuration parameters. Each item on this list should be
|
||||
# a pair: a section name and a parameter name.
|
||||
MANDATORY_CONFIG_ITEMS = [("relay", "host"),
|
||||
("relay", "port"),
|
||||
("daemon", "host"),
|
||||
("daemon", "port"),
|
||||
("gpg", "keyhome"),
|
||||
('database', 'enabled'),
|
||||
('database', 'url'),
|
||||
('database', 'pooling_mode')]
|
||||
|
||||
CRON_REQUIRED = [('cron', 'mail_templates')]
|
||||
|
||||
# Global dict to keep configuration parameters. It's hidden behind several
|
||||
# utility functions to make it easy to replace it with ConfigParser object in
|
||||
# the future.
|
||||
cfg = dict()
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
"""Parse configuration file.
|
||||
|
||||
If environment variable identified by CONFIG_PATH_ENV
|
||||
variable is set, its value is taken as a configuration file
|
||||
path. Otherwise, the default is taken
|
||||
('/etc/lacre.conf').
|
||||
"""
|
||||
config_file = config_source()
|
||||
|
||||
parser = _read_config(config_file)
|
||||
|
||||
# XXX: Global variable. It is a left-over from old GPG-Mailgate code. We
|
||||
# should drop it and probably use ConfigParser instance where configuration
|
||||
# parameters are needed.
|
||||
global cfg
|
||||
cfg = _copy_to_dict(parser)
|
||||
return cfg
|
||||
|
||||
|
||||
def config_source() -> str:
|
||||
"""Return path of configuration file.
|
||||
|
||||
Taken from LACRE_CONFIG environment variable, and if it's not
|
||||
set, defaults to /etc/lacre.conf."""
|
||||
return os.getenv(CONFIG_PATH_ENV, '/etc/lacre.conf')
|
||||
|
||||
|
||||
def _read_config(fileName) -> RawConfigParser:
|
||||
cp = RawConfigParser()
|
||||
cp.read(fileName)
|
||||
|
||||
return cp
|
||||
|
||||
|
||||
def _copy_to_dict(confParser) -> dict:
|
||||
config = dict()
|
||||
|
||||
for sect in confParser.sections():
|
||||
config[sect] = dict()
|
||||
for (name, value) in confParser.items(sect):
|
||||
config[sect][name] = value
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def get_item(section, key, empty_value=None):
|
||||
global cfg
|
||||
if config_item_set(section, key):
|
||||
return cfg[section][key]
|
||||
else:
|
||||
return empty_value
|
||||
|
||||
|
||||
def has_section(section) -> bool:
|
||||
return section in cfg
|
||||
|
||||
|
||||
def config_item_set(section, key) -> bool:
|
||||
return section in cfg and (key in cfg[section]) and not (cfg[section][key] is None)
|
||||
|
||||
|
||||
def config_item_equals(section, key, value) -> bool:
|
||||
return section in cfg and key in cfg[section] and cfg[section][key] == value
|
||||
|
||||
|
||||
def flag_enabled(section, key) -> bool:
|
||||
return config_item_equals(section, key, 'yes')
|
||||
|
||||
|
||||
def validate_config(*, additional=None):
|
||||
"""Check if configuration is complete.
|
||||
|
||||
Returns a list of missing parameters, so an empty list means
|
||||
configuration is complete.
|
||||
|
||||
If 'additional' parameter is specified, it should be a list of
|
||||
tuples (section, param).
|
||||
"""
|
||||
missing = []
|
||||
for (section, param) in MANDATORY_CONFIG_ITEMS:
|
||||
if not config_item_set(section, param):
|
||||
missing.append((section, param))
|
||||
if additional:
|
||||
for (section, param) in additional:
|
||||
if not config_item_set(section, param):
|
||||
missing.append((section, param))
|
||||
return missing
|
||||
|
||||
|
||||
#
|
||||
# High level access to configuration.
|
||||
#
|
||||
|
||||
Host = namedtuple('Host', ['name', 'port'])
|
||||
|
||||
def relay_params() -> Host:
|
||||
"""Return a Host named tuple identifying the mail relay."""
|
||||
return Host(name = cfg["relay"]["host"], port = int(cfg["relay"]["port"]))
|
||||
|
||||
|
||||
def daemon_params():
|
||||
"""Return a (HOST, PORT) tuple to setup a server socket for Lacre daemon."""
|
||||
return (cfg["daemon"]["host"], int(cfg["daemon"]["port"]))
|
||||
|
||||
|
||||
def strict_mode():
|
||||
"""Check if Lacre is configured to support only a fixed list of keys."""
|
||||
return ("default" in cfg and cfg["default"]["enc_keymap_only"] == "yes")
|
||||
|
||||
|
||||
def should_log_headers() -> bool:
|
||||
"""Check if Lacre should log message headers."""
|
||||
return flag_enabled('daemon', 'log_headers')
|
||||
|
||||
|
||||
class FromStrMixin:
|
||||
"""Additional operations for configuration enums."""
|
||||
|
||||
@classmethod
|
||||
def from_str(cls, name, *, required=False):
|
||||
if name is None:
|
||||
return None
|
||||
|
||||
name = name.upper()
|
||||
|
||||
if name in cls.__members__:
|
||||
return cls.__members__[name]
|
||||
|
||||
if required:
|
||||
raise NameError('Unsupported or missing value')
|
||||
else:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, section, key, *, required=False):
|
||||
param = get_item(section, key)
|
||||
return cls.from_str(param, required=required)
|
||||
|
||||
|
||||
class PGPStyle(FromStrMixin, Enum):
|
||||
"""PGP message structure: PGP/Inline or PGP/MIME."""
|
||||
MIME = auto()
|
||||
INLINE = auto()
|
||||
|
||||
|
||||
class PoolingMode(FromStrMixin, Enum):
|
||||
"""Database connection pool behaviour.
|
||||
|
||||
- Optimistic - recycles connections.
|
||||
- Pessimistic - checks connection before usage.
|
||||
"""
|
||||
OPTIMISTIC = auto()
|
||||
PESSIMISTIC = auto()
|
417
lacre/core.py
417
lacre/core.py
|
@ -1,417 +0,0 @@
|
|||
#
|
||||
# lacre
|
||||
#
|
||||
# This file is part of the lacre source code.
|
||||
#
|
||||
# lacre is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# lacre source code is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with lacre source code. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
"""Lacre's actual mail-delivery module.
|
||||
|
||||
IMPORTANT: This module has to be loaded _after_ initialisation of the logging
|
||||
module.
|
||||
"""
|
||||
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
import copy
|
||||
import email
|
||||
from email.message import EmailMessage, MIMEPart
|
||||
import email.utils
|
||||
from email.policy import SMTPUTF8
|
||||
import GnuPG
|
||||
import asyncio
|
||||
from typing import Tuple
|
||||
|
||||
import logging
|
||||
import lacre.text as text
|
||||
import lacre.config as conf
|
||||
import lacre.keyring as kcache
|
||||
import lacre.recipients as recpt
|
||||
import lacre.smime as smime
|
||||
from lacre.transport import send_msg, register_sender, SendFrom
|
||||
from lacre.mailop import KeepIntact, InlineOpenPGPEncrypt, MimeOpenPGPEncrypt, MailSerialisationException
|
||||
from lacre.lazymessage import LazyMessage
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _gpg_encrypt(raw_message, recipients):
|
||||
if not conf.config_item_set('gpg', 'keyhome'):
|
||||
LOG.error("No valid entry for gpg keyhome. Encryption aborted.")
|
||||
return recipients
|
||||
|
||||
gpg_recipients, cleartext_recipients = \
|
||||
recpt.identify_gpg_recipients(recipients, kcache.freeze_and_load_keys())
|
||||
|
||||
LOG.info(f"Got addresses: gpg_to={gpg_recipients!r}, ungpg_to={cleartext_recipients!r}")
|
||||
|
||||
if gpg_recipients:
|
||||
LOG.info("Encrypting email to: %s", gpg_recipients)
|
||||
|
||||
mime, inline = _sort_gpg_recipients(gpg_recipients)
|
||||
|
||||
if mime:
|
||||
# Encrypt mail with PGP/MIME
|
||||
_gpg_encrypt_and_deliver(raw_message,
|
||||
mime.keys(), mime.emails(),
|
||||
_encrypt_all_payloads_mime)
|
||||
|
||||
if inline:
|
||||
# Encrypt mail with PGP/INLINE
|
||||
_gpg_encrypt_and_deliver(raw_message,
|
||||
inline.keys(), inline.emails(),
|
||||
_encrypt_all_payloads_inline)
|
||||
|
||||
LOG.info('Not processed emails: %s', cleartext_recipients)
|
||||
return cleartext_recipients
|
||||
|
||||
|
||||
def _sort_gpg_recipients(gpg_to) -> Tuple[recpt.RecipientList, recpt.RecipientList]:
|
||||
recipients_mime = list()
|
||||
keys_mime = list()
|
||||
|
||||
recipients_inline = list()
|
||||
keys_inline = list()
|
||||
|
||||
default_to_pgp_mime = conf.flag_enabled('default', 'mime_conversion')
|
||||
|
||||
for rcpt in gpg_to:
|
||||
# Checking pre defined styles in settings first
|
||||
style = conf.PGPStyle.from_config('pgp_style', rcpt.email())
|
||||
if style is conf.PGPStyle.MIME:
|
||||
recipients_mime.append(rcpt.email())
|
||||
keys_mime.extend(rcpt.key().split(','))
|
||||
elif style is conf.PGPStyle.INLINE:
|
||||
recipients_inline.append(rcpt.email())
|
||||
keys_inline.extend(rcpt.key().split(','))
|
||||
else:
|
||||
# Log message only if an unknown style is defined
|
||||
if conf.config_item_set('pgp_style', rcpt.email()):
|
||||
LOG.debug("Style %s for recipient %s is not known. Use default as fallback.",
|
||||
conf.get_item("pgp_style", rcpt.email()), rcpt.email())
|
||||
|
||||
# If no style is in settings defined for recipient, use default from settings
|
||||
if default_to_pgp_mime:
|
||||
recipients_mime.append(rcpt.email())
|
||||
keys_mime.extend(rcpt.key().split(','))
|
||||
else:
|
||||
recipients_inline.append(rcpt.email())
|
||||
keys_inline.extend(rcpt.key().split(','))
|
||||
|
||||
mime = recpt.RecipientList(recipients_mime, keys_mime)
|
||||
inline = recpt.RecipientList(recipients_inline, keys_inline)
|
||||
|
||||
LOG.debug('Loaded recipients: MIME %s; Inline %s', repr(mime), repr(inline))
|
||||
|
||||
return mime, inline
|
||||
|
||||
|
||||
def _gpg_encrypt_copy(message: EmailMessage, keys, recipients, encrypt_f, lmessage: LazyMessage = None) -> EmailMessage:
|
||||
if lmessage:
|
||||
message = lmessage.get_message()
|
||||
|
||||
msg_copy = copy.deepcopy(message)
|
||||
_customise_headers(msg_copy)
|
||||
encrypted_payloads = encrypt_f(msg_copy, keys)
|
||||
msg_copy.set_payload(encrypted_payloads)
|
||||
return msg_copy
|
||||
|
||||
|
||||
def _gpg_encrypt_to_bytes(message: EmailMessage, keys, recipients, encrypt_f, lmessage) -> bytes:
|
||||
msg_copy = _gpg_encrypt_copy(message, keys, recipients, encrypt_f, lmessage)
|
||||
try:
|
||||
return msg_copy.as_bytes(policy=SMTPUTF8)
|
||||
except IndexError as ie:
|
||||
raise MailSerialisationException(ie)
|
||||
|
||||
|
||||
def _gpg_encrypt_to_str(message: EmailMessage, keys, recipients, encrypt_f) -> str:
|
||||
msg_copy = _gpg_encrypt_copy(message, keys, recipients, encrypt_f)
|
||||
return msg_copy.as_string(policy=SMTPUTF8)
|
||||
|
||||
|
||||
def _gpg_encrypt_and_deliver(message: EmailMessage, keys, recipients, encrypt_f):
|
||||
out = _gpg_encrypt_to_str(message, keys, recipients, encrypt_f)
|
||||
send_msg(out, recipients)
|
||||
|
||||
|
||||
def _customise_headers(message: EmailMessage):
|
||||
if conf.flag_enabled('default', 'add_header'):
|
||||
message['X-Lacre'] = 'Encrypted by Lacre'
|
||||
|
||||
|
||||
def _encrypt_all_payloads_inline(message: EmailMessage, gpg_to_cmdline, lmessage: LazyMessage = None):
|
||||
if lmessage:
|
||||
message = lmessage.get_message()
|
||||
|
||||
# This breaks cascaded MIME messages. Blame PGP/INLINE.
|
||||
encrypted_payloads = list()
|
||||
if isinstance(message.get_payload(), str):
|
||||
return _encrypt_payload(message, gpg_to_cmdline).get_payload()
|
||||
|
||||
for payload in message.get_payload():
|
||||
if(isinstance(payload.get_payload(), list)):
|
||||
encrypted_payloads.extend(_encrypt_all_payloads_inline(payload, gpg_to_cmdline))
|
||||
else:
|
||||
encrypted_payloads.append(_encrypt_payload(payload, gpg_to_cmdline))
|
||||
|
||||
return encrypted_payloads
|
||||
|
||||
|
||||
def _encrypt_all_payloads_mime(message: EmailMessage, gpg_to_cmdline, lmessage: LazyMessage = None):
|
||||
# Convert a plain text email into PGP/MIME attachment style. Modeled after enigmail.
|
||||
pgp_ver_part = MIMEPart()
|
||||
pgp_ver_part.set_content('Version: 1' + text.EOL_S)
|
||||
pgp_ver_part.set_type("application/pgp-encrypted")
|
||||
pgp_ver_part.set_param('PGP/MIME version identification', "", 'Content-Description')
|
||||
|
||||
encrypted_part = MIMEPart()
|
||||
encrypted_part.set_type("application/octet-stream")
|
||||
encrypted_part.set_param('name', "encrypted.asc")
|
||||
encrypted_part.set_param('OpenPGP encrypted message', "", 'Content-Description')
|
||||
encrypted_part.set_param('inline', "", 'Content-Disposition')
|
||||
encrypted_part.set_param('filename', "encrypted.asc", 'Content-Disposition')
|
||||
|
||||
if lmessage:
|
||||
message = lmessage.get_message()
|
||||
|
||||
message.preamble = "This is an OpenPGP/MIME encrypted message (RFC 2440 and 3156)"
|
||||
|
||||
boundary = _make_boundary()
|
||||
|
||||
if isinstance(message.get_payload(), str):
|
||||
LOG.debug('Rewrapping a flat, text-only message')
|
||||
wrapped_payload = _rewrap_payload(message)
|
||||
encrypted_part.set_payload(wrapped_payload.as_string())
|
||||
|
||||
_set_type_and_boundary(message, boundary)
|
||||
|
||||
check_nested = True
|
||||
else:
|
||||
processed_payloads = _generate_message_from_payloads(message)
|
||||
encrypted_part.set_payload(processed_payloads.as_string())
|
||||
|
||||
_set_type_and_boundary(message, boundary)
|
||||
|
||||
check_nested = False
|
||||
|
||||
return [pgp_ver_part, _encrypt_payload(encrypted_part, gpg_to_cmdline, check_nested)]
|
||||
|
||||
|
||||
def _rewrap_payload(message: EmailMessage, lmessage: LazyMessage = None) -> MIMEPart:
|
||||
# In PGP/MIME (RFC 3156), the payload has to be a valid MIME entity. In
|
||||
# other words, we need to wrap text/* message's payload in a new MIME
|
||||
# entity.
|
||||
|
||||
wrapper = MIMEPart(policy=SMTPUTF8)
|
||||
if lmessage:
|
||||
message = lmessage.get_message()
|
||||
content = message.get_content()
|
||||
wrapper.set_content(content)
|
||||
|
||||
wrapper.set_type(message.get_content_type())
|
||||
|
||||
# Copy all Content-Type parameters.
|
||||
for (pname, pvalue) in message.get_params(failobj=list()):
|
||||
# Skip MIME type that's also returned by get_params().
|
||||
if not '/' in pname:
|
||||
wrapper.set_param(pname, pvalue)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def _make_boundary():
|
||||
junk_msg = MIMEMultipart()
|
||||
# XXX See EmailTest.test_boundary_generated_after_as_string_call.
|
||||
_ = junk_msg.as_string()
|
||||
return junk_msg.get_boundary()
|
||||
|
||||
|
||||
def _set_type_and_boundary(message: EmailMessage, boundary):
|
||||
message.set_type('multipart/encrypted')
|
||||
message.set_param('protocol', 'application/pgp-encrypted')
|
||||
message.set_param('boundary', boundary)
|
||||
|
||||
|
||||
def _encrypt_payload(payload: EmailMessage, recipients, check_nested=True, lmessage: LazyMessage = None, **kwargs):
|
||||
if lmessage:
|
||||
payload = lmessage.get_message()
|
||||
raw_payload = payload.get_payload(decode=True)
|
||||
LOG.debug('About to encrypt raw payload: %s', raw_payload)
|
||||
LOG.debug('Original message: %s', payload)
|
||||
|
||||
if check_nested and text.is_payload_pgp_inline(raw_payload):
|
||||
LOG.debug("Message is already pgp encrypted. No nested encryption needed.")
|
||||
return payload
|
||||
|
||||
gpg = _make_encryptor(raw_payload, recipients)
|
||||
|
||||
gpg.update(raw_payload)
|
||||
encrypted_data, exit_code = gpg.encrypt()
|
||||
|
||||
payload.set_payload(encrypted_data)
|
||||
isAttachment = payload.get_param('attachment', None, 'Content-Disposition') is not None
|
||||
|
||||
if isAttachment:
|
||||
_append_gpg_extension(payload)
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def _make_encryptor(raw_data, recipients):
|
||||
# No check is needed for conf.get_item('gpg', 'keyhome') as this is already
|
||||
# done in method gpg_encrypt
|
||||
keyhome = conf.get_item('gpg', 'keyhome')
|
||||
|
||||
if isinstance(raw_data, str):
|
||||
return GnuPG.GPGEncryptor(keyhome, recipients, 'utf-8')
|
||||
else:
|
||||
return GnuPG.GPGEncryptor(keyhome, recipients)
|
||||
|
||||
|
||||
def _append_gpg_extension(attachment):
|
||||
filename = attachment.get_filename()
|
||||
if not filename:
|
||||
return
|
||||
|
||||
pgpFilename = filename + ".pgp"
|
||||
|
||||
# Attachment name can come from one of two places: Content-Disposition or
|
||||
# Content-Type header, hence the two cases below.
|
||||
|
||||
if not (attachment.get('Content-Disposition') is None):
|
||||
attachment.set_param('filename', pgpFilename, 'Content-Disposition')
|
||||
|
||||
if not (attachment.get('Content-Type') is None) and not (attachment.get_param('name') is None):
|
||||
attachment.set_param('name', pgpFilename)
|
||||
|
||||
|
||||
def _generate_message_from_payloads(payloads, message=None):
|
||||
if message is None:
|
||||
message = email.mime.multipart.MIMEMultipart(payloads.get_content_subtype())
|
||||
|
||||
for payload in payloads.get_payload():
|
||||
if(isinstance(payload.get_payload(), list)):
|
||||
message.attach(_generate_message_from_payloads(payload))
|
||||
else:
|
||||
message.attach(payload)
|
||||
|
||||
return message
|
||||
|
||||
|
||||
def _get_first_payload(payloads):
|
||||
if payloads.is_multipart():
|
||||
return _get_first_payload(payloads.get_payload(0))
|
||||
else:
|
||||
return payloads
|
||||
|
||||
|
||||
def _recode(m: EmailMessage):
|
||||
payload = m.get_payload()
|
||||
m.set_content(payload)
|
||||
|
||||
|
||||
def failover_delivery(message: EmailMessage, recipients, from_address):
|
||||
"""Try delivering message just one last time."""
|
||||
LOG.debug('Failover delivery')
|
||||
|
||||
send = SendFrom(from_address)
|
||||
if message.get_content_maintype() == 'text':
|
||||
LOG.debug('Flat text message, adjusting coding')
|
||||
_recode(message)
|
||||
b = message.as_bytes(policy=SMTPUTF8)
|
||||
send(b, recipients)
|
||||
elif message.get_content_maintype() == 'multipart':
|
||||
LOG.debug('Multipart message, adjusting coding of text entities')
|
||||
for part in message.iter_parts():
|
||||
if part.get_content_maintype() == 'text':
|
||||
_recode(part)
|
||||
b = message.as_bytes(policy=SMTPUTF8)
|
||||
send(b, recipients)
|
||||
else:
|
||||
LOG.warning('No failover strategy, giving up')
|
||||
|
||||
|
||||
def _is_encrypted(raw_message: EmailMessage, lmessage: LazyMessage = None):
|
||||
if lmessage:
|
||||
raw_message = lmessage.get_message()
|
||||
if raw_message.get_content_type() == 'multipart/encrypted':
|
||||
return True
|
||||
|
||||
first_part = _get_first_payload(raw_message)
|
||||
if first_part.get_content_type() == 'application/pkcs7-mime':
|
||||
return True
|
||||
|
||||
return text.is_message_pgp_inline(first_part)
|
||||
|
||||
|
||||
def delivery_plan(recipients, message: EmailMessage, key_cache: kcache.KeyCache, lmessage: LazyMessage = None):
|
||||
"""Generate a sequence of delivery strategies."""
|
||||
if lmessage:
|
||||
message = lmessage.get_message()
|
||||
|
||||
if _is_encrypted(message):
|
||||
LOG.debug('Message is already encrypted: %s', message)
|
||||
return [KeepIntact(recipients)]
|
||||
|
||||
gpg_recipients, cleartext_recipients = recpt.identify_gpg_recipients(recipients, key_cache)
|
||||
|
||||
mime, inline = _sort_gpg_recipients(gpg_recipients)
|
||||
|
||||
keyhome = conf.get_item('gpg', 'keyhome')
|
||||
|
||||
plan = []
|
||||
if mime:
|
||||
plan.append(MimeOpenPGPEncrypt(mime.emails(), mime.keys(), keyhome))
|
||||
if inline:
|
||||
plan.append(InlineOpenPGPEncrypt(inline.emails(), inline.keys(), keyhome))
|
||||
if cleartext_recipients:
|
||||
plan.append(KeepIntact(cleartext_recipients))
|
||||
|
||||
return plan
|
||||
|
||||
|
||||
def deliver_message(raw_message: EmailMessage, from_address, to_addrs):
|
||||
"""Send RAW_MESSAGE to all TO_ADDRS using the best encryption method available."""
|
||||
# Ugly workaround to keep the code working without too many changes.
|
||||
register_sender(from_address)
|
||||
|
||||
sanitize = text.choose_sanitizer(conf.get_item('default', 'mail_case_insensitive'))
|
||||
recipients_left = [sanitize(recipient) for recipient in to_addrs]
|
||||
|
||||
send = SendFrom(from_address)
|
||||
|
||||
# There is no need for nested encryption
|
||||
LOG.debug("Seeing if it's already encrypted")
|
||||
if _is_encrypted(raw_message):
|
||||
LOG.debug("Message is already encrypted. Encryption aborted.")
|
||||
send(raw_message.as_string(), recipients_left)
|
||||
return
|
||||
|
||||
# Encrypt mails for recipients with known public PGP keys
|
||||
LOG.debug("Encrypting with OpenPGP")
|
||||
recipients_left = _gpg_encrypt(raw_message, recipients_left)
|
||||
if not recipients_left:
|
||||
return
|
||||
|
||||
# Encrypt mails for recipients with known S/MIME certificate
|
||||
LOG.debug("Encrypting with S/MIME")
|
||||
recipients_left = smime.encrypt(raw_message, recipients_left, from_address)
|
||||
if not recipients_left:
|
||||
return
|
||||
|
||||
# Send out mail to recipients which are left
|
||||
LOG.debug("Sending the rest as text/plain")
|
||||
send(raw_message.as_bytes(policy=SMTPUTF8), recipients_left)
|
159
lacre/daemon.py
159
lacre/daemon.py
|
@ -1,159 +0,0 @@
|
|||
"""Lacre Daemon, the Advanced Mail Filter message dispatcher."""
|
||||
|
||||
import logging
|
||||
import lacre
|
||||
from lacre.text import DOUBLE_EOL_BYTES
|
||||
from lacre.stats import time_logger
|
||||
import lacre.config as conf
|
||||
import sys
|
||||
from aiosmtpd.controller import Controller
|
||||
from aiosmtpd.smtp import Envelope
|
||||
import asyncio
|
||||
import email
|
||||
from email.policy import SMTPUTF8
|
||||
|
||||
# Load configuration and init logging, in this order. Only then can we load
|
||||
# the last Lacre module, i.e. lacre.core.
|
||||
conf.load_config()
|
||||
lacre.init_logging(conf.get_item("logging", "config"))
|
||||
LOG = logging.getLogger('lacre.daemon')
|
||||
|
||||
from GnuPG import EncryptionException
|
||||
import lacre.core as gate
|
||||
import lacre.keyring as kcache
|
||||
import lacre.transport as xport
|
||||
from lacre.mailop import KeepIntact, MailSerialisationException
|
||||
from lacre.lazymessage import LazyMessage
|
||||
|
||||
|
||||
class MailEncryptionProxy:
|
||||
"""A mail handler dispatching to appropriate mail operation."""
|
||||
|
||||
def __init__(self, keyring: kcache.KeyRing):
|
||||
"""Initialise the mail proxy with a reference to the key cache."""
|
||||
self._keyring = keyring
|
||||
|
||||
async def handle_DATA(self, server, session, envelope: Envelope):
|
||||
"""Accept a message and either encrypt it or forward as-is."""
|
||||
with time_logger('Message delivery', LOG):
|
||||
try:
|
||||
keys = self._keyring.freeze_identities()
|
||||
lmessage = LazyMessage(envelope.rcpt_tos, lambda: envelope.original_content)
|
||||
message = email.message_from_bytes(envelope.original_content, policy=SMTPUTF8)
|
||||
|
||||
if message.defects:
|
||||
LOG.warning("Issues found: %s", repr(message.defects))
|
||||
|
||||
send = xport.SendFrom(envelope.mail_from)
|
||||
for operation in gate.delivery_plan(envelope.rcpt_tos, message, keys, lmessage):
|
||||
LOG.debug(f"Sending mail via {operation!r}")
|
||||
try:
|
||||
new_message = operation.perform(message, lmessage)
|
||||
send(new_message, operation.recipients())
|
||||
except (EncryptionException, MailSerialisationException) as e:
|
||||
# If the message can't be encrypted or serialised to a
|
||||
# stream of bytes, deliver original payload in
|
||||
# cleartext.
|
||||
LOG.exception('Unable to encrypt message, delivering in cleartext')
|
||||
self._send_unencrypted(operation, envelope, send)
|
||||
|
||||
except xport.TransientFailure:
|
||||
LOG.info('Bouncing message')
|
||||
return xport.RESULT_TRANS_FAIL
|
||||
|
||||
except xport.PermanentFailure:
|
||||
LOG.exception('Permanent failure')
|
||||
return xport.RESULT_PERM_FAIL
|
||||
|
||||
except:
|
||||
if conf.should_log_headers():
|
||||
LOG.exception('Unexpected exception caught, bouncing message. Erroneous message headers: %s', self._beginning(envelope))
|
||||
else:
|
||||
LOG.exception('Unexpected exception caught, bouncing message')
|
||||
|
||||
return xport.RESULT_PERM_FAIL
|
||||
|
||||
return xport.RESULT_OK
|
||||
|
||||
def _send_unencrypted(self, operation, envelope: Envelope, send: xport.SendFrom):
|
||||
# Do not parse and re-generate the message, just send it as it is.
|
||||
try:
|
||||
send(envelope.original_content, operation.recipients())
|
||||
except:
|
||||
LOG.exception('Unencrypted delivery failed, returning PERMANENT FAILURE to sender')
|
||||
raise xport.PermanentFailure()
|
||||
|
||||
def _beginning(self, e: Envelope) -> bytes:
|
||||
double_eol_pos = e.original_content.find(DOUBLE_EOL_BYTES)
|
||||
if double_eol_pos < 0:
|
||||
limit = len(e.original_content)
|
||||
else:
|
||||
limit = double_eol_pos
|
||||
end = min(limit, 2560)
|
||||
return e.original_content[0:end]
|
||||
|
||||
|
||||
def _init_controller(keys: kcache.KeyRing, max_body_bytes=None, tout: float = 5):
|
||||
proxy = MailEncryptionProxy(keys)
|
||||
host, port = conf.daemon_params()
|
||||
LOG.info(f"Initialising a mail Controller at {host}:{port}")
|
||||
return Controller(proxy, hostname=host, port=port,
|
||||
ready_timeout=tout,
|
||||
data_size_limit=max_body_bytes,
|
||||
# Do not decode data into str as we only operate on raw
|
||||
# data available via Envelope.original_content.
|
||||
decode_data=False)
|
||||
|
||||
|
||||
def _validate_config():
|
||||
missing = conf.validate_config()
|
||||
if missing:
|
||||
params = ", ".join([_full_param_name(tup) for tup in missing])
|
||||
LOG.error(f"Following mandatory parameters are missing: {params}")
|
||||
sys.exit(lacre.EX_CONFIG)
|
||||
|
||||
|
||||
def _full_param_name(tup):
|
||||
return f"[{tup[0]}]{tup[1]}"
|
||||
|
||||
|
||||
async def _sleep():
|
||||
while True:
|
||||
await asyncio.sleep(360)
|
||||
|
||||
|
||||
async def _main():
|
||||
_validate_config()
|
||||
|
||||
keyring_path = conf.get_item('gpg', 'keyhome')
|
||||
max_data_bytes = int(conf.get_item('daemon', 'max_data_bytes', 2**25))
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
try:
|
||||
keyring = kcache.init_keyring()
|
||||
controller = _init_controller(keyring, max_data_bytes)
|
||||
|
||||
keyring.post_init_hook()
|
||||
|
||||
LOG.info('Starting the daemon with GnuPG=%s, socket=%s, database=%s',
|
||||
keyring_path,
|
||||
conf.daemon_params(),
|
||||
conf.get_item('database', 'url'))
|
||||
controller.start()
|
||||
|
||||
await _sleep()
|
||||
except KeyboardInterrupt:
|
||||
LOG.info("Finishing...")
|
||||
except:
|
||||
LOG.exception('Unexpected exception caught, your system may be unstable')
|
||||
finally:
|
||||
LOG.info('Shutting down keyring watcher and the daemon...')
|
||||
keyring.shutdown()
|
||||
controller.stop()
|
||||
|
||||
LOG.info("Done")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(_main())
|
|
@ -1,65 +0,0 @@
|
|||
"""Database schema for Lacre.
|
||||
|
||||
This definition includes:
|
||||
|
||||
- 'lacre_keys' -- temporary key storage, used by the frontend to submit keys and
|
||||
by webgate-cron script to import submitted keys.
|
||||
|
||||
- 'lacre_identities' -- identity catalogue, used by encryption logic to match
|
||||
emails with corresponding keys.
|
||||
|
||||
- 'lacre_locks' -- used only by the frontend.
|
||||
"""
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
# Values for lacre_keys.status column:
|
||||
# - ST_DEFAULT: initial state;
|
||||
# - ST_IMPORTED: key has been successfully processed by cron job;
|
||||
# - ST_TO_BE_DELETED: key can be deleted. We only have checks for this value
|
||||
# but never assign it, so this is a candidate for removal.
|
||||
ST_DEFAULT, ST_IMPORTED, ST_TO_BE_DELETED = range(3)
|
||||
|
||||
# lacre_keys.confirmed is set to an empty string when a key is confirmed by the user.
|
||||
CO_CONFIRMED = ''
|
||||
|
||||
_meta = sqlalchemy.MetaData()
|
||||
|
||||
LACRE_KEYS = sqlalchemy.Table('lacre_keys', _meta,
|
||||
sqlalchemy.Column('id', sqlalchemy.Integer, primary_key=True, nullable=False, autoincrement='auto'),
|
||||
sqlalchemy.Column('email', sqlalchemy.String(256), index=True),
|
||||
# ASCII-armored key
|
||||
sqlalchemy.Column('publickey', sqlalchemy.Text),
|
||||
# Empty string means this key has been confirmed.
|
||||
sqlalchemy.Column('confirm', sqlalchemy.String(32)),
|
||||
# Status: see ST_* constants at the top of the file.
|
||||
sqlalchemy.Column('status', sqlalchemy.Integer, nullable=False, default=0),
|
||||
sqlalchemy.Column('time', sqlalchemy.DateTime))
|
||||
|
||||
LACRE_LOCKS = sqlalchemy.Table('lacre_locks', _meta,
|
||||
sqlalchemy.Column('id', sqlalchemy.Integer, primary_key=True, nullable=False, autoincrement='auto'),
|
||||
sqlalchemy.Column('ip', sqlalchemy.String(16)),
|
||||
sqlalchemy.Column('time', sqlalchemy.Integer),
|
||||
sqlalchemy.Column('action', sqlalchemy.String(16)),
|
||||
sqlalchemy.Column('num', sqlalchemy.Integer),
|
||||
)
|
||||
|
||||
LACRE_IDENTITIES = sqlalchemy.Table('lacre_identities', _meta,
|
||||
sqlalchemy.Column('email', sqlalchemy.String(256), index=True, nullable=False),
|
||||
# Key fingerprint
|
||||
sqlalchemy.Column('fingerprint', sqlalchemy.String(64), index=True, nullable=False))
|
||||
|
||||
def init_identities_table() -> sqlalchemy.Table:
|
||||
return LACRE_IDENTITIES
|
||||
|
||||
def init_locks_table() -> sqlalchemy.Table:
|
||||
return LACRE_LOCKS
|
||||
|
||||
def init_keys_table() -> sqlalchemy.Table:
|
||||
return LACRE_KEYS
|
||||
|
||||
def create_tables(engine):
|
||||
_meta.create_all(engine)
|
||||
|
||||
def table_metadata():
|
||||
return _meta
|
|
@ -1,36 +0,0 @@
|
|||
"""Key management utilities."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
|
||||
from lacre.config import get_item
|
||||
|
||||
|
||||
# By default, we let keys stay in confirmation queue for 1 hour.
|
||||
_DEFAULT_TTL = 1
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def calculate_expiry_date(now: datetime) -> datetime:
|
||||
"""Calculate date-time of key queue item expiry.
|
||||
|
||||
Given current timestamp and configuration item
|
||||
[database]max_queue_hours, return a date-time object that should be
|
||||
older than any key in our confirmation queue. If a key is older
|
||||
than this threshold, we should remove it."""
|
||||
max_hours = _get_ttl()
|
||||
return now - timedelta(hours=max_hours)
|
||||
|
||||
|
||||
def _get_ttl():
|
||||
max_hours = get_item('database', 'max_queue_hours', _DEFAULT_TTL)
|
||||
try:
|
||||
ttl = int(max_hours)
|
||||
LOG.debug('Key configmration queue max item age: %d hours', ttl)
|
||||
return ttl
|
||||
except ValueError:
|
||||
# Not a valid integer, so we return the default.
|
||||
LOG.exception('Invalid max_queue_hours format: %s, using default (%d)', max_hours, _DEFAULT_TTL)
|
||||
return _DEFAULT_TTL
|
|
@ -1,28 +0,0 @@
|
|||
"""Data structures and utilities to make keyring access easier.
|
||||
|
||||
IMPORTANT: This module has to be loaded _after_ initialisation of the logging
|
||||
module.
|
||||
"""
|
||||
|
||||
import lacre.config as conf
|
||||
from lacre._keyringcommon import KeyRing, KeyCache
|
||||
from lacre.repositories import IdentityRepository, init_engine
|
||||
import logging
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def init_keyring() -> KeyRing:
|
||||
"""Initialise appropriate type of keyring."""
|
||||
url = conf.get_item('database', 'url')
|
||||
db_engine = init_engine(url)
|
||||
return IdentityRepository(engine=db_engine)
|
||||
|
||||
|
||||
def freeze_and_load_keys() -> KeyCache:
|
||||
"""Load and return keys.
|
||||
|
||||
Doesn't refresh the keys when they change on disk.
|
||||
"""
|
||||
keyring = init_keyring()
|
||||
return keyring.freeze_identities()
|
|
@ -1,33 +0,0 @@
|
|||
from aiosmtpd.smtp import Envelope
|
||||
from email import message_from_bytes
|
||||
from email.message import EmailMessage
|
||||
from email.parser import BytesHeaderParser
|
||||
from email.policy import SMTPUTF8
|
||||
|
||||
class LazyMessage:
|
||||
def __init__(self, recipients, content_provider):
|
||||
self._content_provider = content_provider
|
||||
self._recipients = recipients
|
||||
self._headers = None
|
||||
self._message = None
|
||||
|
||||
def get_original_content(self) -> bytes:
|
||||
return self._content_provider()
|
||||
|
||||
def get_recipients(self):
|
||||
return self._recipients
|
||||
|
||||
def get_headers(self) -> EmailMessage:
|
||||
if self._message:
|
||||
return self._message
|
||||
|
||||
if not self._headers:
|
||||
self._headers = BytesHeaderParser(policy=SMTPUTF8).parsebytes(self.get_original_content())
|
||||
|
||||
return self._headers
|
||||
|
||||
def get_message(self) -> EmailMessage:
|
||||
if not self._message:
|
||||
self._message = message_from_bytes(self.get_original_content(), policy=SMTPUTF8)
|
||||
|
||||
return self._message
|
143
lacre/mailop.py
143
lacre/mailop.py
|
@ -1,143 +0,0 @@
|
|||
"""Mail operations for a given recipient.
|
||||
|
||||
There are 3 operations available:
|
||||
|
||||
- OpenPGPEncrypt: to deliver the message to a recipient with an OpenPGP public
|
||||
key available.
|
||||
|
||||
- SMimeEncrypt: to deliver the message to a recipient with an S/MIME
|
||||
certificate.
|
||||
|
||||
- KeepIntact: a no-operation (implementation of the Null Object pattern), used
|
||||
for messages already encrypted or those who haven't provided their keys or
|
||||
certificates.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import lacre.core as core
|
||||
from lacre.lazymessage import LazyMessage
|
||||
from email.message import Message, EmailMessage
|
||||
from email.parser import BytesHeaderParser
|
||||
from email.policy import SMTP, SMTPUTF8
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MailSerialisationException(BaseException):
|
||||
"""We can't turn an EmailMessage into sequence of bytes."""
|
||||
pass
|
||||
|
||||
|
||||
class MailOperation:
|
||||
"""Contract for an operation to be performed on a message."""
|
||||
|
||||
def __init__(self, recipients=[]):
|
||||
"""Initialise the operation with a recipient."""
|
||||
self._recipients = recipients
|
||||
|
||||
def perform(self, message: Message, lmessage: LazyMessage) -> bytes:
|
||||
"""Perform this operation on MESSAGE.
|
||||
|
||||
Return target message.
|
||||
"""
|
||||
raise NotImplementedError(self.__class__())
|
||||
|
||||
def recipients(self):
|
||||
"""Return list of recipients of the message."""
|
||||
return self._recipients
|
||||
|
||||
def add_recipient(self, recipient):
|
||||
"""Register another message recipient."""
|
||||
self._recipients.append(recipient)
|
||||
|
||||
|
||||
class OpenPGPEncrypt(MailOperation):
|
||||
"""OpenPGP-encrypt the message."""
|
||||
|
||||
def __init__(self, recipients, keys, keyhome):
|
||||
"""Initialise encryption operation."""
|
||||
super().__init__(recipients)
|
||||
self._keys = keys
|
||||
self._keyhome = keyhome
|
||||
|
||||
def extend_keys(self, keys):
|
||||
"""Register GPG keys to encrypt this message for."""
|
||||
self._keys.extend(keys)
|
||||
|
||||
def __repr__(self):
|
||||
"""Generate a representation with just method and key."""
|
||||
return f"<{type(self).__name__} {self._recipients} {self._keys}>"
|
||||
|
||||
|
||||
class InlineOpenPGPEncrypt(OpenPGPEncrypt):
|
||||
"""Inline encryption strategy."""
|
||||
|
||||
def __init__(self, recipients, keys, keyhome):
|
||||
"""Initialise strategy object."""
|
||||
super().__init__(recipients, keys, keyhome)
|
||||
|
||||
def perform(self, msg: Message, lmessage: LazyMessage) -> bytes:
|
||||
"""Encrypt with PGP Inline."""
|
||||
LOG.debug('Sending PGP/Inline...')
|
||||
return core._gpg_encrypt_to_bytes(msg,
|
||||
self._keys, self._recipients,
|
||||
core._encrypt_all_payloads_inline,
|
||||
lmessage)
|
||||
|
||||
|
||||
class MimeOpenPGPEncrypt(OpenPGPEncrypt):
|
||||
"""MIME encryption strategy."""
|
||||
|
||||
def __init__(self, recipients, keys, keyhome):
|
||||
"""Initialise strategy object."""
|
||||
super().__init__(recipients, keys, keyhome)
|
||||
|
||||
def perform(self, msg: Message, lmessage: LazyMessage) -> bytes:
|
||||
"""Encrypt with PGP MIME."""
|
||||
LOG.debug('Sending PGP/MIME...')
|
||||
return core._gpg_encrypt_to_bytes(msg,
|
||||
self._keys, self._recipients,
|
||||
core._encrypt_all_payloads_mime,
|
||||
lmessage)
|
||||
|
||||
|
||||
class SMimeEncrypt(MailOperation):
|
||||
"""S/MIME encryption operation."""
|
||||
|
||||
def __init__(self, recipient, email, certificate):
|
||||
"""Initialise S/MIME encryption for a given EMAIL and CERTIFICATE."""
|
||||
super().__init__(recipient)
|
||||
self._email = email
|
||||
self._cert = certificate
|
||||
|
||||
def perform(self, message: Message, lmessage: LazyMessage) -> bytes:
|
||||
"""Encrypt with a certificate."""
|
||||
LOG.warning(f"Delivering clear-text to {self._recipients}")
|
||||
return message.as_bytes(policy=SMTP)
|
||||
|
||||
def __repr__(self):
|
||||
"""Generate a representation with just method and key."""
|
||||
return f"<S/MIME {self._recipients}, {self._cert}>"
|
||||
|
||||
|
||||
class KeepIntact(MailOperation):
|
||||
"""A do-nothing operation (Null Object implementation).
|
||||
|
||||
This operation should be used for mail that's already encrypted.
|
||||
"""
|
||||
|
||||
def __init__(self, recipients):
|
||||
"""Initialise pass-through operation for a given recipient."""
|
||||
super().__init__(recipients)
|
||||
|
||||
def perform(self, message: Message, lmessage: LazyMessage) -> bytes:
|
||||
"""Return MESSAGE unmodified."""
|
||||
try:
|
||||
return lmessage.get_original_content()
|
||||
except (IndexError, UnicodeEncodeError, ValueError) as e:
|
||||
raise MailSerialisationException(e)
|
||||
|
||||
def __repr__(self):
|
||||
"""Return representation with just method and email."""
|
||||
return f"<KeepIntact {self._recipients}>"
|
|
@ -1,54 +0,0 @@
|
|||
"""Lacre notification sender"""
|
||||
|
||||
import logging
|
||||
import lacre
|
||||
import lacre.config as conf
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
import markdown
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _load_file(name):
|
||||
f = open(name)
|
||||
data = f.read()
|
||||
f.close()
|
||||
return data
|
||||
|
||||
|
||||
def _authenticate_maybe(smtp):
|
||||
if conf.config_item_equals('smtp', 'enabled', 'true'):
|
||||
LOG.debug(f"Connecting to {conf.get_item('smtp', 'host')}:{conf.get_item('smtp', 'port')}")
|
||||
smtp.connect(conf.get_item('smtp', 'host'), conf.get_item('smtp', 'port'))
|
||||
smtp.ehlo()
|
||||
if conf.config_item_equals('smtp', 'starttls', 'true'):
|
||||
LOG.debug("StartTLS enabled")
|
||||
smtp.starttls()
|
||||
smtp.ehlo()
|
||||
smtp.login(conf.get_item('smtp', 'username'), conf.get_item('smtp', 'password'))
|
||||
|
||||
|
||||
def notify(mailsubject, messagefile, recipients = None):
|
||||
"""Send notification email."""
|
||||
|
||||
mailbody = _load_file(conf.get_item('cron', 'mail_templates') + "/" + messagefile)
|
||||
msg = MIMEMultipart("alternative")
|
||||
|
||||
msg["From"] = conf.get_item('cron', 'notification_email')
|
||||
msg["To"] = recipients
|
||||
msg["Subject"] = mailsubject
|
||||
|
||||
msg.attach(MIMEText(mailbody, 'plain'))
|
||||
msg.attach(MIMEText(markdown.markdown(mailbody), 'html'))
|
||||
|
||||
if conf.config_item_set('relay', 'host') and conf.config_item_set('relay', 'enc_port'):
|
||||
host = conf.relay_params()
|
||||
smtp = smtplib.SMTP(host.name, host.port)
|
||||
_authenticate_maybe(smtp)
|
||||
LOG.info('Delivering notification: %s', recipients)
|
||||
smtp.sendmail(conf.get_item('cron', 'notification_email'), recipients, msg.as_string())
|
||||
else:
|
||||
LOG.warning("Could not send mail due to wrong configuration")
|
|
@ -1,202 +0,0 @@
|
|||
#
|
||||
# lacre
|
||||
#
|
||||
# This file is part of the lacre source code.
|
||||
#
|
||||
# lacre is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# lacre source code is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with lacre source code. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
"""Recipient processing package.
|
||||
|
||||
Defines:
|
||||
- GpgRecipient, wrapper for user's email and identity.
|
||||
- RecipientList, a wrapper for lists of GpgRecipient objects.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import lacre.config as conf
|
||||
import lacre.keyring as kcache
|
||||
import lacre.text as text
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Recipient:
|
||||
"""Wraps recipient's email."""
|
||||
|
||||
def __init__(self, email):
|
||||
"""Initialise the recipient."""
|
||||
self._email = email
|
||||
|
||||
def email(self) -> str:
|
||||
"""Return email address of this recipient."""
|
||||
return self._email
|
||||
|
||||
def __str__(self):
|
||||
"""Return string representation of this recipient: the email address."""
|
||||
return self._email
|
||||
|
||||
|
||||
class GpgRecipient(Recipient):
|
||||
"""A tuple-like object that contains GPG recipient data."""
|
||||
|
||||
def __init__(self, left, right):
|
||||
"""Initialise a tuple-like object that contains GPG recipient data."""
|
||||
super().__init__(left)
|
||||
self._right = right
|
||||
|
||||
def __getitem__(self, index):
|
||||
"""Pretend this object is a tuple by returning an indexed tuple element."""
|
||||
if index == 0:
|
||||
return self.email()
|
||||
elif index == 1:
|
||||
return self._right
|
||||
else:
|
||||
raise IndexError()
|
||||
|
||||
def __repr__(self):
|
||||
"""Return textual representation of this GPG Recipient."""
|
||||
return f"GpgRecipient({self.email()!r}, {self._right!r})"
|
||||
|
||||
__str__ = __repr__
|
||||
|
||||
def key(self):
|
||||
"""Return this recipient's key ID."""
|
||||
return self._right
|
||||
|
||||
|
||||
class RecipientList:
|
||||
"""Encalsulates two lists of recipients.
|
||||
|
||||
First list contains addresses, the second - GPG identities.
|
||||
"""
|
||||
|
||||
def __init__(self, recipients=[], keys=[]):
|
||||
"""Initialise lists of recipients and identities."""
|
||||
self._recipients = [GpgRecipient(email, key) for (email, key) in zip(recipients, keys)]
|
||||
|
||||
def emails(self):
|
||||
"""Return list of recipients."""
|
||||
return [r.email() for r in self._recipients]
|
||||
|
||||
def keys(self):
|
||||
"""Return list of GPG identities."""
|
||||
return [r.key() for r in self._recipients]
|
||||
|
||||
def __iadd__(self, recipient: GpgRecipient):
|
||||
"""Append a recipient."""
|
||||
LOG.debug('Adding %s to %s', recipient, self._recipients)
|
||||
self._recipients.append(recipient)
|
||||
LOG.debug('Added; got: %s', self._recipients)
|
||||
return self
|
||||
|
||||
def __len__(self):
|
||||
"""Provide len().
|
||||
|
||||
With this method, it is possible to write code like:
|
||||
|
||||
rl = RecipientList()
|
||||
if rl:
|
||||
# do something
|
||||
"""
|
||||
return len(self._recipients)
|
||||
|
||||
def __repr__(self):
|
||||
"""Returns textual object representation."""
|
||||
return '<RecipientList %d %s>' % (len(self._recipients), ','.join(self.emails()))
|
||||
|
||||
|
||||
def identify_gpg_recipients(recipients, keys: kcache.KeyCache):
|
||||
"""Split recipient list into GPG and non-GPG ones."""
|
||||
# This list will be filled with pairs (M, N), where M is the destination
|
||||
# address we're going to deliver the message to and N is the identity we're
|
||||
# going to encrypt it for.
|
||||
gpg_recipients = list()
|
||||
|
||||
# This will be the list of recipients that haven't provided us with their
|
||||
# public keys.
|
||||
cleartext_recipients = list()
|
||||
|
||||
# In "strict mode", only keys included in configuration are used to encrypt
|
||||
# email.
|
||||
strict_mode = conf.strict_mode()
|
||||
|
||||
for recipient in recipients:
|
||||
gpg_recipient = _find_key(recipient, keys, strict_mode)
|
||||
if gpg_recipient is not None:
|
||||
gpg_recipients.append(gpg_recipient)
|
||||
else:
|
||||
cleartext_recipients.append(recipient)
|
||||
|
||||
LOG.debug('Collected recipients; GPG: %s; cleartext: %s', gpg_recipients, cleartext_recipients)
|
||||
return gpg_recipients, cleartext_recipients
|
||||
|
||||
|
||||
def _find_key(recipient, keys: kcache.KeyCache, strict_mode):
|
||||
own_key = _try_configured_key(recipient, keys)
|
||||
if own_key is not None:
|
||||
return GpgRecipient(own_key[0], own_key[1])
|
||||
|
||||
direct_key = _try_direct_key_lookup(recipient, keys, strict_mode)
|
||||
if direct_key is not None:
|
||||
return GpgRecipient(direct_key[0], direct_key[1])
|
||||
|
||||
domain_key = _try_configured_domain_key(recipient, keys)
|
||||
if domain_key is not None:
|
||||
return GpgRecipient(domain_key[0], domain_key[1])
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _try_configured_key(recipient, keys: kcache.KeyCache):
|
||||
if conf.config_item_set('enc_keymap', recipient):
|
||||
key = conf.get_item('enc_keymap', recipient)
|
||||
if key in keys:
|
||||
LOG.debug(f"Found key {key} configured for {recipient}")
|
||||
return (recipient, key)
|
||||
|
||||
LOG.debug(f"No configured key found for {recipient}")
|
||||
return None
|
||||
|
||||
|
||||
def _try_direct_key_lookup(recipient, keys: kcache.KeyCache, strict_mode):
|
||||
if strict_mode:
|
||||
return None
|
||||
|
||||
if keys.has_email(recipient):
|
||||
LOG.info(f"Found key for {recipient}")
|
||||
return recipient, recipient
|
||||
|
||||
(newto, topic) = text.parse_delimiter(recipient)
|
||||
if keys.has_email(newto):
|
||||
LOG.info(f"Found key for {newto}, stripped {recipient}")
|
||||
return recipient, newto
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _try_configured_domain_key(recipient, keys: kcache.KeyCache):
|
||||
parts = recipient.split('@')
|
||||
if len(parts) != 2:
|
||||
return None
|
||||
|
||||
domain = parts[1]
|
||||
if conf.config_item_set('enc_domain_keymap', domain):
|
||||
domain_key = conf.get_item('enc_domain_keymap', domain)
|
||||
if domain_key in keys:
|
||||
LOG.debug(f"Found domain key {domain_key} for {recipient}")
|
||||
return recipient, domain_key
|
||||
|
||||
LOG.debug(f"No domain key for {recipient}")
|
||||
return None
|
|
@ -1,225 +0,0 @@
|
|||
"""Lacre identity and key repositories."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import create_engine, select, delete, and_, func
|
||||
from sqlalchemy.exc import OperationalError
|
||||
import logging
|
||||
|
||||
from lacre.config import flag_enabled, config_item_set, get_item, PoolingMode
|
||||
from lacre._keyringcommon import KeyRing, KeyCache
|
||||
import lacre.dbschema as db
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_HOUR_IN_SECONDS = 3600
|
||||
|
||||
# Internal state
|
||||
_engine = None
|
||||
|
||||
|
||||
def init_engine(url, db_debug=False):
|
||||
global _engine
|
||||
|
||||
if not _engine:
|
||||
config = _conn_config(db_debug)
|
||||
_engine = create_engine(url, **config)
|
||||
|
||||
return _engine
|
||||
|
||||
|
||||
def _conn_config(db_debug):
|
||||
config = dict()
|
||||
|
||||
mode = PoolingMode.from_config('database', 'pooling_mode', required=True)
|
||||
if mode is PoolingMode.OPTIMISTIC:
|
||||
# Optimistic distonnect-handling: recycle connections.
|
||||
config['pool_recycle'] = int(get_item('database', 'max_connection_age', _HOUR_IN_SECONDS))
|
||||
elif mode is PoolingMode.PESSIMISTIC:
|
||||
# Pessimistic disconnect-handling: pre_ping.
|
||||
config['pool_pre_ping'] = True
|
||||
|
||||
# Additional pool settings
|
||||
if config_item_set('database', 'pool_size'):
|
||||
config['pool_size'] = int(get_item('database', 'pool_size'))
|
||||
|
||||
if config_item_set('database', 'max_overflow'):
|
||||
config['max_overflow'] = int(get_item('database', 'max_overflow'))
|
||||
|
||||
if db_debug:
|
||||
config['echo'] = 'debug'
|
||||
config['echo_pool'] = 'debug'
|
||||
|
||||
LOG.debug('Database engine configuration: %s', config)
|
||||
return config
|
||||
|
||||
|
||||
class IdentityRepository(KeyRing):
|
||||
def __init__(self, /, connection=None, *, engine):
|
||||
self._identities = db.LACRE_IDENTITIES
|
||||
self._engine = engine
|
||||
|
||||
def register_or_update(self, email, fprint):
|
||||
assert email, "email is mandatory"
|
||||
assert fprint, "fprint is mandatory"
|
||||
|
||||
if self._exists(email):
|
||||
self._update(email, fprint)
|
||||
else:
|
||||
self._insert(email, fprint)
|
||||
|
||||
def _exists(self, email: str) -> bool:
|
||||
selq = select(self._identities.c.email).where(self._identities.c.email == email)
|
||||
with self._engine.connect() as conn:
|
||||
return [e for e in conn.execute(selq)]
|
||||
|
||||
def _insert(self, email, fprint):
|
||||
insq = self._identities.insert().values(email=email, fingerprint=fprint)
|
||||
|
||||
LOG.debug('Registering identity: %s -- %s', insq, insq.compile().params)
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(insq)
|
||||
conn.commit()
|
||||
|
||||
def _update(self, email, fprint):
|
||||
upq = self._identities.update() \
|
||||
.values(fingerprint=fprint) \
|
||||
.where(self._identities.c.email == email)
|
||||
|
||||
LOG.debug('Updating identity: %s -- %s', upq, upq.compile().params)
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(upq)
|
||||
conn.commit()
|
||||
|
||||
def delete(self, email):
|
||||
delq = delete(self._identities).where(self._identities.c.email == email)
|
||||
LOG.debug('Deleting assigned keys: %s -- %s', delq, delq.compile().params)
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(delq)
|
||||
conn.commit()
|
||||
|
||||
def delete_all(self):
|
||||
LOG.warn('Deleting all identities from the database')
|
||||
|
||||
delq = delete(self._identities)
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(delq)
|
||||
conn.commit()
|
||||
|
||||
def freeze_identities(self) -> KeyCache:
|
||||
"""Return a static, async-safe copy of the identity map.
|
||||
|
||||
Depending on the value of [daemon]bounce_on_keys_missing value,
|
||||
if we get a database exception, this method will either return
|
||||
empty collection or let the exception be propagated.
|
||||
"""
|
||||
try:
|
||||
return self._load_identities()
|
||||
except OperationalError:
|
||||
if flag_enabled('daemon', 'bounce_on_keys_missing'):
|
||||
raise
|
||||
else:
|
||||
LOG.exception('Failed to load keys, returning empty collection')
|
||||
return KeyCache({})
|
||||
|
||||
def _load_identities(self) -> KeyCache:
|
||||
all_identities = select(self._identities.c.fingerprint, self._identities.c.email)
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(all_identities)
|
||||
LOG.debug('Retrieving all keys: %s', all_identities)
|
||||
return KeyCache({key_id: email for key_id, email in result})
|
||||
|
||||
|
||||
class KeyConfirmationQueue:
|
||||
"""Encapsulates access to lacre_keys table."""
|
||||
|
||||
# Default number of items retrieved from the database.
|
||||
keys_read_max = 100
|
||||
|
||||
def __init__(self, /, engine):
|
||||
self._keys = db.LACRE_KEYS
|
||||
self._engine = engine
|
||||
|
||||
def fetch_keys(self, /, max_keys=None):
|
||||
"""Runs a query to retrieve at most `keys_read_max` keys and returns db result."""
|
||||
max_keys = max_keys or self.keys_read_max
|
||||
LOG.debug('Row limit: %d', max_keys)
|
||||
|
||||
selq = select(self._keys.c.publickey, self._keys.c.id, self._keys.c.email) \
|
||||
.where(and_(self._keys.c.status == db.ST_DEFAULT, self._keys.c.confirm == db.CO_CONFIRMED)) \
|
||||
.limit(max_keys)
|
||||
|
||||
LOG.debug('Retrieving keys to be processed: %s -- %s', selq, selq.compile().params)
|
||||
with self._engine.connect() as conn:
|
||||
return [e for e in conn.execute(selq)]
|
||||
|
||||
def count_keys(self):
|
||||
selq = select(func.count(self._keys.c.id)) \
|
||||
.where(and_(self._keys.c.status == db.ST_DEFAULT, self._keys.c.confirm == db.CO_CONFIRMED))
|
||||
|
||||
LOG.debug('Counting all keys: %s -- %s', selq, selq.compile().params)
|
||||
try:
|
||||
with self._engine.connect() as conn:
|
||||
res = conn.execute(selq)
|
||||
# This is a 1-element tuple.
|
||||
return res.one_or_none()[0]
|
||||
except OperationalError:
|
||||
LOG.exception('Cannot count keys')
|
||||
return None
|
||||
|
||||
def fetch_keys_to_delete(self):
|
||||
seldel = select(self._keys.c.email, self._keys.c.id) \
|
||||
.where(self._keys.c.status == db.ST_TO_BE_DELETED) \
|
||||
.limit(self.keys_read_max)
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
return [e for e in conn.execute(seldel)]
|
||||
|
||||
def delete_expired_queue_items(self, older_than: datetime):
|
||||
"""Remove keys that have been in queue before `older_than`."""
|
||||
delq = delete(self._keys) \
|
||||
.where(
|
||||
and_(
|
||||
self._keys.c.time < older_than,
|
||||
# We only want to delete keys that haven't been confirmed.
|
||||
self._keys.c.confirm != db.CO_CONFIRMED
|
||||
)
|
||||
)
|
||||
LOG.debug('Deleting queue items older than %s: %s', repr(older_than), delq)
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(delq)
|
||||
conn.commit()
|
||||
|
||||
def delete_keys(self, row_id, /, email=None):
|
||||
"""Remove key from the database."""
|
||||
if email is not None:
|
||||
LOG.debug('Deleting key: id=%s, email=%s', row_id, email)
|
||||
delq = delete(self._keys).where(and_(self._keys.c.email == email, self._keys.c.id == row_id))
|
||||
else:
|
||||
LOG.debug('Deleting key: id=%s', row_id)
|
||||
delq = delete(self._keys).where(self._keys.c.id == row_id)
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
LOG.debug('Deleting public keys associated with confirmed email: %s', delq)
|
||||
conn.execute(delq)
|
||||
conn.commit()
|
||||
|
||||
def delete_key_by_email(self, email):
|
||||
"""Remove keys linked to the given email from the database."""
|
||||
delq = delete(self._keys).where(self._keys.c.email == email)
|
||||
|
||||
LOG.debug('Deleting email for: %s', email)
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(delq)
|
||||
conn.commit()
|
||||
|
||||
def mark_accepted(self, row_id):
|
||||
modq = self._keys.update().where(self._keys.c.id == row_id).values(status=db.ST_IMPORTED)
|
||||
LOG.debug("Key imported, updating key: %s", modq)
|
||||
|
||||
with self._engine.connect() as conn:
|
||||
conn.execute(modq)
|
||||
conn.commit()
|
126
lacre/smime.py
126
lacre/smime.py
|
@ -1,126 +0,0 @@
|
|||
#
|
||||
# lacre
|
||||
#
|
||||
# This file is part of the lacre source code.
|
||||
#
|
||||
# lacre is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# lacre source code is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with lacre source code. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
"""S/MIME handling module."""
|
||||
|
||||
import os
|
||||
|
||||
from M2Crypto import BIO, SMIME, X509
|
||||
|
||||
import logging
|
||||
import lacre.text as text
|
||||
import lacre.config as conf
|
||||
import lacre.transport as xport
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
#
|
||||
# WARNING: This file is not covered with E2E tests.
|
||||
#
|
||||
|
||||
def encrypt(raw_message, recipients, from_addr):
|
||||
"""Encrypt with S/MIME."""
|
||||
if not conf.config_item_set('smime', 'cert_path'):
|
||||
LOG.info("No valid path for S/MIME certs found in config file. S/MIME encryption aborted.")
|
||||
return recipients
|
||||
|
||||
cert_path = conf.get_item('smime', 'cert_path')+"/"
|
||||
s = SMIME.SMIME()
|
||||
sk = X509.X509_Stack()
|
||||
smime_to = list()
|
||||
cleartext_to = list()
|
||||
|
||||
for addr in recipients:
|
||||
cert_and_email = _get_cert_for_email(addr, cert_path)
|
||||
|
||||
if not (cert_and_email is None):
|
||||
(to_cert, normal_email) = cert_and_email
|
||||
LOG.debug("Found cert " + to_cert + " for " + addr + ": " + normal_email)
|
||||
smime_to.append(addr)
|
||||
x509 = X509.load_cert(to_cert, format=X509.FORMAT_PEM)
|
||||
sk.push(x509)
|
||||
else:
|
||||
cleartext_to.append(addr)
|
||||
|
||||
if smime_to:
|
||||
s.set_x509_stack(sk)
|
||||
s.set_cipher(SMIME.Cipher('aes_192_cbc'))
|
||||
p7 = s.encrypt(BIO.MemoryBuffer(raw_message.as_string()))
|
||||
# Output p7 in mail-friendly format.
|
||||
out = BIO.MemoryBuffer()
|
||||
out.write('From: ' + from_addr + text.EOL_S)
|
||||
out.write('To: ' + raw_message['To'] + text.EOL_S)
|
||||
if raw_message['Cc']:
|
||||
out.write('Cc: ' + raw_message['Cc'] + text.EOL_S)
|
||||
if raw_message['Bcc']:
|
||||
out.write('Bcc: ' + raw_message['Bcc'] + text.EOL_S)
|
||||
if raw_message['Subject']:
|
||||
out.write('Subject: ' + raw_message['Subject'] + text.EOL_S)
|
||||
|
||||
if conf.config_item_equals('default', 'add_header', 'yes'):
|
||||
out.write('X-Lacre: Encrypted by Lacre' + text.EOL_S)
|
||||
|
||||
s.write(out, p7)
|
||||
|
||||
LOG.debug(f"Sending message from {from_addr} to {smime_to}")
|
||||
|
||||
send_msg = xport.SendFrom(from_addr)
|
||||
send_msg(out.read(), smime_to)
|
||||
|
||||
if cleartext_to:
|
||||
LOG.debug(f"Unable to find valid S/MIME certificates for {cleartext_to}")
|
||||
|
||||
return cleartext_to
|
||||
|
||||
|
||||
def _path_comparator(insensitive: bool):
|
||||
if insensitive:
|
||||
return lambda filename, recipient: filename.casefold() == recipient
|
||||
else:
|
||||
return lambda filename, recipient: filename == recipient
|
||||
|
||||
|
||||
def _get_cert_for_email(to_addr, cert_path):
|
||||
insensitive = conf.config_item_equals('default', 'mail_case_insensitive', 'yes')
|
||||
paths_equal = _path_comparator(insensitive)
|
||||
|
||||
LOG.info('Retrieving certificate for %s from %s, insensitive=%s',
|
||||
to_addr, cert_path, insensitive)
|
||||
|
||||
files_in_directory = os.listdir(cert_path)
|
||||
for filename in files_in_directory:
|
||||
file_path = os.path.join(cert_path, filename)
|
||||
if not os.path.isfile(file_path):
|
||||
continue
|
||||
|
||||
if paths_equal(file_path, to_addr):
|
||||
return (file_path, to_addr)
|
||||
|
||||
# support foo+ignore@bar.com -> foo@bar.com
|
||||
LOG.info(f"An email with topic? {to_addr}")
|
||||
(fixed_up_email, topic) = text.parse_delimiter(to_addr)
|
||||
LOG.info(f'Got {fixed_up_email!r} and {topic!r}')
|
||||
if topic is None:
|
||||
# delimiter not used
|
||||
LOG.info('Topic not found')
|
||||
return None
|
||||
else:
|
||||
LOG.info(f"Looking up certificate for {fixed_up_email} after parsing {to_addr}")
|
||||
return _get_cert_for_email(fixed_up_email, cert_path)
|
|
@ -1,29 +0,0 @@
|
|||
"""Insights into Lacre's inner workings."""
|
||||
|
||||
import time
|
||||
import logging
|
||||
|
||||
class ExecutionTimeLogger:
|
||||
"""Context-manager that measures how much time some operation took and logs it."""
|
||||
|
||||
def __init__(self, message: str, logger: logging.Logger):
|
||||
self._message = message
|
||||
self._log = logger
|
||||
self._start = None
|
||||
|
||||
def __enter__(self):
|
||||
self._start = time.process_time()
|
||||
self._log.info('Start: %s', self._message)
|
||||
|
||||
def __exit__(self, exc_type=None, exc_value=None, traceback=None):
|
||||
end = time.process_time()
|
||||
ellapsed = (end - self._start) * 1000
|
||||
|
||||
if exc_type:
|
||||
exception = (exc_type, exc_value, traceback)
|
||||
self._log.error('%s took %d ms, raised exception', self._message, ellapsed, exc_info=exception)
|
||||
else:
|
||||
self._log.info('%s took %d ms', self._message, ellapsed)
|
||||
|
||||
def time_logger(msg: str, logger: logging.Logger):
|
||||
return ExecutionTimeLogger(msg, logger)
|
104
lacre/text.py
104
lacre/text.py
|
@ -1,104 +0,0 @@
|
|||
"""Basic payload-processing routines."""
|
||||
|
||||
import sys
|
||||
import re
|
||||
import logging
|
||||
from email.message import EmailMessage
|
||||
|
||||
|
||||
# The standard way to encode line-ending in email:
|
||||
EOL = b"\r\n"
|
||||
EOL_S = EOL.decode()
|
||||
DOUBLE_EOL_BYTES = EOL*2
|
||||
|
||||
PGP_BEGIN = b"-----BEGIN PGP MESSAGE-----"
|
||||
PGP_END = b"-----END PGP MESSAGE-----"
|
||||
|
||||
PGP_BEGIN_S = PGP_BEGIN.decode()
|
||||
PGP_END_S = PGP_END.decode()
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_content_type(content_type: str):
|
||||
"""Analyse Content-Type email header.
|
||||
|
||||
Return a pair: type and sub-type.
|
||||
"""
|
||||
parts = [p.strip() for p in content_type.split(';')]
|
||||
if len(parts) == 1:
|
||||
# No additional attributes provided. Use default encoding.
|
||||
return (content_type, sys.getdefaultencoding())
|
||||
|
||||
# At least one attribute provided. Find out if any of them is named
|
||||
# 'charset' and if so, use it.
|
||||
ctype = parts[0]
|
||||
encoding = [p for p in parts[1:] if p.startswith('charset=')]
|
||||
if encoding:
|
||||
eq_idx = encoding[0].index('=')
|
||||
return (ctype, encoding[0][eq_idx+1:])
|
||||
else:
|
||||
return (ctype, sys.getdefaultencoding())
|
||||
|
||||
|
||||
def parse_delimiter(address: str):
|
||||
"""Parse an email with delimiter and topic.
|
||||
|
||||
Return destination emaili and topic as a tuple.
|
||||
"""
|
||||
withdelim = re.match('^([^\\+]+)\\+([^@]+)@(.*)$', address)
|
||||
LOG.debug(f'Parsed email: {withdelim!r}')
|
||||
|
||||
if withdelim:
|
||||
return (withdelim.group(1) + '@' + withdelim.group(3), withdelim.group(2))
|
||||
else:
|
||||
return (address, None)
|
||||
|
||||
|
||||
def _lowercase_whole_address(address: str):
|
||||
return address.lower()
|
||||
|
||||
|
||||
def _lowercase_domain_only(address: str):
|
||||
parts = address.split('@', maxsplit=2)
|
||||
if len(parts) > 1:
|
||||
return parts[0] + '@' + parts[1].lower()
|
||||
else:
|
||||
return address
|
||||
|
||||
|
||||
def choose_sanitizer(mail_case_insensitive: bool):
|
||||
"""Return a function to sanitize email case sense."""
|
||||
if mail_case_insensitive:
|
||||
return _lowercase_whole_address
|
||||
else:
|
||||
return _lowercase_domain_only
|
||||
|
||||
|
||||
def is_payload_pgp_inline(payload) -> bool:
|
||||
"""Find out if the payload (bytes) contains PGP/inline markers."""
|
||||
if isinstance(payload, bytes):
|
||||
return payload.startswith(PGP_BEGIN) and _ends_with(payload, PGP_END)
|
||||
elif isinstance(payload, str):
|
||||
return payload.startswith(PGP_BEGIN_S) and _ends_with(payload, PGP_END_S)
|
||||
else:
|
||||
raise TypeError('Expected str or bytes')
|
||||
|
||||
|
||||
def _ends_with(payload, marker) -> bool:
|
||||
# Length of the span at the end of the payload we want to inspect should
|
||||
# include CRLF, CR or LF, so make it slightly larger than the marker
|
||||
# itself.
|
||||
span = len(marker) + 2
|
||||
return marker in payload[-span:]
|
||||
|
||||
|
||||
def is_message_pgp_inline(message: EmailMessage) -> bool:
|
||||
"""Find out if a message is already PGP-Inline encrypted."""
|
||||
if message.is_multipart() or isinstance(message.get_payload(), list):
|
||||
# more than one payload, check each one of them
|
||||
return any(is_message_pgp_inline(m.payload()) for m in message.iter_parts())
|
||||
else:
|
||||
# one payload, check it
|
||||
return is_payload_pgp_inline(message.get_payload(decode=True))
|
|
@ -1,110 +0,0 @@
|
|||
"""SMTP transport module."""
|
||||
|
||||
import smtplib
|
||||
import logging
|
||||
from typing import AnyStr, List
|
||||
|
||||
import lacre.config as conf
|
||||
from lacre.mailop import MailSerialisationException
|
||||
|
||||
# Mail status constants.
|
||||
#
|
||||
# These are the only values that our mail handler is allowed to return.
|
||||
RESULT_OK = '250 OK'
|
||||
RESULT_TRANS_FAIL = '451 Aborted: error in processing'
|
||||
RESULT_PERM_FAIL = '554 Transaction failed'
|
||||
|
||||
# See RFC 5321, section 4.2.1 "Reply Code Severities and Theory" for more
|
||||
# information on SMTP reply codes.
|
||||
RESP_TRANSIENT_NEG = 4
|
||||
RESP_PERMANENT_NEG = 5
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
# This is a left-over from old architecture.
|
||||
from_addr = None
|
||||
|
||||
|
||||
def register_sender(fromaddr):
|
||||
"""Set module state: message sender address."""
|
||||
global from_addr
|
||||
LOG.warning('Setting global recipient: %s', fromaddr)
|
||||
from_addr = fromaddr
|
||||
|
||||
|
||||
def send_msg(message: AnyStr, recipients: List[str]):
|
||||
"""Send MESSAGE to RECIPIENTS to the mail relay."""
|
||||
global from_addr
|
||||
LOG.debug('Delivery from %s to %s', from_addr, recipients)
|
||||
|
||||
recipients = [_f for _f in recipients if _f]
|
||||
if recipients:
|
||||
LOG.info(f"Sending email to: {recipients!r}")
|
||||
relay = conf.relay_params()
|
||||
smtp = smtplib.SMTP(relay.name, relay.port)
|
||||
if conf.flag_enabled('relay', 'starttls'):
|
||||
smtp.starttls()
|
||||
smtp.sendmail(from_addr, recipients, message)
|
||||
else:
|
||||
LOG.info("No recipient found")
|
||||
|
||||
|
||||
class TransientFailure(BaseException):
|
||||
"""Signals a transient delivery failure (4xx SMTP reply).
|
||||
|
||||
Message should be bounced and re-sent later.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class PermanentFailure(BaseException):
|
||||
"""Signals a permanent delivery failure (5xx SMTP reply)."""
|
||||
pass
|
||||
|
||||
|
||||
class SendFrom:
|
||||
"""A class wrapping the transport process."""
|
||||
|
||||
def __init__(self, from_addr):
|
||||
"""Initialise the transport."""
|
||||
self._from_addr = from_addr
|
||||
|
||||
def __call__(self, message: AnyStr, recipients: List[str]):
|
||||
"""Send the given message to all recipients from the list.
|
||||
|
||||
- Message is the email object serialised to str or bytes.
|
||||
- Empty recipients are filtered out before communication.
|
||||
"""
|
||||
recipients = [_f for _f in recipients if _f]
|
||||
|
||||
if not recipients:
|
||||
LOG.warning("No recipient found")
|
||||
return
|
||||
|
||||
LOG.info("Sending email to: %s", recipients)
|
||||
relay = conf.relay_params()
|
||||
smtp = smtplib.SMTP(relay.name, relay.port)
|
||||
|
||||
if conf.flag_enabled('relay', 'starttls'):
|
||||
smtp.starttls()
|
||||
|
||||
try:
|
||||
smtp.sendmail(self._from_addr, recipients, message)
|
||||
except smtplib.SMTPResponseException as re:
|
||||
resp_class = self._get_class(re.smtp_code)
|
||||
|
||||
if resp_class == RESP_TRANSIENT_NEG:
|
||||
LOG.warning('Transient delivery failure: %s', re)
|
||||
raise TransientFailure()
|
||||
elif resp_class == RESP_PERMANENT_NEG:
|
||||
LOG.error('Permanent delivery failure: %s', re)
|
||||
raise PermanentFailure()
|
||||
except smtplib.SMTPException as err:
|
||||
LOG.error('Failed to deliver message: %s', err)
|
||||
raise PermanentFailure()
|
||||
except UnicodeEncodeError as uee:
|
||||
LOG.error('Failed to deliver for non-SMTP reason', uee)
|
||||
raise MailSerialisationException(uee)
|
||||
|
||||
def _get_class(self, resp_code):
|
||||
return int(resp_code / 100)
|
|
@ -7,28 +7,37 @@ from M2Crypto import BIO, Rand, SMIME, X509
|
|||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
import logging
|
||||
# Read configuration from /etc/gpg-mailgate.conf
|
||||
_cfg = RawConfigParser()
|
||||
_cfg.read('/etc/gpg-mailgate.conf')
|
||||
cfg = dict()
|
||||
for sect in _cfg.sections():
|
||||
cfg[sect] = dict()
|
||||
for (name, value) in _cfg.items(sect):
|
||||
cfg[sect][name] = value
|
||||
|
||||
import lacre
|
||||
import lacre.config as conf
|
||||
def log(msg):
|
||||
if 'logging' in cfg and 'file' in cfg['logging']:
|
||||
if cfg['logging']['file'] == "syslog":
|
||||
syslog.syslog(syslog.LOG_INFO | syslog.LOG_MAIL, msg)
|
||||
else:
|
||||
logfile = open(cfg['logging']['file'], 'a')
|
||||
logfile.write(msg + "\n")
|
||||
logfile.close()
|
||||
|
||||
CERT_PATH = cfg['smime']['cert_path']+"/"
|
||||
|
||||
def send_msg( message, from_addr, recipients = None ):
|
||||
if conf.config_item_set('relay', 'host') and conf.config_item_set('relay', 'enc_port'):
|
||||
relay = (conf.get_item('relay', 'host'), int(conf.get_item('relay', 'enc_port')))
|
||||
|
||||
if 'relay' in cfg and 'host' in cfg['relay'] and 'enc_port' in cfg['relay']:
|
||||
relay = (cfg['relay']['host'], int(cfg['relay']['enc_port']))
|
||||
smtp = smtplib.SMTP(relay[0], relay[1])
|
||||
smtp.sendmail( from_addr, recipients, message.as_string() )
|
||||
else:
|
||||
LOG.info("Could not send mail due to wrong configuration")
|
||||
log("Could not send mail due to wrong configuration")
|
||||
|
||||
if __name__ == "__main__":
|
||||
# try:
|
||||
conf.load_config()
|
||||
lacre.init_logging(conf.get_item('logging', 'config'))
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
CERT_PATH = conf.get_item('smime', 'cert_path') + '/'
|
||||
|
||||
# Read e-mail from stdin
|
||||
raw = sys.stdin.read()
|
||||
register_msg = email.message_from_string( raw )
|
||||
|
@ -54,18 +63,18 @@ if __name__ == "__main__":
|
|||
break
|
||||
|
||||
if sign_part == None:
|
||||
LOG.info("Unable to find PKCS7 signature or public PGP key in registration email")
|
||||
log("Unable to find PKCS7 signature or public PGP key in registration email")
|
||||
|
||||
failure_msg = file( conf.get_item('mailregister', 'mail_templates') + "/registrationError.md").read()
|
||||
failure_msg = file( cfg['mailregister']['mail_templates'] + "/registrationError.md").read()
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = conf.get_item('mailregister', 'register_email')
|
||||
msg["From"] = cfg['mailregister']['register_email']
|
||||
msg["To"] = from_addr
|
||||
msg["Subject"] = "S/MIME / OpenPGP registration failed"
|
||||
|
||||
msg.attach(MIMEText(failure_msg, 'plain'))
|
||||
msg.attach(MIMEText(markdown.markdown(failure_msg), 'html'))
|
||||
|
||||
send_msg(msg, conf.get_item('mailregister', 'register_email'), [from_addr])
|
||||
send_msg(msg, cfg['mailregister']['register_email'], [from_addr])
|
||||
sys.exit(0)
|
||||
|
||||
if sign_type == 'smime':
|
||||
|
@ -96,42 +105,42 @@ if __name__ == "__main__":
|
|||
|
||||
# format in user-specific data
|
||||
# sending success mail only for S/MIME as GPGMW handles this on its own
|
||||
success_msg = file(conf.get_item('mailregister', 'mail_templates')+"/registrationSuccess.md").read()
|
||||
success_msg = file(cfg['mailregister']['mail_templates']+"/registrationSuccess.md").read()
|
||||
success_msg = success_msg.replace("[:FROMADDRESS:]", from_addr)
|
||||
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = conf.get_item('mailregister', 'register_email')
|
||||
msg["From"] = cfg['mailregister']['register_email']
|
||||
msg["To"] = from_addr
|
||||
msg["Subject"] = "S/MIME certificate registration succeeded"
|
||||
|
||||
msg.attach(MIMEText(success_msg, 'plain'))
|
||||
msg.attach(MIMEText(markdown.markdown(success_msg), 'html'))
|
||||
|
||||
send_msg(msg, conf.get_item('mailregister', 'register_email'), [from_addr])
|
||||
send_msg(msg, cfg['mailregister']['register_email'], [from_addr])
|
||||
|
||||
LOG.info("S/MIME Registration succeeded")
|
||||
log("S/MIME Registration succeeded")
|
||||
elif sign_type == 'pgp':
|
||||
# send POST to gpg-mailgate webpanel
|
||||
sig = sign_part
|
||||
payload = {'email': from_addr, 'key': sig}
|
||||
r = requests.post(conf.get_item('mailregister', 'webpanel_url'), data=payload)
|
||||
r = requests.post(cfg['mailregister']['webpanel_url'], data=payload)
|
||||
|
||||
if r.status_code != 200:
|
||||
LOG.info("Could not hand registration over to GPGMW. Error: %s" % r.status_code)
|
||||
error_msg = open(conf.get_item('mailregister', 'mail_templates')+"/gpgmwFailed.md").read()
|
||||
log("Could not hand registration over to GPGMW. Error: %s" % r.status_code)
|
||||
error_msg = file(cfg['mailregister']['mail_templates']+"/gpgmwFailed.md").read()
|
||||
error_msg = error_msg.replace("[:FROMADDRESS:]", from_addr)
|
||||
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = conf.get_item('mailregister', 'register_email')
|
||||
msg["From"] = cfg['mailregister']['register_email']
|
||||
msg["To"] = from_addr
|
||||
msg["Subject"] = "PGP key registration failed"
|
||||
|
||||
msg.attach(MIMEText(error_msg, 'plain'))
|
||||
msg.attach(MIMEText(markdown.markdown(error_msg), 'html'))
|
||||
|
||||
send_msg(msg, conf.get_item('mailregister', 'register_email'), [from_addr])
|
||||
send_msg(msg, cfg['mailregister']['register_email'], [from_addr])
|
||||
else:
|
||||
LOG.info("PGP registration is handed over to GPGMW")
|
||||
log("PGP registration is handed over to GPGMW")
|
||||
# except:
|
||||
# LOG.info("Registration exception")
|
||||
# log("Registration exception")
|
||||
# sys.exit(0)
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
aiosmtpd==1.4.2
|
||||
SQLAlchemy==2.0.29
|
||||
Markdown==3.4.1
|
||||
M2Crypto==0.38.0
|
||||
requests==2.27.1
|
|
@ -1,141 +0,0 @@
|
|||
#
|
||||
# lacre
|
||||
#
|
||||
# This file is part of the lacre source code.
|
||||
#
|
||||
# lacre is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# lacre source code is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with lacre source code. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
import configparser
|
||||
import logging
|
||||
import subprocess
|
||||
import os
|
||||
import time
|
||||
import unittest
|
||||
from typing import Dict
|
||||
|
||||
|
||||
def _spawn(cmd, *, env_add: Dict = None):
|
||||
env_dict = {
|
||||
"PATH": os.getenv("PATH"),
|
||||
"PYTHONPATH": os.getcwd(),
|
||||
"LANG": 'en_US.UTF-8',
|
||||
"LACRE_CONFIG": "test/lacre-daemon.conf"
|
||||
}
|
||||
if env_add:
|
||||
env_dict.update(env_add)
|
||||
|
||||
logging.debug(f"Spawning command: {cmd} with environment: {env_dict!r}")
|
||||
return subprocess.Popen(cmd,
|
||||
stdin=None,
|
||||
stdout=subprocess.PIPE,
|
||||
env=env_dict)
|
||||
|
||||
|
||||
def _interrupt(proc):
|
||||
proc.terminate()
|
||||
|
||||
|
||||
def _send(host, port, mail_from, mail_to, message):
|
||||
logging.debug(f"Sending message to {host}:{port}")
|
||||
python = os.getenv("PYTHON") or "python"
|
||||
p = _spawn([python,
|
||||
"test/utils/sendmail.py",
|
||||
"-f", mail_from,
|
||||
"-t", mail_to,
|
||||
"-m", message])
|
||||
|
||||
# Perform subprocess's internal resource management:
|
||||
p.communicate()
|
||||
|
||||
|
||||
def _load_test_config():
|
||||
cp = configparser.ConfigParser()
|
||||
cp.read("test/e2e.ini")
|
||||
return cp
|
||||
|
||||
|
||||
class AdvancedMailFilterE2ETest(unittest.TestCase):
|
||||
"""End-to-end tests for Advanced Mail Filter.
|
||||
|
||||
These tests are described by e2e.ini file, each case being a
|
||||
separate section. All cases are executed following the same
|
||||
procedure:
|
||||
1. start up a mail relay mock;
|
||||
2. load test message;
|
||||
3. send the message to the daemon;
|
||||
4. check if message received by relay mock meets criteria.
|
||||
|
||||
Before any case is executed, the daemon is started and finally it's
|
||||
terminated by sending it a SIGINT signal."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Start up the daemon."""
|
||||
cls.config = _load_test_config()
|
||||
|
||||
python = os.getenv("PYTHON", "python")
|
||||
|
||||
logging.info('Starting the server...')
|
||||
cls.server = _spawn([python, '-m', 'lacre.daemon'], env_add={'SQLALCHEMY_WARN_20': '1'})
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
"""Terminate the daemon."""
|
||||
logging.info('Closing the server (SIGINT): %s', (cls.server))
|
||||
_interrupt(cls.server)
|
||||
|
||||
def case_names(self):
|
||||
"""A generator yielding a sequence of test case names."""
|
||||
def is_test_case(case_name: str) -> bool:
|
||||
return case_name.startswith('case-')
|
||||
|
||||
for tc in filter(is_test_case, self.config.sections()):
|
||||
yield tc
|
||||
|
||||
def test_all_cases(self):
|
||||
for case_name in self.case_names():
|
||||
with self.subTest(case=case_name):
|
||||
self._execute_case(self.config, case_name=case_name)
|
||||
|
||||
def _execute_case(self, config, case_name):
|
||||
logging.info(f"Executing case {case_name}")
|
||||
python = os.getenv("PYTHON", "python")
|
||||
|
||||
relay_mock = _spawn([python, "test/utils/relay.py", "2500"])
|
||||
time.sleep(1) # Wait for the relay to start up.
|
||||
|
||||
_send("localhost", 10025, "dave@disposlab",
|
||||
config.get(case_name, 'to'), config.get(case_name, 'in'))
|
||||
|
||||
(test_out, _) = relay_mock.communicate()
|
||||
|
||||
test_out = test_out.decode('utf-8')
|
||||
logging.debug(f"Read {len(test_out)} characters of output: '{test_out}'")
|
||||
|
||||
if 'out' in config[case_name]:
|
||||
expected = '\r\n' + self.config.get(case_name, 'out')
|
||||
self.assertIn(expected, test_out, self.config.get(case_name, 'in'))
|
||||
else:
|
||||
unexpected = '\r\n' + self.config.get(case_name, 'out-not')
|
||||
self.assertNotIn(unexpected, test_out, self.config.get(case_name, 'in'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
logging.basicConfig(filename="test/logs/daemon-test.log",
|
||||
format="%(asctime)s %(pathname)s:%(lineno)d %(levelname)s [%(funcName)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
level=logging.DEBUG)
|
||||
|
||||
unittest.main()
|
111
test/e2e.ini
111
test/e2e.ini
|
@ -22,26 +22,19 @@
|
|||
|
||||
[relay]
|
||||
port: 2500
|
||||
script: test/utils/relay.py
|
||||
script: test/relay.py
|
||||
|
||||
[dirs]
|
||||
keys: test/keyhome
|
||||
certs: test/certs
|
||||
|
||||
[tests]
|
||||
# Number of "test-*" sections in this file, describing test cases.
|
||||
cases: 6
|
||||
e2e_log: test/logs/e2e.log
|
||||
e2e_log_format: %(asctime)s %(pathname)s:%(lineno)d %(levelname)s [%(funcName)s] %(message)s
|
||||
e2e_log_datefmt: %Y-%m-%d %H:%M:%S
|
||||
lacre_log: test/logs/lacre-simple.log
|
||||
log_config: test/lacre-logging.conf
|
||||
|
||||
# TEST IDENTITIES AND SETTINGS:
|
||||
#
|
||||
# Email Key Style
|
||||
# alice@disposlab RSA 3072 PGP/Inline
|
||||
# bob@disposlab ED25519 PGP/Inline
|
||||
# carlos@disposlab none PGP/Inline
|
||||
# evan@disposlab ED25519 PGP/MIME
|
||||
lacre_log: test/logs/gpg-mailgate.log
|
||||
|
||||
[case-1]
|
||||
descr: Clear text message to a user without a key
|
||||
|
@ -78,99 +71,3 @@ descr: Multipart encrypted message to a user with an Ed25519 key.
|
|||
to: bob@disposlab
|
||||
in: test/msgin/multipart2rsa.msg
|
||||
out: -----BEGIN PGP MESSAGE-----
|
||||
|
||||
[case-7]
|
||||
descr: Clear text message to a user with an RSA key and PGP/MIME enabled in configuration
|
||||
to: evan@disposlab
|
||||
in: test/msgin/clear2rsa2.msg
|
||||
out: -----BEGIN PGP MESSAGE-----
|
||||
|
||||
[case-8]
|
||||
descr: Clear text message to address with delimiter and a user with an Ed25519 key
|
||||
to: bob+foo@disposlab
|
||||
in: test/msgin/clear2ed-delim.msg
|
||||
out: -----BEGIN PGP MESSAGE-----
|
||||
|
||||
[case-9]
|
||||
descr: Clear text message with inline PGP markers to recipient with a key
|
||||
to: bob@disposlab
|
||||
in: test/msgin/with-markers2clear.msg
|
||||
out-not: This message includes inline PGP markers.
|
||||
|
||||
[case-10]
|
||||
descr: UTF-8 message (yields Base64)
|
||||
to: carlos@disposlab
|
||||
in: test/msgin/utf8.msg
|
||||
out: xYHEhENaTk/FmsSGLiBaYcW6w7PFgsSHIGfEmcWbbMSFIGphxbrFhC4=
|
||||
|
||||
[case-11]
|
||||
descr: Non-ASCII message (ISO-8859-2; yields quoted-printable)
|
||||
to: carlos@disposlab
|
||||
in: test/msgin/nonascii.msg
|
||||
out: =A3=A1CZNO=A6=C6.
|
||||
|
||||
[case-12]
|
||||
descr: multipart/alternative with UTF-8, not encrypted
|
||||
to: carlos@disposlab
|
||||
in: test/msgin/utf8-alternative.msg
|
||||
out-not: -----BEGIN PGP MESSAGE-----
|
||||
|
||||
[case-13]
|
||||
descr: multipart/alternative with UTF-8, encrypted
|
||||
to: evan@disposlab
|
||||
in: test/msgin/utf8-alternative.msg
|
||||
out: -----BEGIN PGP MESSAGE-----
|
||||
|
||||
[case-14]
|
||||
descr: Clear text with UTF-8, PGP/MIME
|
||||
to: evan@disposlab
|
||||
in: test/msgin/utf8-plain.msg
|
||||
out: Content-Type: application/pgp-encrypted
|
||||
|
||||
[case-15]
|
||||
descr: Clear text with UTF-8, PGP/Inline
|
||||
to: bob@disposlab
|
||||
in: test/msgin/utf8-plain.msg
|
||||
out: -----BEGIN PGP MESSAGE-----
|
||||
|
||||
[case-16]
|
||||
descr: HTML, cleartext
|
||||
to: carlos@disposlab
|
||||
in: test/msgin/html-utf8.msg
|
||||
out: PGh0bWw+CjxoZWFkPgo8L2hlYWQ+Cjxib2R5PgpaQcW7w5PFgcSGIEfEmMWaTMSEIEpBxbnFgy48
|
||||
|
||||
[case-17]
|
||||
descr: HTML, PGP/MIME
|
||||
to: evan@disposlab
|
||||
in: test/msgin/html-utf8.msg
|
||||
out: -----BEGIN PGP MESSAGE-----
|
||||
|
||||
[case-18]
|
||||
descr: HTML, PGP/Inline
|
||||
to: bob@disposlab
|
||||
in: test/msgin/html-utf8.msg
|
||||
out: -----BEGIN PGP MESSAGE-----
|
||||
|
||||
[case-19]
|
||||
descr: US-ASCII HTML, cleartext
|
||||
to: carlos@disposlab
|
||||
in: test/msgin/html-ascii.msg
|
||||
out: <html>
|
||||
|
||||
[case-20]
|
||||
descr: US-ASCII HTML, PGP/Inline
|
||||
to: bob@disposlab
|
||||
in: test/msgin/html-ascii.msg
|
||||
out: -----BEGIN PGP MESSAGE-----
|
||||
|
||||
[case-21]
|
||||
descr: US-ASCII HTML, PGP/MIME
|
||||
to: evan@disposlab
|
||||
in: test/msgin/html-ascii.msg
|
||||
out: -----BEGIN PGP MESSAGE-----
|
||||
|
||||
[case-22]
|
||||
descr: HTML with emoji, PGP/MIME
|
||||
to: evan@disposlab
|
||||
in: test/msgin/emoji.msg
|
||||
out: -----BEGIN PGP MESSAGE-----
|
||||
|
|
227
test/e2e_test.py
227
test/e2e_test.py
|
@ -1,49 +1,47 @@
|
|||
#
|
||||
# lacre
|
||||
# gpg-mailgate
|
||||
#
|
||||
# This file is part of the lacre source code.
|
||||
# This file is part of the gpg-mailgate source code.
|
||||
#
|
||||
# lacre is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
# gpg-mailgate is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# lacre source code is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
# gpg-mailgate source code is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with lacre source code. If not, see <http://www.gnu.org/licenses/>.
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with gpg-mailgate source code. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import subprocess
|
||||
|
||||
import difflib
|
||||
|
||||
import configparser
|
||||
import logging
|
||||
|
||||
import unittest
|
||||
from time import sleep
|
||||
|
||||
RELAY_SCRIPT = "test/relay.py"
|
||||
CONFIG_FILE = "test/gpg-mailgate.conf"
|
||||
|
||||
RELAY_SCRIPT = "test/utils/relay.py"
|
||||
CONFIG_FILE = "test/lacre.conf"
|
||||
|
||||
|
||||
def _build_config(config):
|
||||
cp = configparser.RawConfigParser()
|
||||
def build_config(config):
|
||||
cp = configparser.ConfigParser()
|
||||
|
||||
cp.add_section("logging")
|
||||
cp.set("logging", "config", config["log_config"])
|
||||
cp.set("logging", "file", config["log_file"])
|
||||
cp.set("logging", "verbose", "yes")
|
||||
|
||||
cp.add_section("gpg")
|
||||
cp.set("gpg", "keyhome", config["gpg_keyhome"])
|
||||
|
||||
cp.add_section('database')
|
||||
cp.set('database', 'enabled', 'yes')
|
||||
cp.set('database', 'url', 'sqlite:///test/lacre.db')
|
||||
cp.set('database', 'pooling_mode', 'optimistic')
|
||||
|
||||
cp.add_section("smime")
|
||||
cp.set("smime", "cert_path", config["smime_certpath"])
|
||||
|
||||
|
@ -51,135 +49,110 @@ def _build_config(config):
|
|||
cp.set("relay", "host", "localhost")
|
||||
cp.set("relay", "port", config["port"])
|
||||
|
||||
cp.add_section("daemon")
|
||||
cp.set("daemon", "host", "localhost")
|
||||
cp.set("daemon", "port", "10025")
|
||||
|
||||
cp.add_section("enc_keymap")
|
||||
cp.set("enc_keymap", "alice@disposlab", "1CD245308F0963D038E88357973CF4D9387C44D7")
|
||||
cp.set("enc_keymap", "bob@disposlab", "19CF4B47ECC9C47AFA84D4BD96F39FDA0E31BB67")
|
||||
cp.set("enc_keymap", "evan@disposlab", "530B1BB2D0CC7971648198BBA4774E507D3AF5BC")
|
||||
|
||||
cp.add_section("pgp_style")
|
||||
# Default style is PGP/Inline, so to cover more branches, one test identity
|
||||
# uses PGP/MIME.
|
||||
cp.set("pgp_style", "evan@disposlab", "mime")
|
||||
|
||||
logging.debug(f"Created config with keyhome={config['gpg_keyhome']}, cert_path={config['smime_certpath']} and relay at port {config['port']}")
|
||||
return cp
|
||||
|
||||
|
||||
def _write_test_config(outfile, **config):
|
||||
def write_test_config(outfile, **config):
|
||||
logging.debug(f"Generating configuration with {config!r}")
|
||||
|
||||
out = open(outfile, "w+")
|
||||
cp = _build_config(config)
|
||||
cp = build_config(config)
|
||||
cp.write(out)
|
||||
out.close()
|
||||
|
||||
logging.debug(f"Wrote configuration to {outfile}")
|
||||
|
||||
def load_file(name):
|
||||
f = open(name, 'r')
|
||||
contents = f.read()
|
||||
f.close()
|
||||
|
||||
def _load_file(name):
|
||||
f = open(name, 'rb')
|
||||
contents = f.read()
|
||||
f.close()
|
||||
return bytes(contents, 'utf-8')
|
||||
|
||||
return contents
|
||||
def report_result(message_file, expected, test_output):
|
||||
status = None
|
||||
if expected in test_output:
|
||||
status = "Success"
|
||||
else:
|
||||
status = "Failure"
|
||||
|
||||
print(message_file.ljust(30), status)
|
||||
|
||||
def _load_test_config():
|
||||
def execute_e2e_test(case_name, config, config_path):
|
||||
"""Read test case configuration from config and run that test case.
|
||||
|
||||
Parameter case_name should refer to a section in test
|
||||
config file. Each of these sections should contain
|
||||
following properties: 'descr', 'to', 'in' and 'out'.
|
||||
"""
|
||||
# This environment variable is set in Makefile.
|
||||
python_path = os.getenv('PYTHON', 'python3')
|
||||
|
||||
gpglacre_cmd = [python_path,
|
||||
"gpg-mailgate.py",
|
||||
config.get(case_name, "to")]
|
||||
|
||||
relay_cmd = [python_path,
|
||||
config.get("relay", "script"),
|
||||
config.get("relay", "port")]
|
||||
|
||||
logging.debug(f"Spawning relay: {relay_cmd}")
|
||||
relay_proc = subprocess.Popen(relay_cmd,
|
||||
stdin = None,
|
||||
stdout = subprocess.PIPE)
|
||||
|
||||
logging.debug(f"Spawning GPG-Lacre: {gpglacre_cmd}, stdin = {config.get(case_name, 'in')}")
|
||||
|
||||
# pass PATH because otherwise it would be dropped
|
||||
gpglacre_proc = subprocess.run(gpglacre_cmd,
|
||||
input = load_file(config.get(case_name, "in")),
|
||||
capture_output = True,
|
||||
env = {"GPG_MAILGATE_CONFIG": config_path,
|
||||
"PATH": os.getenv("PATH")})
|
||||
|
||||
# Let the relay process the data.
|
||||
relay_proc.wait()
|
||||
|
||||
(testout, _) = relay_proc.communicate()
|
||||
testout = testout.decode('utf-8')
|
||||
|
||||
logging.debug(f"Read {len(testout)} characters of test output: '{testout}'")
|
||||
|
||||
report_result(config.get(case_name, "in"), config.get(case_name, "out"), testout)
|
||||
|
||||
def load_test_config():
|
||||
cp = configparser.ConfigParser()
|
||||
cp.read("test/e2e.ini")
|
||||
|
||||
return cp
|
||||
|
||||
|
||||
class SimpleMailFilterE2ETest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls._e2e_config = _load_test_config()
|
||||
cls._e2e_config_path = os.path.join(os.getcwd(), CONFIG_FILE)
|
||||
config = load_test_config()
|
||||
|
||||
# This environment variable is set in Makefile.
|
||||
cls._python_path = os.getenv('PYTHON', 'python')
|
||||
logging.basicConfig(filename = config.get("tests", "e2e_log"),
|
||||
# Get raw values of log and date formats because they
|
||||
# contain %-sequences and we don't want them to be expanded
|
||||
# by the ConfigParser.
|
||||
format = config.get("tests", "e2e_log_format", raw=True),
|
||||
datefmt = config.get("tests", "e2e_log_datefmt", raw=True),
|
||||
level = logging.DEBUG)
|
||||
|
||||
_write_test_config(cls._e2e_config_path,
|
||||
port = cls._e2e_config.get("relay", "port"),
|
||||
gpg_keyhome = cls._e2e_config.get("dirs", "keys"),
|
||||
smime_certpath = cls._e2e_config.get("dirs", "certs"),
|
||||
log_config = cls._e2e_config.get("tests", "log_config"))
|
||||
config_path = os.getcwd() + "/" + CONFIG_FILE
|
||||
|
||||
def case_names(self):
|
||||
def is_test_case(case_name: str) -> bool:
|
||||
return case_name.startswith('case-')
|
||||
write_test_config(config_path,
|
||||
port = config.get("relay", "port"),
|
||||
gpg_keyhome = config.get("dirs", "keys"),
|
||||
smime_certpath = config.get("dirs", "certs"),
|
||||
log_file = config.get("tests", "lacre_log"))
|
||||
|
||||
for tc in filter(is_test_case, self._e2e_config.sections()):
|
||||
yield tc
|
||||
for case_no in range(1, config.getint("tests", "cases")+1):
|
||||
case_name = f"case-{case_no}"
|
||||
logging.info(f"Executing {case_name}: {config.get(case_name, 'descr')}")
|
||||
|
||||
def test_all_cases(self):
|
||||
for case_name in self.case_names():
|
||||
with self.subTest(case=case_name):
|
||||
self._execute_e2e_test(case_name)
|
||||
execute_e2e_test(case_name, config, config_path)
|
||||
|
||||
def _execute_e2e_test(self, case_name):
|
||||
"""Read test case configuration from config and run that test case.
|
||||
|
||||
Parameter case_name should refer to a section in test
|
||||
config file. Each of these sections should contain
|
||||
following properties: 'descr', 'to', 'in' and 'out'.
|
||||
"""
|
||||
gpglacre_cmd = self._python_command(
|
||||
'lacre.py',
|
||||
self._e2e_config.get(case_name, 'to'))
|
||||
|
||||
relay_cmd = self._python_command(
|
||||
self._e2e_config.get("relay", "script"),
|
||||
self._e2e_config.get("relay", "port"))
|
||||
|
||||
logging.debug(f"Spawning relay: {relay_cmd}")
|
||||
relay_proc = subprocess.Popen(relay_cmd,
|
||||
stdin=None,
|
||||
stdout=subprocess.PIPE)
|
||||
|
||||
logging.debug(f"Spawning GPG-Lacre: {gpglacre_cmd}, stdin = {self._e2e_config.get(case_name, 'in')}")
|
||||
|
||||
# pass PATH because otherwise it would be dropped
|
||||
gpglacre_proc = subprocess.run(gpglacre_cmd,
|
||||
input=_load_file(self._e2e_config.get(case_name, "in")),
|
||||
capture_output=True,
|
||||
env={"LACRE_CONFIG": self._e2e_config_path,
|
||||
"PATH": os.getenv("PATH")})
|
||||
|
||||
# Let the relay process the data.
|
||||
relay_proc.wait()
|
||||
|
||||
(testout, _) = relay_proc.communicate()
|
||||
testout = testout.decode('utf-8')
|
||||
|
||||
logging.debug(f"Read {len(testout)} characters of test output: '{testout}'")
|
||||
|
||||
if 'out' in self._e2e_config[case_name]:
|
||||
expected = "\r\n" + self._e2e_config.get(case_name, "out")
|
||||
self.assertIn(expected, testout, self._e2e_config.get(case_name, "in"))
|
||||
else:
|
||||
unexpected = "\r\n" + self._e2e_config.get(case_name, "out-not")
|
||||
self.assertNotIn(unexpected, testout, self._e2e_config.get(case_name, "in"))
|
||||
|
||||
def _python_command(self, script, *args):
|
||||
command = [self._python_path, script]
|
||||
command.extend(args)
|
||||
return command
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
config = _load_test_config()
|
||||
|
||||
logging.basicConfig(filename = config.get("tests", "e2e_log"),
|
||||
# Get raw values of log and date formats because they
|
||||
# contain %-sequences and we don't want them to be
|
||||
# expanded by the ConfigParser.
|
||||
format = config.get("tests", "e2e_log_format", raw=True),
|
||||
datefmt = config.get("tests", "e2e_log_datefmt", raw=True),
|
||||
level = logging.DEBUG)
|
||||
|
||||
unittest.main()
|
||||
print("See diagnostic output for details. Tests: '%s', Lacre: '%s'" % (config.get("tests", "e2e_log"), config.get("tests", "lacre_log")))
|
||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -1,13 +0,0 @@
|
|||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
mDMEYdTFkRYJKwYBBAHaRw8BAQdA2tgdP1pMt3cv3XAW7ov5AFn74mMZvyTksp9Q
|
||||
eO1PkpK0GkJvYiBGb29iYXIgPGJvYkBkaXNwb3NsYWI+iJYEExYIAD4WIQQZz0tH
|
||||
7MnEevqE1L2W85/aDjG7ZwUCYdTFkQIbAwUJA8JnAAULCQgHAgYVCgkICwIEFgID
|
||||
AQIeAQIXgAAKCRCW85/aDjG7ZxVnAP49t7BU2H+/WCpa3fCAlMEcik82sU4p+U9D
|
||||
pMsbjawwYgEA1SbA5CF835cMjoEufy1h+2M4T9gI/0X2lk8OAtwwggm4OARh1MXg
|
||||
EgorBgEEAZdVAQUBAQdAUVNKx2OsGtNdRsnl3J/uv6obkUC0KcO4ikdRs+iejlMD
|
||||
AQgHiHgEGBYIACAWIQQZz0tH7MnEevqE1L2W85/aDjG7ZwUCYdTF4AIbDAAKCRCW
|
||||
85/aDjG7Z039APwLGP5ibqCC9yIr4YVbdWff1Ch+2C91MR2ObF93Up9+ogD8D2zd
|
||||
OjjB6xRD0Q2FN+alsNGCtdutAs18AZ5l33RMzws=
|
||||
=wWoq
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
|
@ -1,34 +0,0 @@
|
|||
[logging]
|
||||
config = test/lacre-logging.conf
|
||||
file = test/logs/gpg-mailgate.log
|
||||
format = %(asctime)s %(module)s[%(process)d]: %(message)s
|
||||
date_format = ISO
|
||||
|
||||
[gpg]
|
||||
keyhome = test/keyhome
|
||||
|
||||
[smime]
|
||||
cert_path = test/certs
|
||||
|
||||
[daemon]
|
||||
host = not_used
|
||||
port = not_used
|
||||
|
||||
[database]
|
||||
enabled = yes
|
||||
url = sqlite:///test/lacre.db
|
||||
pooling_mode = optimistic
|
||||
max_connection_age = 3600
|
||||
|
||||
[relay]
|
||||
host = localhost
|
||||
port = 2500
|
||||
|
||||
[cron]
|
||||
send_email = no
|
||||
mail_templates = not_used
|
||||
|
||||
[enc_keymap]
|
||||
alice@disposlab = 1CD245308F0963D038E88357973CF4D9387C44D7
|
||||
bob@disposlab = 19CF4B47ECC9C47AFA84D4BD96F39FDA0E31BB67
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
[logging]
|
||||
config = test/lacre-logging.conf
|
||||
file = test/logs/gpg-mailgate.log
|
||||
format = %(asctime)s %(module)s[%(process)d]: %(message)s
|
||||
date_format = ISO
|
||||
|
||||
[gpg]
|
||||
keyhome = test/keyhome
|
||||
|
||||
[smime]
|
||||
cert_path = test/certs
|
||||
|
||||
[database]
|
||||
enabled = yes
|
||||
url = sqlite:///test/lacre.db
|
||||
pooling_mode = optimistic
|
||||
|
||||
[relay]
|
||||
host = localhost
|
||||
port = 2500
|
||||
|
||||
[daemon]
|
||||
host = localhost
|
||||
port = 10025
|
||||
log_headers = yes
|
||||
|
||||
[cron]
|
||||
send_email = no
|
||||
mail_templates = not_used
|
||||
|
||||
[pgp_style]
|
||||
# this recipient has PGP/MIME enabled, because the default approach is to use
|
||||
# PGP/Inline
|
||||
evan@disposlab = mime
|
||||
|
||||
[enc_keymap]
|
||||
alice@disposlab = 1CD245308F0963D038E88357973CF4D9387C44D7
|
||||
bob@disposlab = 19CF4B47ECC9C47AFA84D4BD96F39FDA0E31BB67
|
|
@ -1,24 +0,0 @@
|
|||
[loggers]
|
||||
keys=root
|
||||
|
||||
[logger_root]
|
||||
level=NOTSET
|
||||
handlers=lacrelog
|
||||
|
||||
[handlers]
|
||||
keys=lacrelog
|
||||
|
||||
[formatters]
|
||||
keys=postfixfmt
|
||||
|
||||
[handler_lacrelog]
|
||||
class=FileHandler
|
||||
level=DEBUG
|
||||
formatter=postfixfmt
|
||||
args=('test/logs/lacre.log', 'a+')
|
||||
|
||||
[formatter_postfixfmt]
|
||||
format=%(asctime)s %(name)s[%(process)d]: %(message)s
|
||||
datefmt=%b %e %H:%M:%S
|
||||
style=%
|
||||
validate=True
|
|
@ -1,314 +0,0 @@
|
|||
#
|
||||
# lacre
|
||||
#
|
||||
# This file is part of the lacre source code.
|
||||
#
|
||||
# lacre is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# lacre source code is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with lacre source code. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
"""Unit-tests as contracts for external dependencies.
|
||||
|
||||
Unit tests defined here are our contracts for the dependencies used by Lacre.
|
||||
Since not all software is documented thoroughly, they are also a form of
|
||||
documentation.
|
||||
"""
|
||||
|
||||
import email
|
||||
import email.mime.multipart
|
||||
from email.message import EmailMessage
|
||||
from email.policy import SMTP, SMTPUTF8
|
||||
from email.errors import HeaderParseError
|
||||
|
||||
import unittest
|
||||
from configparser import RawConfigParser
|
||||
|
||||
|
||||
class EmailParsingTest(unittest.TestCase):
|
||||
"""This test serves as a package contract and documentation of its behaviour."""
|
||||
|
||||
def test_message_from_bytes_produces_message_with_str_headers(self):
|
||||
rawmsg = b"From: alice@lacre.io\r\n" \
|
||||
+ b"To: bob@lacre.io\r\n" \
|
||||
+ b"Subject: Test message\r\n" \
|
||||
+ b"\r\n" \
|
||||
+ b"Test message from Alice to Bob.\r\n"
|
||||
|
||||
parsed = email.message_from_bytes(rawmsg)
|
||||
|
||||
self.assertEqual(parsed["From"], "alice@lacre.io")
|
||||
self.assertEqual(parsed["To"], "bob@lacre.io")
|
||||
self.assertEqual(parsed["Subject"], "Test message")
|
||||
|
||||
def test_bytes_message_payload_decoded_produces_bytes(self):
|
||||
rawmsg = b"From: alice@lacre.io\r\n" \
|
||||
+ b"To: bob@lacre.io\r\n" \
|
||||
+ b"Subject: Test message\r\n" \
|
||||
+ b"\r\n" \
|
||||
+ b"Test message from Alice to Bob.\r\n"
|
||||
|
||||
parsed = email.message_from_bytes(rawmsg)
|
||||
|
||||
self.assertEqual(parsed["From"], "alice@lacre.io")
|
||||
self.assertEqual(parsed.get_payload(), "Test message from Alice to Bob.\r\n")
|
||||
self.assertEqual(parsed.get_payload(decode=True), b"Test message from Alice to Bob.\r\n")
|
||||
|
||||
def test_message_from_string_produces_message_with_str_headers(self):
|
||||
rawmsg = "From: alice@lacre.io\r\n" \
|
||||
+ "To: bob@lacre.io\r\n" \
|
||||
+ "Subject: Test message\r\n" \
|
||||
+ "\r\n" \
|
||||
+ "Test message from Alice to Bob.\r\n"
|
||||
|
||||
parsed = email.message_from_string(rawmsg)
|
||||
|
||||
self.assertEqual(parsed["From"], "alice@lacre.io")
|
||||
self.assertEqual(parsed["To"], "bob@lacre.io")
|
||||
self.assertEqual(parsed["Subject"], "Test message")
|
||||
|
||||
def test_str_base64_payload(self):
|
||||
rawmsg = "From: alice@lacre.io\r\n" \
|
||||
+ "To: bob@lacre.io\r\n" \
|
||||
+ "Subject: Test message\r\n" \
|
||||
+ "Content-Type: text/plain\r\n" \
|
||||
+ "Content-Transfer-Encoding: base64\r\n" \
|
||||
+ "\r\n" \
|
||||
+ "VGVzdCBtZXNzYWdlIGZyb20gQWxpY2UgdG8gQm9iLgo=\r\n"
|
||||
|
||||
parsed: EmailMessage = email.message_from_string(rawmsg, policy=SMTP)
|
||||
|
||||
self.assertEqual(parsed.get_payload(decode=False),
|
||||
"VGVzdCBtZXNzYWdlIGZyb20gQWxpY2UgdG8gQm9iLgo=\r\n")
|
||||
self.assertEqual(parsed.get_payload(decode=True),
|
||||
b"Test message from Alice to Bob.\n")
|
||||
self.assertEqual(parsed.get_content(),
|
||||
"Test message from Alice to Bob.\n")
|
||||
|
||||
def test_bytes_base64_payload(self):
|
||||
rawmsg = b"From: alice@lacre.io\r\n" \
|
||||
+ b"To: bob@lacre.io\r\n" \
|
||||
+ b"Subject: Test message\r\n" \
|
||||
+ b"Content-Type: application/octet-stream\r\n" \
|
||||
+ b"Content-Transfer-Encoding: base64\r\n" \
|
||||
+ b"\r\n" \
|
||||
+ b"VGVzdCBtZXNzYWdlIGZyb20gQWxpY2UgdG8gQm9iLgo=\r\n"
|
||||
|
||||
parsed: EmailMessage = email.message_from_bytes(rawmsg, policy=SMTP)
|
||||
|
||||
self.assertEqual(parsed.get_payload(decode=False),
|
||||
"VGVzdCBtZXNzYWdlIGZyb20gQWxpY2UgdG8gQm9iLgo=\r\n")
|
||||
self.assertEqual(parsed.get_payload(decode=True),
|
||||
b"Test message from Alice to Bob.\n")
|
||||
self.assertEqual(parsed.get_content(),
|
||||
b"Test message from Alice to Bob.\n")
|
||||
|
||||
def test_multipart_parser(self):
|
||||
rawmsg = b"Content-Type: multipart/mixed; boundary=XXXXXXXX\r\n" \
|
||||
+ b"\r\n" \
|
||||
+ b"--XXXXXXXX\r\n" \
|
||||
+ b"Content-Type: application/octet-stream\r\n" \
|
||||
+ b"Content-Transfer-Encoding: base64\r\n" \
|
||||
+ b"\r\n" \
|
||||
+ b"VGVzdCBtZXNzYWdlIGZyb20gQWxpY2UgdG8gQm9iLgo=\r\n" \
|
||||
+ b"\r\n" \
|
||||
+ b"--XXXXXXXX\r\n" \
|
||||
+ b"Content-Type: application/octet-stream\r\n" \
|
||||
+ b"Content-Transfer-Encoding: base64\r\n" \
|
||||
+ b"\r\n" \
|
||||
+ b"SGVsbG8sIFdvcmxkIQo=\r\n" \
|
||||
+ b"\r\n" \
|
||||
+ b"--XXXXXXXX--\r\n"
|
||||
|
||||
parsed: EmailMessage = email.message_from_bytes(rawmsg, policy=SMTP)
|
||||
|
||||
self.assertRaises(KeyError, parsed.get_content)
|
||||
self.assertEqual(parsed.get_payload(0).get_content(),
|
||||
b'Test message from Alice to Bob.\n')
|
||||
self.assertEqual(parsed.get_payload(1).get_content(),
|
||||
b'Hello, World!\n')
|
||||
|
||||
def test_headers_only_returns_bytes_payload(self):
|
||||
rawmsg = b"From: alice@lacre.io\r\n" \
|
||||
+ b"To: bob@lacre.io\r\n" \
|
||||
+ b"Subject: Test message\r\n" \
|
||||
+ b"Content-Type: text/plain\r\n" \
|
||||
+ b"Content-Transfer-Encoding: base64\r\n" \
|
||||
+ b"\r\n" \
|
||||
+ b"SGVsbG8sIFdvcmxkIQo=\r\n"
|
||||
|
||||
parser = email.parser.BytesHeaderParser()
|
||||
parsed = parser.parsebytes(rawmsg)
|
||||
|
||||
self.assertEqual(parsed.get_payload(decode=False), "SGVsbG8sIFdvcmxkIQo=\r\n")
|
||||
self.assertEqual(parsed.get_payload(decode=True), b"Hello, World!\n")
|
||||
|
||||
def test_headers_only_produces_single_payload_for_multipart(self):
|
||||
msg = None
|
||||
with open('test/msgin/utf8-alternative.msg', 'rb') as f:
|
||||
p = email.parser.BytesHeaderParser()
|
||||
msg = p.parse(f)
|
||||
|
||||
payload = msg.get_payload()
|
||||
|
||||
# Taken from test/msgin/utf8-alternative.msg:
|
||||
message_boundary = '6s7R3c0y2W8qiD7cU3iWyXcw'
|
||||
|
||||
self.assertIsInstance(payload, str)
|
||||
self.assertTrue(message_boundary in payload)
|
||||
|
||||
def test_fail_if_message_id_parsing_is_fixed(self):
|
||||
# Unfortunately, Microsoft sends messages with Message-Id header values
|
||||
# that email parser can't process.
|
||||
#
|
||||
# Bug: https://github.com/python/cpython/issues/105802
|
||||
# Fix: https://github.com/python/cpython/pull/108133
|
||||
|
||||
rawmsg = b"From: alice@lacre.io\r\n" \
|
||||
+ b"To: bob@lacre.io\r\n" \
|
||||
+ b"Subject: Test message\r\n" \
|
||||
+ b"Content-Type: text/plain\r\n" \
|
||||
+ b"Content-Transfer-Encoding: base64\r\n" \
|
||||
+ b"Message-Id: <[yada-yada-yada@microsoft.com]>\r\n" \
|
||||
+ b"\r\n" \
|
||||
+ b"SGVsbG8sIFdvcmxkIQo=\r\n"
|
||||
|
||||
msg = email.message_from_bytes(rawmsg, policy=SMTPUTF8)
|
||||
self.assertEqual(len(msg.defects), 0)
|
||||
self.assertRaises(IndexError, lambda: msg['Message-Id'])
|
||||
|
||||
def test_headersonly_text_plain(self):
|
||||
rawmsg = b"From: alice@lacre.io\r\n" \
|
||||
+ b"To: bob@lacre.io\r\n" \
|
||||
+ b"Subject: Test message\r\n" \
|
||||
+ b"Content-Type: text/plain\r\n" \
|
||||
+ b"Content-Transfer-Encoding: base64\r\n" \
|
||||
+ b"Message-Id: <[yada-yada-yada@microsoft.com]>\r\n" \
|
||||
+ b"\r\n" \
|
||||
+ b"SGVsbG8sIFdvcmxkIQo=\r\n"
|
||||
|
||||
from email.parser import BytesHeaderParser
|
||||
msg_headers_only = BytesHeaderParser(policy=SMTPUTF8).parsebytes(rawmsg)
|
||||
|
||||
self.assertEqual(msg_headers_only['From'], 'alice@lacre.io')
|
||||
self.assertEqual(msg_headers_only.get_body().as_bytes(), rawmsg)
|
||||
self.assertEqual(msg_headers_only.get_payload(), 'SGVsbG8sIFdvcmxkIQo=\r\n')
|
||||
|
||||
def test_headersonly_multipart_mixed(self):
|
||||
rawmsg = b"From: eva@lacre.io\r\n" \
|
||||
+ b"Content-Type: multipart/mixed; boundary=XXXXXXXX\r\n" \
|
||||
+ b"\r\n" \
|
||||
+ b"--XXXXXXXX\r\n" \
|
||||
+ b"Content-Type: application/octet-stream\r\n" \
|
||||
+ b"Content-Transfer-Encoding: base64\r\n" \
|
||||
+ b"\r\n" \
|
||||
+ b"VGVzdCBtZXNzYWdlIGZyb20gQWxpY2UgdG8gQm9iLgo=\r\n" \
|
||||
+ b"\r\n" \
|
||||
+ b"--XXXXXXXX\r\n" \
|
||||
+ b"Content-Type: application/octet-stream\r\n" \
|
||||
+ b"Content-Transfer-Encoding: base64\r\n" \
|
||||
+ b"\r\n" \
|
||||
+ b"SGVsbG8sIFdvcmxkIQo=\r\n" \
|
||||
+ b"\r\n" \
|
||||
+ b"--XXXXXXXX--\r\n"
|
||||
|
||||
message_body = "--XXXXXXXX\r\n" \
|
||||
+ "Content-Type: application/octet-stream\r\n" \
|
||||
+ "Content-Transfer-Encoding: base64\r\n" \
|
||||
+ "\r\n" \
|
||||
+ "VGVzdCBtZXNzYWdlIGZyb20gQWxpY2UgdG8gQm9iLgo=\r\n" \
|
||||
+ "\r\n" \
|
||||
+ "--XXXXXXXX\r\n" \
|
||||
+ "Content-Type: application/octet-stream\r\n" \
|
||||
+ "Content-Transfer-Encoding: base64\r\n" \
|
||||
+ "\r\n" \
|
||||
+ "SGVsbG8sIFdvcmxkIQo=\r\n" \
|
||||
+ "\r\n" \
|
||||
+ "--XXXXXXXX--\r\n"
|
||||
|
||||
from email.parser import BytesHeaderParser
|
||||
msg_headers_only = BytesHeaderParser(policy=SMTPUTF8).parsebytes(rawmsg)
|
||||
|
||||
self.assertEqual(msg_headers_only['From'], 'eva@lacre.io')
|
||||
self.assertIsNone(msg_headers_only.get_body())
|
||||
self.assertEqual(msg_headers_only.get_payload(), message_body)
|
||||
self.assertRaises(KeyError, lambda: msg_headers_only.get_content())
|
||||
self.assertFalse(msg_headers_only.is_multipart())
|
||||
|
||||
def test_headersonly_multipart_alternative(self):
|
||||
rawmsg = b"From: eva@lacre.io\r\n" \
|
||||
+ b"Content-Type: multipart/alternative; boundary=XXXXXXXX\r\n" \
|
||||
+ b"\r\n" \
|
||||
+ b"--XXXXXXXX\r\n" \
|
||||
+ b"Content-Type: application/octet-stream\r\n" \
|
||||
+ b"Content-Transfer-Encoding: base64\r\n" \
|
||||
+ b"\r\n" \
|
||||
+ b"VGVzdCBtZXNzYWdlIGZyb20gQWxpY2UgdG8gQm9iLgo=\r\n" \
|
||||
+ b"\r\n" \
|
||||
+ b"--XXXXXXXX\r\n" \
|
||||
+ b"Content-Type: application/octet-stream\r\n" \
|
||||
+ b"Content-Transfer-Encoding: base64\r\n" \
|
||||
+ b"\r\n" \
|
||||
+ b"SGVsbG8sIFdvcmxkIQo=\r\n" \
|
||||
+ b"\r\n" \
|
||||
+ b"--XXXXXXXX--\r\n"
|
||||
|
||||
message_body = "--XXXXXXXX\r\n" \
|
||||
+ "Content-Type: application/octet-stream\r\n" \
|
||||
+ "Content-Transfer-Encoding: base64\r\n" \
|
||||
+ "\r\n" \
|
||||
+ "VGVzdCBtZXNzYWdlIGZyb20gQWxpY2UgdG8gQm9iLgo=\r\n" \
|
||||
+ "\r\n" \
|
||||
+ "--XXXXXXXX\r\n" \
|
||||
+ "Content-Type: application/octet-stream\r\n" \
|
||||
+ "Content-Transfer-Encoding: base64\r\n" \
|
||||
+ "\r\n" \
|
||||
+ "SGVsbG8sIFdvcmxkIQo=\r\n" \
|
||||
+ "\r\n" \
|
||||
+ "--XXXXXXXX--\r\n"
|
||||
|
||||
from email.parser import BytesHeaderParser
|
||||
msg_headers_only = BytesHeaderParser(policy=SMTPUTF8).parsebytes(rawmsg)
|
||||
|
||||
self.assertEqual(msg_headers_only['From'], 'eva@lacre.io')
|
||||
self.assertIsNone(msg_headers_only.get_body())
|
||||
self.assertEqual(msg_headers_only.get_payload(), message_body)
|
||||
self.assertRaises(KeyError, lambda: msg_headers_only.get_content())
|
||||
self.assertFalse(msg_headers_only.is_multipart())
|
||||
|
||||
|
||||
class EmailTest(unittest.TestCase):
|
||||
def test_boundary_generated_after_as_string_call(self):
|
||||
mp = email.mime.multipart.MIMEMultipart()
|
||||
self.assertTrue(mp.get_boundary() is None)
|
||||
_ = mp.as_string()
|
||||
self.assertFalse(mp.get_boundary() is None)
|
||||
|
||||
def test_content_type_params_include_mime_type(self):
|
||||
p = email.message.MIMEPart()
|
||||
p.set_type('text/plain')
|
||||
p.set_param('charset', 'UTF-8')
|
||||
p.set_param('format', 'flowed')
|
||||
|
||||
self.assertIn(('text/plain', ''), p.get_params())
|
||||
|
||||
|
||||
class RawConfigParserTest(unittest.TestCase):
|
||||
def test_config_parser_returns_str(self):
|
||||
cp = RawConfigParser()
|
||||
cp.read("test/sample.ini")
|
||||
self.assertEqual(cp.get("foo", "bar"), "quux")
|
||||
self.assertEqual(cp.get("foo", "baz"), "14")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
|
@ -1,96 +0,0 @@
|
|||
import GnuPG
|
||||
import logging
|
||||
|
||||
import unittest
|
||||
|
||||
class GnuPGUtilitiesTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# Record GnuPG logs:
|
||||
logging.basicConfig(filename='test/logs/unittest.log', level=logging.DEBUG,
|
||||
format='%(asctime)s %(pathname)s:%(lineno)d %(levelname)s [%(funcName)s] %(message)s')
|
||||
|
||||
def test_build_default_command(self):
|
||||
cmd = GnuPG._build_command("test/keyhome")
|
||||
self.assertEqual(cmd, ["gpg", "--homedir", "test/keyhome"])
|
||||
|
||||
def test_build_command_extended_with_args(self):
|
||||
cmd = GnuPG._build_command("test/keyhome", "--foo", "--bar")
|
||||
self.assertEqual(cmd, ["gpg", "--homedir", "test/keyhome", "--foo", "--bar"])
|
||||
|
||||
def test_key_confirmation_with_matching_email(self):
|
||||
armored_key = self._load('test/keys/bob@disposlab.pub')
|
||||
matching_email = 'bob@disposlab'
|
||||
|
||||
is_confirmed = GnuPG.confirm_key(armored_key, matching_email)
|
||||
self.assertTrue(is_confirmed)
|
||||
|
||||
def test_key_confirmation_email_mismatch(self):
|
||||
armored_key = self._load('test/keys/bob@disposlab.pub')
|
||||
not_matching_email = 'lucy@disposlab'
|
||||
|
||||
is_confirmed = GnuPG.confirm_key(armored_key, not_matching_email)
|
||||
self.assertFalse(is_confirmed)
|
||||
|
||||
def test_key_listing(self):
|
||||
keys = GnuPG.public_keys('test/keyhome')
|
||||
|
||||
known_identities = {
|
||||
'1CD245308F0963D038E88357973CF4D9387C44D7': 'alice@disposlab',
|
||||
'19CF4B47ECC9C47AFA84D4BD96F39FDA0E31BB67': 'bob@disposlab',
|
||||
'530B1BB2D0CC7971648198BBA4774E507D3AF5BC': 'evan@disposlab'
|
||||
}
|
||||
|
||||
self.assertDictEqual(keys, known_identities)
|
||||
|
||||
def test_add_delete_key(self):
|
||||
self.assertDictEqual(GnuPG.public_keys('test/keyhome.other'), { })
|
||||
GnuPG.add_key('test/keyhome.other', self._load('test/keys/bob@disposlab.pub'))
|
||||
self.assertDictEqual(GnuPG.public_keys('test/keyhome.other'), {
|
||||
'19CF4B47ECC9C47AFA84D4BD96F39FDA0E31BB67': 'bob@disposlab',
|
||||
})
|
||||
GnuPG.delete_key('test/keyhome.other', 'bob@disposlab')
|
||||
self.assertDictEqual(GnuPG.public_keys('test/keyhome.other'), { })
|
||||
|
||||
def _load(self, filename):
|
||||
with open(filename) as f:
|
||||
return f.read()
|
||||
|
||||
def test_extract_fingerprint(self):
|
||||
sample_in = '''fpr:::::::::1CD245308F0963D038E88357973CF4D9387C44D7:'''
|
||||
fpr = GnuPG._extract_fingerprint(sample_in)
|
||||
self.assertEqual(fpr, '1CD245308F0963D038E88357973CF4D9387C44D7')
|
||||
|
||||
def test_parse_uid_line(self):
|
||||
sample_in = '''uid:e::::1624794010::C16E259AA1435947C6385B8160BC020B6C05EE18::alice@disposlab::::::::::0:'''
|
||||
uid = GnuPG._parse_uid_line(sample_in)
|
||||
self.assertEqual(uid, 'alice@disposlab')
|
||||
|
||||
def test_parse_statusfd_key_expired(self):
|
||||
key_expired = b"""
|
||||
[GNUPG:] KEYEXPIRED 1668272263
|
||||
[GNUPG:] KEY_CONSIDERED XXXXXXXXXXXXX 0
|
||||
[GNUPG:] INV_RECP 0 name@domain
|
||||
[GNUPG:] FAILURE encrypt 1
|
||||
"""
|
||||
|
||||
result = GnuPG.parse_status(key_expired)
|
||||
self.assertEqual(result['issue'], 'key expired')
|
||||
self.assertEqual(result['recipient'], b'name@domain')
|
||||
self.assertEqual(result['cause'], 'Unknown')
|
||||
self.assertEqual(result['key'], b'XXXXXXXXXXXXX')
|
||||
|
||||
def test_parse_statusfd_key_absent(self):
|
||||
non_specific_errors = b"""
|
||||
[GNUPG:] INV_RECP 0 name@domain
|
||||
[GNUPG:] FAILURE encrypt 1
|
||||
"""
|
||||
|
||||
result = GnuPG.parse_status(non_specific_errors)
|
||||
self.assertEqual(result['issue'], b'n/a')
|
||||
self.assertEqual(result['recipient'], b'name@domain')
|
||||
self.assertEqual(result['cause'], 'Unknown')
|
||||
self.assertEqual(result['key'], b'n/a')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
|
@ -1,10 +0,0 @@
|
|||
import unittest
|
||||
|
||||
import datetime
|
||||
import lacre.keymgmt as km
|
||||
|
||||
class KeyManagementUtilitiesTest(unittest.TestCase):
|
||||
def test_expiry_date_calculation(self):
|
||||
ts = datetime.datetime(2024, 1, 1, 12, 0)
|
||||
exp = km.calculate_expiry_date(ts)
|
||||
self.assertEqual(exp, datetime.datetime(2024, 1, 1, 11, 0))
|
|
@ -1,56 +0,0 @@
|
|||
import lacre.core
|
||||
from email.message import EmailMessage
|
||||
|
||||
import unittest
|
||||
|
||||
|
||||
class LacreCoreTest(unittest.TestCase):
|
||||
def test_attachment_handling(self):
|
||||
m = EmailMessage()
|
||||
m.set_payload('This is a payload')
|
||||
m.set_param('attachment', '', 'Content-Disposition')
|
||||
m.set_param('filename', 'foo', 'Content-Disposition')
|
||||
|
||||
lacre.core._append_gpg_extension(m)
|
||||
|
||||
self.assertEqual(m.get_filename(), 'foo.pgp')
|
||||
|
||||
def test_attachment_handling_2(self):
|
||||
m = EmailMessage()
|
||||
m.set_payload('This is a payload')
|
||||
m.set_param('attachment', '', 'Content-Disposition')
|
||||
m.set_param('name', 'quux', 'Content-Type')
|
||||
|
||||
lacre.core._append_gpg_extension(m)
|
||||
|
||||
self.assertEqual(m.get_filename(), 'quux.pgp')
|
||||
|
||||
def test_payload_wrapping(self):
|
||||
m = EmailMessage()
|
||||
m.set_payload('This is a payload.\r\n'
|
||||
+ '\r\n'
|
||||
+ 'It has two paragraphs.\r\n')
|
||||
m['Subject'] = 'Source message'
|
||||
m.set_type('text/plain')
|
||||
m.set_param('charset', 'utf-8')
|
||||
|
||||
rewrapped = lacre.core._rewrap_payload(m)
|
||||
|
||||
self.assertFalse('Subject' in rewrapped,
|
||||
'only content and content-type should be copied')
|
||||
self.assertEqual(rewrapped.get_content_type(), 'text/plain',
|
||||
'rewrapped part should have initial message\'s content-type')
|
||||
|
||||
def test_payload_wrapping_wo_content_type(self):
|
||||
m = EmailMessage()
|
||||
m.set_payload('This is a payload.\r\n'
|
||||
+ '\r\n'
|
||||
+ 'It has two paragraphs.\r\n')
|
||||
m['Subject'] = 'Source message'
|
||||
|
||||
rewrapped = lacre.core._rewrap_payload(m)
|
||||
|
||||
self.assertFalse('Subject' in rewrapped,
|
||||
'only content and content-type should be copied')
|
||||
self.assertEqual(rewrapped.get_content_type(), 'text/plain',
|
||||
'rewrapped part should have initial message\'s content-type')
|
|
@ -1,20 +0,0 @@
|
|||
from lacre.keyring import KeyCache
|
||||
|
||||
import unittest
|
||||
|
||||
class LacreKeyCacheTest(unittest.TestCase):
|
||||
def test_extend_keyring(self):
|
||||
kc = KeyCache({'FINGERPRINT': 'john.doe@example.com'})
|
||||
self.assertTrue('FINGERPRINT' in kc)
|
||||
|
||||
def test_membership_methods(self):
|
||||
kc = KeyCache({
|
||||
'FINGERPRINT': 'alice@example.com',
|
||||
'OTHERPRINT': 'bob@example.com'
|
||||
})
|
||||
|
||||
self.assertTrue('FINGERPRINT' in kc)
|
||||
self.assertFalse('FOOTPRINT' in kc)
|
||||
|
||||
self.assertTrue(kc.has_email('bob@example.com'))
|
||||
self.assertFalse(kc.has_email('dave@example.com'))
|
|
@ -1,17 +0,0 @@
|
|||
import lacre.recipients
|
||||
|
||||
import unittest
|
||||
|
||||
|
||||
class RecipientListTest(unittest.TestCase):
|
||||
def test_addition(self):
|
||||
a_list = lacre.recipients.RecipientList()
|
||||
a_list += lacre.recipients.GpgRecipient(
|
||||
'alice@disposlab',
|
||||
'1CD245308F0963D038E88357973CF4D9387C44D7')
|
||||
|
||||
emails = [x for x in a_list.emails()]
|
||||
keys = [x for x in a_list.keys()]
|
||||
|
||||
self.assertSequenceEqual(emails, ['alice@disposlab'])
|
||||
self.assertSequenceEqual(keys, ['1CD245308F0963D038E88357973CF4D9387C44D7'])
|
|
@ -1,24 +0,0 @@
|
|||
"""Lacre identity and key repository tests."""
|
||||
|
||||
import unittest
|
||||
|
||||
import lacre.config as conf
|
||||
import lacre.repositories as r
|
||||
import lacre.dbschema as s
|
||||
|
||||
def ignore_sql(sql, *args, **kwargs):
|
||||
pass
|
||||
|
||||
class IdentityRepositoryTest(unittest.TestCase):
|
||||
|
||||
def setUpClass():
|
||||
# required for init_engine to work
|
||||
conf.load_config()
|
||||
|
||||
def test_freeze_identities(self):
|
||||
eng = r.init_engine('sqlite:///test/lacre.db')
|
||||
|
||||
ir = r.IdentityRepository(engine=eng)
|
||||
identities = ir.freeze_identities()
|
||||
|
||||
self.assertTrue(identities)
|
|
@ -1,60 +0,0 @@
|
|||
import lacre.text
|
||||
import sys
|
||||
from email import message_from_binary_file
|
||||
from email.message import EmailMessage
|
||||
from email.policy import SMTPUTF8
|
||||
|
||||
import unittest
|
||||
|
||||
class LacreTextTest(unittest.TestCase):
|
||||
def test_parse_content_type_without_charset(self):
|
||||
(mtype, mcharset) = lacre.text.parse_content_type('text/plain')
|
||||
self.assertEqual(mtype, 'text/plain')
|
||||
self.assertEqual(mcharset, sys.getdefaultencoding())
|
||||
|
||||
def test_parse_content_type_with_charset(self):
|
||||
(mtype, mcharset) = lacre.text.parse_content_type('text/plain; charset="UTF-8"')
|
||||
self.assertEqual(mtype, 'text/plain')
|
||||
self.assertEqual(mcharset, '"UTF-8"')
|
||||
|
||||
def test_parse_content_type_with_other_attributes(self):
|
||||
(mtype, mcharset) = lacre.text.parse_content_type('text/plain; some-param="Some Value"')
|
||||
self.assertEqual(mtype, 'text/plain')
|
||||
self.assertEqual(mcharset, sys.getdefaultencoding())
|
||||
|
||||
def test_parse_content_type_with_several_attributes(self):
|
||||
(mtype, mcharset) = lacre.text.parse_content_type('text/plain; charset="UTF-8"; some-param="Some Value"')
|
||||
self.assertEqual(mtype, 'text/plain')
|
||||
self.assertEqual(mcharset, '"UTF-8"')
|
||||
|
||||
def test_parse_email_without_delimiter(self):
|
||||
addr = "Some.Name@example.com"
|
||||
(addr2, topic) = lacre.text.parse_delimiter(addr)
|
||||
self.assertEqual(addr2, "Some.Name@example.com")
|
||||
self.assertEqual(topic, None)
|
||||
|
||||
def test_parse_email_with_delimiter(self):
|
||||
addr = "Some.Name+some-topic@example.com"
|
||||
(addr2, topic) = lacre.text.parse_delimiter(addr)
|
||||
self.assertEqual(addr2, "Some.Name@example.com")
|
||||
self.assertEqual(topic, "some-topic")
|
||||
|
||||
def test_pgp_inline_recognised(self):
|
||||
msg = None
|
||||
with open('test/msgin/ed2ed.msg', 'rb') as f:
|
||||
msg = message_from_binary_file(f, policy=SMTPUTF8)
|
||||
|
||||
body = msg.get_payload()
|
||||
|
||||
self.assertIn(lacre.text.PGP_BEGIN_S, body)
|
||||
self.assertIn(lacre.text.PGP_END_S, body)
|
||||
self.assertTrue(lacre.text.is_payload_pgp_inline(body))
|
||||
|
||||
def test_pgp_marker_mentioned(self):
|
||||
msg = None
|
||||
with open('test/msgin/with-markers2clear.msg', 'rb') as f:
|
||||
msg = message_from_binary_file(f, policy=SMTPUTF8)
|
||||
|
||||
body = msg.get_payload()
|
||||
|
||||
self.assertFalse(lacre.text.is_payload_pgp_inline(body))
|
|
@ -1,15 +0,0 @@
|
|||
from M2Crypto import BIO
|
||||
import unittest
|
||||
|
||||
class M2CryptoBioMemoryBufferTest(unittest.TestCase):
|
||||
def test_memory_buffer_write_str(self):
|
||||
mb = BIO.MemoryBuffer()
|
||||
mb.write("Foo")
|
||||
mb.close()
|
||||
self.assertEqual(len(mb), 3)
|
||||
|
||||
def test_memory_buffer_write_bytes(self):
|
||||
mb = BIO.MemoryBuffer()
|
||||
mb.write(b"Foo")
|
||||
mb.close()
|
||||
self.assertEqual(len(mb), 3)
|
|
@ -1,38 +0,0 @@
|
|||
import unittest
|
||||
|
||||
from logging import getLogger, ERROR, Handler
|
||||
from lacre.stats import time_logger
|
||||
|
||||
def make_exception_raiser(logger):
|
||||
def f():
|
||||
with time_logger('Just a test', logger):
|
||||
logger.info('Doing something')
|
||||
raise Exception('this is a test')
|
||||
return f
|
||||
|
||||
|
||||
class LogRecordCollector(Handler):
|
||||
logged_records = []
|
||||
|
||||
def handle(self, r):
|
||||
self.logged_records.append(self.format(r))
|
||||
|
||||
|
||||
class ExecutionTimeLoggerTest(unittest.TestCase):
|
||||
def test_exception_handling(self):
|
||||
handler = LogRecordCollector()
|
||||
logger = getLogger('test-logger')
|
||||
logger.addHandler(handler)
|
||||
|
||||
f = make_exception_raiser(logger)
|
||||
|
||||
self.assertRaises(Exception, f)
|
||||
self.assertLogs(logger, ERROR)
|
||||
self.assertEqual(len(handler.logged_records), 3)
|
||||
self.assertEqual(handler.logged_records[0], 'Start: Just a test')
|
||||
self.assertEqual(handler.logged_records[1], 'Doing something')
|
||||
|
||||
# Exception record should include the timing result and the traceback...
|
||||
self.assertRegex(handler.logged_records[2], '^Just a test took \\d ms, raised exception\nTraceback.*')
|
||||
# ...as well as the original exception
|
||||
self.assertRegex(handler.logged_records[2], 'Exception: this is a test$')
|
|
@ -1,5 +1,5 @@
|
|||
From: Dave <dave@localhost>
|
||||
To: Carlos <carlos@localhost>
|
||||
Subject: Test
|
||||
|
||||
Body of the message.
|
||||
From: Dave <dave@localhost>
|
||||
To: Carlos <carlos@localhost>
|
||||
Subject: Test
|
||||
|
||||
Body of the message.
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
From: Dave <dave@localhost>
|
||||
To: Bob <bob+foobar@localhost>
|
||||
Subject: Test
|
||||
|
||||
Body of the message.
|
|
@ -1,5 +1,5 @@
|
|||
From: Dave <dave@localhost>
|
||||
To: Bob <bob@localhost>
|
||||
Subject: Test
|
||||
|
||||
Body of the message.
|
||||
From: Dave <dave@localhost>
|
||||
To: Bob <bob@localhost>
|
||||
Subject: Test
|
||||
|
||||
Body of the message.
|
||||
|
|