From 4e1465cdb218d38f98e4ab480667c10b57ca9b4d Mon Sep 17 00:00:00 2001 From: s3lph <1375407-s3lph@users.noreply.gitlab.com> Date: Tue, 31 Jan 2023 00:46:23 +0100 Subject: [PATCH] feat: allow submitting additional revoked keys with the submission request --- easywks/files.py | 27 +++++++++++++------------- easywks/httpd.py | 4 ++-- easywks/process.py | 48 ++++++++++++++++++++++++++++++---------------- easywks/types.py | 9 ++++++++- easywks/util.py | 41 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 96 insertions(+), 33 deletions(-) diff --git a/easywks/files.py b/easywks/files.py index fef5ed8..02821d6 100644 --- a/easywks/files.py +++ b/easywks/files.py @@ -8,7 +8,7 @@ from pgpy import PGPKey from .config import Config from .crypto import create_pgp_key, privkey_to_pubkey -from .util import hash_user_id +from .util import hash_user_id, armor_keys, split_revoked def _locked_read(file: str, binary: bool = False): @@ -62,33 +62,34 @@ def init_working_directory(): def read_public_key(domain, user): - hu = hash_user_id(user) - keyfile = os.path.join(Config.working_directory, domain, 'hu', hu) - key, _ = PGPKey.from_blob(_locked_read(keyfile, binary=True)) - return key + return read_hashed_public_key(domain, hash_user_id(user)) def read_hashed_public_key(domain, hu): keyfile = os.path.join(Config.working_directory, domain, 'hu', hu) - key, _ = PGPKey.from_blob(_locked_read(keyfile, binary=True)) - return key + _, keys = PGPKey.from_blob(_locked_read(keyfile, binary=True)) + key, revoked = split_revoked(keys.values()) + return key, revoked -def write_public_key(domain, user, key): +def write_public_key(domain, user, key, revoked): hu = hash_user_id(user) keyfile = os.path.join(Config.working_directory, domain, 'hu', hu) - _locked_write(keyfile, bytes(key), binary=True) + joined = bytes(key) + b''.join([bytes(k) for k in revoked]) + _locked_write(keyfile, joined, binary=True) def read_pending_key(domain, nonce): keyfile = os.path.join(Config.working_directory, domain, 'pending', nonce) - key, _ = PGPKey.from_blob(_locked_read(keyfile)) - return key + _, keys = PGPKey.from_blob(_locked_read(keyfile)) + key, revoked = split_revoked(keys.values()) + return key[0], revoked -def write_pending_key(domain, nonce, key): +def write_pending_key(domain, nonce, key, revoked_keys): keyfile = os.path.join(Config.working_directory, domain, 'pending', nonce) - _locked_write(keyfile, str(key)) + armored = armor_keys([key] + revoked_keys) + _locked_write(keyfile, armored) def remove_pending_key(domain, nonce): diff --git a/easywks/httpd.py b/easywks/httpd.py index fd814e7..cbf65a1 100644 --- a/easywks/httpd.py +++ b/easywks/httpd.py @@ -59,10 +59,10 @@ def advanced_hu(domain: str, userhash: str): if not userid or hash_user_id(userid) != userhash: abort(404, 'Not Found') try: - pubkey = read_hashed_public_key(domain, userhash) + pubkey, revoked = read_hashed_public_key(domain, userhash) response.add_header('Content-Type', 'application/octet-stream') response.add_header(*CORS) - return bytes(pubkey) + return bytes(pubkey) + b''.join([bytes(k) for k in revoked]) except FileNotFoundError: abort(404, 'Not Found') diff --git a/easywks/process.py b/easywks/process.py index bd0ef80..fe3d9aa 100644 --- a/easywks/process.py +++ b/easywks/process.py @@ -1,5 +1,5 @@ import sys -from typing import List, Dict +from typing import List, Dict, Tuple from .crypto import pgp_decrypt from .mailing import get_mailing_method @@ -8,6 +8,7 @@ from .files import read_pending_key, write_public_key, remove_pending_key, write from .types import SubmissionRequest, ConfirmationResponse, PublishResponse, ConfirmationRequest, EasyWksError,\ XLOOP_HEADER from .types import fingerprint +from .util import split_revoked from email.message import MIMEPart, Message from email.parser import BytesParser @@ -46,30 +47,43 @@ def _get_pgp_message(parts: List[MIMEPart]) -> PGPMessage: return pgp -def _get_pgp_publickey(parts: List[MIMEPart]) -> PGPKey: - pubkey = None +def _get_pgp_publickeys(parts: List[MIMEPart]) -> Tuple[PGPKey, List[PGPKey]]: + pubkeys: Dict[str, PGPKey] = {} for part in parts: try: - key, _ = PGPKey.from_blob(part.get_content()) - if key.is_public: - if pubkey: - raise EasyWksError('More than one PGP public key in message. Only submit a single key at once.') - pubkey = key + _, keys = PGPKey.from_blob(part.get_content()) + for (_, public), key in keys.items(): + if not public: + continue + fpr = fingerprint(key.fingerprint) + if fpr in pubkeys: + raise EasyWksError(f'Key with fingerprint {fpr} appears multiple times in submission request.') + pubkeys[fpr] = key except PGPError: pass - if not pubkey: + if len(pubkeys) == 0: raise EasyWksError('No PGP public key found in the encrypted message part.') - return pubkey + key, revoked_keys = split_revoked(pubkeys.values()) + if len(key) < 1: + raise EasyWksError('All of the submitted keys appear to be revoked.') + elif len(key) > 1: + fprs = ' '.join([fingerprint(k) for k in key]) + raise EasyWksError(f'More than one non-revoked key was submitted: {fprs}') + return key[0], revoked_keys def _parse_submission_request(pgp: PGPMessage, submission: str, sender: str): payload = BytesParser(policy=default).parsebytes(pgp.message) leafs = _get_mime_leafs(payload) - pubkey = _get_pgp_publickey(leafs) - sender_uid: PGPUID = pubkey.get_uid(sender) + valid_key, revoked_keys = _get_pgp_publickeys(leafs) + sender_uid: PGPUID = valid_key.get_uid(sender) if sender_uid is None or sender_uid.email != sender: raise EasyWksError(f'Key has no UID that matches {sender}') - return SubmissionRequest(sender, submission, pubkey) + for key in revoked_keys: + sender_uid: PGPUID = key.get_uid(sender) + if sender_uid is None or sender_uid.email != sender: + raise EasyWksError(f'Revoked key {fingerprint(key)} has no UID that matches {sender}') + return SubmissionRequest(sender, submission, valid_key, revoked_keys) def _find_confirmation_response(parts: List[MIMEPart]) -> str: @@ -152,7 +166,7 @@ def process_mail(mail: bytes): if confirmation_response is not None: request: ConfirmationResponse = _parse_confirmation_response(decrypted, submission_address, sender_mail) try: - key = read_pending_key(sender_domain, request.nonce) + key, revoked_keys = read_pending_key(sender_domain, request.nonce) except FileNotFoundError: raise EasyWksError('There is no submission request for this email address, or it has expired. ' 'Please resubmit your submission request.') @@ -160,13 +174,13 @@ def process_mail(mail: bytes): request.verify_signature(key) response: PublishResponse = request.get_publish_response(key) rmsg = response.create_signed_message() - write_public_key(sender_domain, sender_mail, key) + 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) response: ConfirmationRequest = request.confirmation_request() rmsg = response.create_signed_message() - write_pending_key(sender_domain, response.nonce, request.key) + write_pending_key(sender_domain, response.nonce, request.key, request.revoked_keys) except EasyWksError as e: rmsg = e.create_message(sender_mail, submission_address) method = get_mailing_method(Config.mailing_method) @@ -200,5 +214,5 @@ def process_key_from_stdin(args): print(f'Skipping foreign email {uid.email}') continue # All checks passed, importing key - write_public_key(domain, uid.email, pubkey) + write_public_key(domain, uid.email, pubkey, []) print(f'Imported key {fingerprint(pubkey)} for email {uid.email}') diff --git a/easywks/types.py b/easywks/types.py index e5e8956..b2d75d9 100644 --- a/easywks/types.py +++ b/easywks/types.py @@ -1,4 +1,6 @@ +from typing import List + from datetime import datetime from email.encoders import encode_noop from email.policy import default @@ -20,10 +22,11 @@ XLOOP_HEADER = 'EasyWKS' class SubmissionRequest: - def __init__(self, submitter_addr: str, submission_addr: str, key: PGPKey): + def __init__(self, submitter_addr: str, submission_addr: str, key: PGPKey, revoked_keys: List[PGPKey]): self._submitter_addr = submitter_addr self._submission_addr = submission_addr self._key = key + self._revoked_keys = revoked_keys def confirmation_request(self) -> 'ConfirmationRequest': return ConfirmationRequest(self._submitter_addr, self. _submission_addr, self._key) @@ -40,6 +43,10 @@ class SubmissionRequest: def key(self): return self._key + @property + def revoked_keys(self): + return list(self._revoked_keys) + class ConfirmationRequest: diff --git a/easywks/util.py b/easywks/util.py index 039341b..edfd22a 100644 --- a/easywks/util.py +++ b/easywks/util.py @@ -1,9 +1,14 @@ +from typing import Iterable, List, Tuple + +import base64 import hashlib import secrets import string +import textwrap from pgpy import PGPKey +from pgpy.constants import SignatureType def _zrtp_base32(sha1: bytes) -> str: @@ -36,3 +41,39 @@ def create_nonce(n: int = 32) -> str: def fingerprint(key: PGPKey) -> str: return key.fingerprint.upper().replace(' ', '') + + +def crc24(data: bytes) -> bytes: + # https://www.rfc-editor.org/rfc/rfc4880#section-6.1 + crc = 0xB704CE + for b in data: + crc ^= (b << 16) + for _ in range(8): + crc <<= 1 + if crc & 0x1000000: + crc ^= 0x1864CFB + return bytes([(crc & 0xff0000) >> 16, (crc & 0xff00) >> 8, (crc & 0xff)]) + + +def armor_keys(keys: List[PGPKey]) -> str: + joined = b''.join([bytes(k) for k in keys]) + armored = base64.b64encode(joined).decode() + wrapped = '\n'.join(textwrap.wrap(armored, 64)) + checksum = base64.b64encode(crc24(joined)).decode() + armored = '-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n' +\ + wrapped + '\n=' + checksum +\ + '\n-----END PGP PUBLIC KEY BLOCK-----\n' + return armored + + +def split_revoked(keys: Iterable[PGPKey]) -> Tuple[List[PGPKey], List[PGPKey]]: + revoked_keys = set() + for key in keys: + if len(key.revocation_signatures) == 0: + continue + for rsig in key.revocation_signatures: + if rsig.type == SignatureType.KeyRevocation: + revoked_keys.add(key) + break + key = [k for k in keys if k not in revoked_keys] + return key, list(revoked_keys) \ No newline at end of file