feat: implement policy_flags and apply these policies to submission requests

This commit is contained in:
s3lph 2023-01-31 20:44:53 +01:00
parent 9182582589
commit 41de3b5704
6 changed files with 183 additions and 22 deletions

View file

@ -45,7 +45,6 @@ easywksserver_gpgwksclient:
- |
cat > /tmp/easywks.yml <<EOF
directory: /tmp/easywks
permit_unsigned_response: true # required for gpg-wks-client compat
httpd:
host: 127.0.0.1
port: 8080
@ -53,6 +52,8 @@ easywksserver_gpgwksclient:
domains:
example.org:
submission_address: webkey@example.org
policy_flags:
me.s3lph.easywks_permit-unsigned-response: true # required for gpg-wks-client compat
EOF
- easywks --config /tmp/easywks.yml init
- easywks --config /tmp/easywks.yml webserver &

View file

@ -3,6 +3,33 @@ import string
import yaml
POLICY_MAILBOX_ONLY = 'mailbox-only'
POLICY_AUTH_SUBMIT = 'auth-submit'
POLICY_PROTOCOL_VERSION = 'protocol-version'
STANDARD_POLICY_FLAGS = {
POLICY_MAILBOX_ONLY: bool,
# 'dane-only': bool, # deprecated
POLICY_AUTH_SUBMIT: bool,
POLICY_PROTOCOL_VERSION: int,
}
EASYWKS_POLICY_PREFIX = 'me.s3lph.easywks_'
EWP_PERMIT_UNSIGNED_RESPONSE = EASYWKS_POLICY_PREFIX + 'permit-unsigned-response'
EWP_STRIP_UNVERIFIED_UIDS = EASYWKS_POLICY_PREFIX + 'strip-unverified-uids'
EWP_STRIP_UA_UIDS = EASYWKS_POLICY_PREFIX + 'strip-ua-uids'
EWP_STRIP_3RDPARTY_SIGNATURES = EASYWKS_POLICY_PREFIX + 'strip-3rdparty-signatures'
EWP_MAX_REVOKED_KEYS = EASYWKS_POLICY_PREFIX + 'max-revoked-keys'
EWP_MINIMIZE_REVOKED_KEYS = EASYWKS_POLICY_PREFIX + 'minimize-revoked-keys'
EASYWKS_POLICY_FLAGS = {
EWP_PERMIT_UNSIGNED_RESPONSE: bool,
EWP_STRIP_UNVERIFIED_UIDS: bool,
EWP_STRIP_UA_UIDS: bool,
EWP_STRIP_3RDPARTY_SIGNATURES: bool,
EWP_MAX_REVOKED_KEYS: int,
EWP_MINIMIZE_REVOKED_KEYS: bool,
}
def _validate_mailing_method(value):
methods = ['stdout', 'smtp']
if value not in methods:
@ -77,6 +104,19 @@ def _validate_policy_flags(value):
for c in flag:
if c not in alphabet:
return f'has invalid key {flag}'
if '_' in flag:
if flag.startswith(EASYWKS_POLICY_PREFIX):
cls = EASYWKS_POLICY_FLAGS.get(flag)
if flag not in EASYWKS_POLICY_FLAGS:
return f'unknown policy flag {flag}'
if not isinstance(v, cls):
return f'invalid type {v.__class__.__name__} for flag {flag}'
else:
cls = STANDARD_POLICY_FLAGS.get(flag)
if flag not in STANDARD_POLICY_FLAGS:
return f'unknown policy flag {flag}'
if not isinstance(v, cls):
return f'invalid type {v.__class__.__name__} for flag {flag}'
def _validate_responses(value):
@ -201,6 +241,7 @@ Config = _GlobalConfig(
working_directory=_ConfigOption('directory', str, '/var/lib/easywks'),
pending_lifetime=_ConfigOption('pending_lifetime', int, 604800),
mailing_method=_ConfigOption('mailing_method', str, 'stdout', validator=_validate_mailing_method),
# TODO: permit_unsigned_response is deprecated, but for now retained for backwards compatibility
permit_unsigned_response=_ConfigOption('permit_unsigned_response', bool, False),
httpd=_ConfigOption('httpd', dict, {
'host': 'localhost',

View file

@ -36,10 +36,15 @@ def make_submission_address_file(domain: str):
def make_policy_file(domain: str):
content = f'submission-address: {Config[domain].submission_address}\n'
for flag, value in Config[domain].policy_flags.items():
if not value or len(value) == 0:
content += f'{flag}: {value}\n'
if isinstance(value, bool):
if not value:
continue
else:
content += flag + '\n'
elif value is None:
content += flag + '\n'
else:
content += f'{flag}: {value}\n'
return content

View file

@ -1,9 +1,11 @@
import sys
from typing import List, Dict, Tuple
from typing import Any, List, Dict, Tuple
from .crypto import pgp_decrypt
from .mailing import get_mailing_method
from .config import Config
from .config import Config, \
POLICY_MAILBOX_ONLY, EWP_MAX_REVOKED_KEYS, EWP_STRIP_UNVERIFIED_UIDS, EWP_STRIP_3RDPARTY_SIGNATURES, \
EWP_STRIP_UA_UIDS, EWP_MINIMIZE_REVOKED_KEYS, EWP_PERMIT_UNSIGNED_RESPONSE, POLICY_AUTH_SUBMIT
from .files import read_pending_key, write_public_key, remove_pending_key, write_pending_key
from .types import SubmissionRequest, ConfirmationResponse, PublishResponse, ConfirmationRequest, EasyWksError,\
XLOOP_HEADER
@ -15,7 +17,7 @@ from email.parser import BytesParser
from email.policy import default
from email.utils import getaddresses
from pgpy import PGPMessage, PGPKey, PGPUID
from pgpy import PGPMessage, PGPKey, PGPUID, PGPSignature
from pgpy.errors import PGPError
@ -134,6 +136,62 @@ def _parse_confirmation_response(pgp: PGPMessage, submission: str, sender: str):
return ConfirmationResponse(rdict['sender'], submission, rdict['nonce'], pgp)
# There is no API for directly removing a PGPUID or PGPSignature object
# noinspection PyProtectedMember
def _apply_submission_policy(request: SubmissionRequest, policy: Dict[str, Any]):
# Policy: Only permit a certain amount of revoked keys
maxrevoke = policy.get(EWP_MAX_REVOKED_KEYS, -1)
if maxrevoke > -1 and len(request.revoked_keys) > maxrevoke:
raise EasyWksError(f'Submission request contains {len(request.revoked_keys)} revoked keys. '
f'Policy permits not more than {maxrevoke}')
uid: PGPUID
revfprs = [fingerprint(request.key)] + [fingerprint(rk) for rk in request.revoked_keys]
for key in [request.key] + request.revoked_keys:
# Policy: Strip user attribute (image) UIDs
if policy.get(POLICY_MAILBOX_ONLY, False) or policy.get(EWP_STRIP_UA_UIDS, False):
for uid in list(key.userattributes):
uid._parent = None
key._uids.remove(uid)
# Policy: Reject keys as invalid if they contain UIDs with non-empty name or comment parts
if policy.get(POLICY_MAILBOX_ONLY, False):
for uid in list(key.userids):
if uid.email == '' or uid.name != '' or uid.comment != '':
raise EasyWksError('This WKS server only accepts UIDs without name and comment parts')
# Policy: Strip all UIDs except the one being verified
if policy.get(EWP_STRIP_UNVERIFIED_UIDS, False):
for uid in list(key.userids):
if uid.email != request.submitter_address:
uid._parent = None
key._uids.remove(uid)
# Policy: Strip all 3rd party signatures from they key
if policy.get(EWP_STRIP_3RDPARTY_SIGNATURES, False):
for uid in list(key.userids):
for sig in list(uid.third_party_certifications):
# Keep signatures signed by the revoked keys
sig: PGPSignature
if sig.signer_fingerprint not in revfprs:
uid._signatures.remove(sig)
# Policy: Produce minimal transportable keys
if policy.get(EWP_MINIMIZE_REVOKED_KEYS, False):
for key in request.revoked_keys:
for uid in list(key.userids):
# Delete all but the submitter UIDs, and all 3rd party signatures
if uid.email != request.submitter_address:
uid._parent = None
key._uids.remove(uid)
else:
for sig in list(uid.third_party_certifications):
uid._signatures.remove(sig)
# Delete UAs
for uid in list(key.userattributes):
uid._parent = None
key._uids.remove(uid)
# Delete subkeys
for subkey in key._children.values():
subkey._parent = None
key._children.clear()
def process_mail(mail: bytes):
try:
msg: Message = BytesParser(policy=default).parsebytes(mail)
@ -170,17 +228,25 @@ def process_mail(mail: bytes):
except FileNotFoundError:
raise EasyWksError('There is no submission request for this email address, or it has expired. '
'Please resubmit your submission request.')
# TODO: Config.permit_unsigned_response is deprecated, but for now retained for backwards compatibility
if not Config[sender_domain].policy_flags.get(EWP_PERMIT_UNSIGNED_RESPONSE, False) and \
not Config.permit_unsigned_response:
# this throws an error if signature verification fails
request.verify_signature(key)
response: PublishResponse = request.get_publish_response(key)
rmsg = response.create_signed_message()
write_public_key(sender_domain, sender_mail, key, revoked_keys)
remove_pending_key(sender_domain, request.nonce)
else:
request: SubmissionRequest = _parse_submission_request(decrypted, submission_address, sender_mail)
policy = Config[sender_domain].policy_flags
_apply_submission_policy(request, policy)
if policy.get(POLICY_AUTH_SUBMIT, False):
response = PublishResponse(request.submitter_address, request.submission_address, request.key)
write_public_key(sender_domain, sender_mail, request.key, request.revoked_keys)
else:
response: ConfirmationRequest = request.confirmation_request()
rmsg = response.create_signed_message()
write_pending_key(sender_domain, response.nonce, request.key, request.revoked_keys)
rmsg = response.create_signed_message()
except EasyWksError as e:
rmsg = e.create_message(sender_mail, submission_address)
method = get_mailing_method(Config.mailing_method)

View file

@ -13,7 +13,7 @@ from pgpy import PGPKey, PGPMessage, PGPUID
from pgpy.errors import PGPError
from .crypto import pgp_sign
from .config import Config, render_message
from .config import render_message
from .util import create_nonce, fingerprint
@ -145,13 +145,9 @@ class ConfirmationResponse:
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)
if uid is None or uid.email != self._submitter_addr:
raise EasyWksError(f'UID {self._submitter_addr} not found in PGP key')

View file

@ -11,6 +11,10 @@
# 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.
#
# This option is deprecated and will be removed in a future release.
# It is replaced by the me.s3lph.easywks_permit-unsigned-response
# per-domain policy flag.
#permit_unsigned_response: false
# Port configuration for the webserver. Put this behind a
@ -75,3 +79,51 @@ domains:
# or if you're supplying your own password-protected key, set the
# passphrase here:
#passphrase: "Correct Horse Battery Staple"
# Policy flags control behavior of the submission process for this
# domain. Supports most of the standard policy flags (see
# https://datatracker.ietf.org/doc/html/draft-koch-openpgp-webkey-service-15#section-4.5
# for details) as well as some EasyWKS-namespaced flags.
policy_flags:
# The mail server provider does only accept keys with only a
# mailbox in the User ID. In particular User IDs with a real name
# in addition to the mailbox will be rejected as invalid.
#mailbox-only: false
# The submission of the mail to the server is done using an
# authenticated connection. Thus the submitted key will be
# published immediately without any confirmation request.
#auth-submit: false
# This keyword can be used to explicitly claim the support of a
# specific version of the Web Key Directory update protocol. This
# is in general not needed but implementations may have
# workarounds for providers which only support an old protocol
# version. If these providers update to a newer version they
# should add this keyword so that the implementation can disable
# the workaround. The value is an integer corresponding to the
# respective draft revision number.
#protocol-version: null
# 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.
#me.s3lph.easywks_permit-unsigned-response: false
# Remove all UIDs except the one being verified.
#me.s3lph.easywks_strip-unverified-uids: false
# Remove user attribute (i.e. photo) UIDs.
#me.s3lph.easywks_strip-ua-uids: false
# Remove all third party certifications fom the submitted keys.
# Certifications issued by revoked keys submitted in the same
# submission request are exempt from this policy.
#me.s3lph.easywks_strip-3rdparty-signatures: false
# Maximal number of revoked keys that can be submitted alongside
# a valid key. If this flag is absent or has value -1, an
# unlimited number of revoked keys is permitted.
#me.s3lph.easywks_max-revoked-keys: -1