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

View file

@ -3,6 +3,33 @@ import string
import yaml 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): def _validate_mailing_method(value):
methods = ['stdout', 'smtp'] methods = ['stdout', 'smtp']
if value not in methods: if value not in methods:
@ -77,6 +104,19 @@ def _validate_policy_flags(value):
for c in flag: for c in flag:
if c not in alphabet: if c not in alphabet:
return f'has invalid key {flag}' 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): def _validate_responses(value):
@ -201,6 +241,7 @@ Config = _GlobalConfig(
working_directory=_ConfigOption('directory', str, '/var/lib/easywks'), working_directory=_ConfigOption('directory', str, '/var/lib/easywks'),
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),
# TODO: permit_unsigned_response is deprecated, but for now retained for backwards compatibility
permit_unsigned_response=_ConfigOption('permit_unsigned_response', bool, False), permit_unsigned_response=_ConfigOption('permit_unsigned_response', bool, False),
httpd=_ConfigOption('httpd', dict, { httpd=_ConfigOption('httpd', dict, {
'host': 'localhost', 'host': 'localhost',

View file

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

View file

@ -1,9 +1,11 @@
import sys import sys
from typing import List, Dict, Tuple from typing import Any, List, Dict, Tuple
from .crypto import pgp_decrypt from .crypto import pgp_decrypt
from .mailing import get_mailing_method 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 .files import read_pending_key, write_public_key, remove_pending_key, write_pending_key
from .types import SubmissionRequest, ConfirmationResponse, PublishResponse, ConfirmationRequest, EasyWksError,\ from .types import SubmissionRequest, ConfirmationResponse, PublishResponse, ConfirmationRequest, EasyWksError,\
XLOOP_HEADER XLOOP_HEADER
@ -15,7 +17,7 @@ from email.parser import BytesParser
from email.policy import default from email.policy import default
from email.utils import getaddresses from email.utils import getaddresses
from pgpy import PGPMessage, PGPKey, PGPUID from pgpy import PGPMessage, PGPKey, PGPUID, PGPSignature
from pgpy.errors import PGPError 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) 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): def process_mail(mail: bytes):
try: try:
msg: Message = BytesParser(policy=default).parsebytes(mail) msg: Message = BytesParser(policy=default).parsebytes(mail)
@ -170,17 +228,25 @@ def process_mail(mail: bytes):
except FileNotFoundError: except FileNotFoundError:
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 # TODO: Config.permit_unsigned_response is deprecated, but for now retained for backwards compatibility
request.verify_signature(key) 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) response: PublishResponse = request.get_publish_response(key)
rmsg = response.create_signed_message()
write_public_key(sender_domain, sender_mail, key, revoked_keys) write_public_key(sender_domain, sender_mail, key, revoked_keys)
remove_pending_key(sender_domain, request.nonce) remove_pending_key(sender_domain, request.nonce)
else: else:
request: SubmissionRequest = _parse_submission_request(decrypted, submission_address, sender_mail) request: SubmissionRequest = _parse_submission_request(decrypted, submission_address, sender_mail)
response: ConfirmationRequest = request.confirmation_request() policy = Config[sender_domain].policy_flags
rmsg = response.create_signed_message() _apply_submission_policy(request, policy)
write_pending_key(sender_domain, response.nonce, request.key, request.revoked_keys) 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()
write_pending_key(sender_domain, response.nonce, request.key, request.revoked_keys)
rmsg = response.create_signed_message()
except EasyWksError as e: except EasyWksError as e:
rmsg = e.create_message(sender_mail, submission_address) rmsg = e.create_message(sender_mail, submission_address)
method = get_mailing_method(Config.mailing_method) 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 pgpy.errors import PGPError
from .crypto import pgp_sign from .crypto import pgp_sign
from .config import Config, render_message from .config import render_message
from .util import create_nonce, fingerprint from .util import create_nonce, fingerprint
@ -145,13 +145,9 @@ class ConfirmationResponse:
def verify_signature(self, key: PGPKey): def verify_signature(self, key: PGPKey):
if not self._msg.is_signed: 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 '
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 '
'gpg-wks-client for submitting your response, please update said tool or try ' 'responding manually.')
'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')

View file

@ -11,6 +11,10 @@
# older version of the WKS standard where signing the confirmation # older version of the WKS standard where signing the confirmation
# response is only recommended, but not required. Set this option to # response is only recommended, but not required. Set this option to
# true if you want to accept such unsigned responses. # 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 #permit_unsigned_response: false
# Port configuration for the webserver. Put this behind a # 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 # or if you're supplying your own password-protected key, set the
# passphrase here: # passphrase here:
#passphrase: "Correct Horse Battery Staple" #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