Release 0.1.3: Compatibility with gpg-wks-client

This commit is contained in:
s3lph 2021-09-29 00:44:07 +02:00
parent 89542ee110
commit e15f6e51b1
10 changed files with 127 additions and 42 deletions

View file

@ -1,5 +1,28 @@
# EasyWKS Changelog # EasyWKS Changelog
<!-- BEGIN RELEASE v0.1.3 -->
## Version 0.1.3
Compatibility with gpg-wks-client.
### Changes
<!-- BEGIN CHANGES 0.1.3 -->
- Webserver config is now under a `httpd` key, to be more in line with
lmtpd and smtp.
- Add a `permit_unsigned_response` boolean key that, if set to true,
instructs EasyWKS to accept confirmation responses even if they are
unsigned, in order to be compatible with version -00 of the draft
standard, and thus e.g. gpg-wks-client.
- Change the detection logic between submission requests and
confirmation requests from PGP signature checking to attempting to
parse the message as a submission response first.
- Add a `__main__` module so that easywks can be invoked as a Python
module.
<!-- END CHANGES 0.1.3 -->
<!-- END RELEASE v0.1.3 -->
<!-- BEGIN RELEASE v0.1.2 --> <!-- BEGIN RELEASE v0.1.2 -->
## Version 0.1.2 ## Version 0.1.2

View file

@ -75,18 +75,29 @@ Configuration is done in `/etc/easywks.yml` (or any other place as specified by
```yaml ```yaml
--- ---
# EasyWKS works inside this directory. Its PGP keys as well # EasyWKS works inside this directory. Its PGP keys as well# as all
# as all the submitted and published keys are stored here. # the submitted and published keys are stored here.
directory: /var/lib/easywks directory: /var/lib/easywks
# Number of seconds after which a pending submission request
# is considered stale and should be removed by easywks clean. # Number of seconds after which a pending submission request is
# considered stale and should be removed by easywks clean.
pending_lifetime: 604800 pending_lifetime: 604800
# Port configuration for the webserver. Put this behind a
# Some clients (including recent versions of gpg-wks-client follow an
# older version of the WKS standard where signing the confirmation
# response is only recommended, but not required. Set this option to
# true if you want to accept such unsigned responses.
permit_unsigned_response: false
# Port configuration for the webserver. Put this behind an
# HTTPS-terminating reverse proxy! # HTTPS-terminating reverse proxy!
httpd:
host: 127.0.0.1 host: 127.0.0.1
port: 8080 port: 8080
# Defaults to stdout, supported: stdout, smtp # Defaults to stdout, supported: stdout, smtp
mailing_method: smtp mailing_method: smtp
# Configure smtp client options # Configure smtp client options
smtp: smtp:
# Connect to this SMTP server to send a mail. # Connect to this SMTP server to send a mail.
@ -105,13 +116,12 @@ lmtpds:
# Every domain served by EasyWKS must be listed here # Every domain served by EasyWKS must be listed here
domains: domains:
example.org: example.org:
# Users send their requests to this address. It's up to # Users send their requests to this address. It's up to you to
# you to make sure that the mails sent their get handed # make sure that the mails sent their get handed to EasyWKS.
# to EasyWKS.
submission_address: webkey@example.org submission_address: webkey@example.org
# If you want the PGP key for this domain to be # If you want the PGP key for this domain to be password-
# password-protected, or if you're supplying your own # protected, or if you're supplying your own password-protected
# password-protected key, set the passphrase here: # key, set the passphrase here:
passphrase: "Correct Horse Battery Staple" passphrase: "Correct Horse Battery Staple"
# Defaults are gpgwks@<domain> and no password protection. # Defaults are gpgwks@<domain> and no password protection.
example.com: {} example.com: {}

View file

@ -1,2 +1,2 @@
__version__ = '0.1.2' __version__ = '0.1.3'

4
easywks/__main__.py Normal file
View file

@ -0,0 +1,4 @@
from .main import main
main()

View file

@ -36,6 +36,15 @@ def _validate_smtp_config(value):
value['password'] = None value['password'] = None
def _validate_httpd_config(value):
if not isinstance(value, dict):
return f'must be a map, got {type(value)}'
if not isinstance(value['host'], str):
return f'host must be a str, got {type(value["host"])}'
if not isinstance(value['port'], int):
return f'port must be a int, got {type(value["port"])}'
def _validate_lmtpd_config(value): def _validate_lmtpd_config(value):
if not isinstance(value, dict): if not isinstance(value, dict):
return f'must be a map, got {type(value)}' return f'must be a map, got {type(value)}'
@ -127,10 +136,13 @@ class _GlobalConfig(_Config):
Config = _GlobalConfig( Config = _GlobalConfig(
working_directory=_ConfigOption('directory', str, '/var/lib/easywks'), working_directory=_ConfigOption('directory', str, '/var/lib/easywks'),
host=_ConfigOption('host', str, '127.0.0.1'),
port=_ConfigOption('port', int, 8080),
pending_lifetime=_ConfigOption('pending_lifetime', int, 604800), pending_lifetime=_ConfigOption('pending_lifetime', int, 604800),
mailing_method=_ConfigOption('mailing_method', str, 'stdout', validator=_validate_mailing_method), mailing_method=_ConfigOption('mailing_method', str, 'stdout', validator=_validate_mailing_method),
permit_unsigned_response=_ConfigOption('permit_unsigned_response', bool, False),
httpd=_ConfigOption('httpd', dict, {
'host': 'localhost',
'port': 8080
}, validator=_validate_httpd_config),
smtp=_ConfigOption('smtp', dict, { smtp=_ConfigOption('smtp', dict, {
'host': 'localhost', 'host': 'localhost',
'port': 25, 'port': 25,

View file

@ -37,7 +37,7 @@ def parse_arguments():
def main(): def main():
args = parse_arguments() args = parse_arguments()
if args.config is list: if isinstance(args.config, list):
conf = args.config[0] conf = args.config[0]
else: else:
conf = args.config conf = args.config

View file

@ -52,12 +52,12 @@ def _get_pgp_publickey(parts: List[MIMEPart]) -> PGPKey:
key, _ = PGPKey.from_blob(part.get_content()) key, _ = PGPKey.from_blob(part.get_content())
if key.is_public: if key.is_public:
if pubkey: if pubkey:
raise EasyWksError('More than one PGP public key in message') raise EasyWksError('More than one PGP public key in message. Only submit a single key at once.')
pubkey = key pubkey = key
except PGPError: except PGPError:
pass pass
if not pubkey: if not pubkey:
raise EasyWksError('No PGP public key in message') raise EasyWksError('No PGP public key found in the encrypted message part.')
return pubkey return pubkey
@ -90,8 +90,6 @@ def _find_confirmation_response(parts: List[MIMEPart]) -> str:
if 'confirmation-response' in c: if 'confirmation-response' in c:
response = c response = c
continue continue
if not response:
raise EasyWksError('No confirmation response found in message')
return response return response
@ -105,12 +103,19 @@ def _parse_confirmation_response(pgp: PGPMessage, submission: str, sender: str):
continue continue
key, value = line.split(':', 1) key, value = line.split(':', 1)
rdict[key.strip()] = value.strip() rdict[key.strip()] = value.strip()
# "from" was renamed to "sender" in draft-koch-openpgp-webkey-service-01. In addition, gpg-wks-client, which still
# implements -00, also violates this standard and uses "address" for the sender and "sender" for the submission
# address.
if 'from' in rdict:
rdict['sender'] = rdict['from']
if 'address' in rdict:
rdict['sender'] = rdict['address']
if rdict.get('type', '') != 'confirmation-response': if rdict.get('type', '') != 'confirmation-response':
raise EasyWksError('Invalid confirmation response: "type" missing or not "confirmation-response"') raise EasyWksError('Invalid confirmation response: "type" missing or not "confirmation-response"')
if 'sender' not in rdict or 'nonce' not in rdict: if 'sender' not in rdict or 'nonce' not in rdict:
raise EasyWksError('Invalid confirmation response: "sender" or "nonce" missing from confirmation response') raise EasyWksError('Invalid confirmation response: "sender" or "nonce" missing from confirmation response')
if rdict['sender'] != sender: if rdict['sender'] != sender:
raise EasyWksError('Confirmation sender does not match message sender') raise EasyWksError(f'Confirmation sender "{rdict["sender"]}" does not match message sender "{sender}"')
return ConfirmationResponse(rdict['sender'], submission, rdict['nonce'], pgp) return ConfirmationResponse(rdict['sender'], submission, rdict['nonce'], pgp)
@ -138,7 +143,12 @@ def process_mail(mail: bytes):
leafs = _get_mime_leafs(msg) leafs = _get_mime_leafs(msg)
pgp: PGPMessage = _get_pgp_message(leafs) pgp: PGPMessage = _get_pgp_message(leafs)
decrypted = pgp_decrypt(sender_domain, pgp) decrypted = pgp_decrypt(sender_domain, pgp)
if decrypted.is_signed: payload = BytesParser(policy=default).parsebytes(decrypted.message)
parts = _get_mime_leafs(payload)
# First attempt to find a confirmation response. It's identifiable much easier due to the
# "type: confirmation-response" part in the message.
confirmation_response = _find_confirmation_response(parts)
if confirmation_response is not None:
request: ConfirmationResponse = _parse_confirmation_response(decrypted, submission_address, sender_mail) request: ConfirmationResponse = _parse_confirmation_response(decrypted, submission_address, sender_mail)
try: try:
key = read_pending_key(sender_domain, request.nonce) key = read_pending_key(sender_domain, request.nonce)
@ -146,7 +156,8 @@ def process_mail(mail: bytes):
raise EasyWksError('There is no submission request for this email address, or it has expired. ' raise EasyWksError('There is no submission request for this email address, or it has expired. '
'Please resubmit your submission request.') 'Please resubmit your submission request.')
# this throws an error if signature verification fails # this throws an error if signature verification fails
response: PublishResponse = request.verify_signature(key) request.verify_signature(key)
response: PublishResponse = request.get_publish_response(key)
rmsg = response.create_signed_message() rmsg = response.create_signed_message()
write_public_key(sender_domain, sender_mail, key) write_public_key(sender_domain, sender_mail, key)
remove_pending_key(sender_domain, request.nonce) remove_pending_key(sender_domain, request.nonce)

View file

@ -10,6 +10,7 @@ from pgpy import PGPKey, PGPMessage, PGPUID
from pgpy.types import SignatureVerification from pgpy.types import SignatureVerification
from .crypto import pgp_sign from .crypto import pgp_sign
from .config import Config
from .util import create_nonce, fingerprint from .util import create_nonce, fingerprint
@ -157,15 +158,26 @@ class ConfirmationResponse:
def nonce(self): def nonce(self):
return self._nonce return self._nonce
def verify_signature(self, key: PGPKey) -> 'PublishResponse': def get_publish_response(self, key: PGPKey) -> 'PublishResponse':
return PublishResponse(self._submitter_addr, self._submission_addr, key)
def verify_signature(self, key: PGPKey):
if not self._msg.is_signed:
if not Config.permit_unsigned_response:
raise EasyWksError('The confirmation response is not signed. If you used an automated tool such as '
'gpg-wks-client for submitting your response, please update said tool or try '
'responding manually.')
else:
# Unsigned, but permitted
return
uid: PGPUID = key.get_uid(self._submitter_addr) uid: PGPUID = key.get_uid(self._submitter_addr)
if uid is None or uid.email != self._submitter_addr: if uid is None or uid.email != self._submitter_addr:
raise EasyWksError(f'UID {self._submitter_addr} not found in PGP key') raise EasyWksError(f'UID {self._submitter_addr} not found in PGP key')
verification: SignatureVerification = key.verify(self._msg) verification: SignatureVerification = key.verify(self._msg)
for verified, by, sig, subject in verification.good_signatures: for verified, by, sig, subject in verification.good_signatures:
if fingerprint(key) == fingerprint(by): if fingerprint(key) == fingerprint(by):
return PublishResponse(self._submitter_addr, self._submission_addr, key) return
raise EasyWksError('PGP Signature could not be verified') raise EasyWksError('PGP signature could not be verified')
class PublishResponse: class PublishResponse:
@ -214,7 +226,7 @@ Encrypt live everybody is.
return self._domain return self._domain
def create_signed_message(self): def create_signed_message(self):
mpplain = MIMEText(ConfirmationRequest.MAIL_TEXT.format(domain=self.domain, uid=self.submitter_address), mpplain = MIMEText(PublishResponse.MAIL_TEXT.format(domain=self.domain, uid=self.submitter_address),
_subtype='plain') _subtype='plain')
to_encrypt = PGPMessage.new(mpplain.as_string(policy=default)) to_encrypt = PGPMessage.new(mpplain.as_string(policy=default))
encrypted: PGPMessage = self.key.encrypt(to_encrypt) encrypted: PGPMessage = self.key.encrypt(to_encrypt)

View file

@ -1,16 +1,27 @@
--- ---
# EasyWKS works inside this directory. Its PGP keys as well # EasyWKS works inside this directory. Its PGP keys as well as all the
# as all the submitted and published keys are stored here. # submitted and published keys are stored here.
#directory: /var/lib/easywks #directory: /var/lib/easywks
# Number of seconds after which a pending submission request
# is considered stale and should be removed by easywks clean. # Number of seconds after which a pending submission request is
# considered stale and should be removed by easywks clean.
#pending_lifetime: 604800 #pending_lifetime: 604800
# Some clients (including recent versions of gpg-wks-client follow an
# older version of the WKS standard where signing the confirmation
# response is only recommended, but not required. Set this option to
# true if you want to accept such unsigned responses.
#permit_unsigned_response: false
# Port configuration for the webserver. Put this behind a # Port configuration for the webserver. Put this behind a
# HTTPS-terminating reverse proxy! # HTTPS-terminating reverse proxy!
httpd:
host: "::1" host: "::1"
port: 8080 port: 8080
# Defaults to stdout, supported: stdout, smtp # Defaults to stdout, supported: stdout, smtp
mailing_method: smtp mailing_method: smtp
# Configure smtp client options # Configure smtp client options
smtp: smtp:
# Connect to this SMTP server to send a mail. # Connect to this SMTP server to send a mail.
@ -22,19 +33,20 @@ smtp:
# Omit username/password if authentication is not needed. # Omit username/password if authentication is not needed.
#username: webkey #username: webkey
#password: SuperS3curePassword123 #password: SuperS3curePassword123
# Configure the LMTP server # Configure the LMTP server
lmtpd: lmtpd:
host: "::1" host: "::1"
port: 8024 port: 8024
# Every domain served by EasyWKS must be listed here # Every domain served by EasyWKS must be listed here
domains: domains:
# Defaults are gpgwks@<domain> and no password protection. # Defaults are gpgwks@<domain> and no password protection.
example.org: example.org:
# Users send their requests to this address. It's up to # Users send their requests to this address. It's up to you to
# you to make sure that the mails sent their get handed # make sure that the mails sent their get handed to EasyWKS.
# to EasyWKS.
submission_address: webkey@example.org submission_address: webkey@example.org
# If you want the PGP key for this domain to be # If you want the PGP key for this domain to be password-protected,
# password-protected, or if you're supplying your own # or if you're supplying your own password-protected key, set the
# password-protected key, set the passphrase here: # passphrase here:
#passphrase: "Correct Horse Battery Staple" #passphrase: "Correct Horse Battery Staple"

View file

@ -7,6 +7,7 @@ smtp:
lmtpd: lmtpd:
host: "::" host: "::"
port: 24 port: 24
httpd:
host: "::" host: "::"
port: 80 port: 80
domains: {} domains: {}