Skip to content

Commit 1cd5ba2

Browse files
committed
Detect presence of DKIM keys, use pre-signed message otherwise
o Detect Ed25519 keys o Produce output from slip39.invoice.communication CLI
1 parent e84c0fb commit 1cd5ba2

File tree

2 files changed

+193
-76
lines changed

2 files changed

+193
-76
lines changed

slip39/invoice/communications.py

Lines changed: 131 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
1515
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
1616
#
17+
import json
1718
import logging
1819
import re
1920
import smtplib
@@ -25,10 +26,10 @@
2526
import click
2627
import dkim
2728

28-
from tabulate import tabulate
29-
from email import utils, message_from_file
30-
from email.mime import multipart, text
3129
from crypto_licensing.licensing import doh
30+
from email import utils, message_from_file, message_from_string
31+
from email.mime import multipart, text
32+
from tabulate import tabulate
3233

3334
from ..util import is_listlike, commas, uniq, log_cfg, log_level
3435

@@ -115,8 +116,6 @@ def dkim_message(
115116

116117
if headers is None:
117118
headers = ["From", "To", "Subject"]
118-
if signature_algorithm is None:
119-
signature_algorithm = "rsa-sha256" # "ed25519-sha256" not well supported, yet.
120119

121120
sender_domain = matchaddr( sender_email ).group( 3 )
122121

@@ -151,6 +150,10 @@ def dkim_message(
151150
# needs to be encoded from strings to bytes.
152151
with open(dkim_private_key_path) as fh:
153152
dkim_private_key = fh.read()
153+
if signature_algorithm is None:
154+
# "ed25519-sha256" not well supported, yet. But, if the key is short, that's probably what it is.
155+
signature_algorithm = "rsa-sha256" if len( dkim_private_key ) > 48 else 'ed25519-sha256'
156+
154157
sig = dkim.sign(
155158
message = msg_data,
156159
selector = str(dkim_selector).encode(),
@@ -165,24 +168,27 @@ def dkim_message(
165168
# b'DKIM-Signature: v=1; i=@lic...\r\n s=... b=Fp2...6H\r\n 5//6o...Ag=='
166169
# ^^^^^ ^^^^^
167170
#
168-
# contains a bunch of unnecessary whitespace, especially within the b: and bh: base-64
169-
# data. However, this whitespace is ignored by the standard email.Message parser.
171+
# contains a bunch of unnecessary whitespace, especially within the b: and bh: base-64 data.
172+
# However, this whitespace is ignored by the standard email.Message parser. Tidy it up.
170173
#
171174
pre,sig_dirty = sig.decode( 'utf-8' ).split( ':', 1 )
172-
log.info( f"DKIM signed: {sig_dirty!r}" )
175+
log.info( f"DKIM signed: {sig_dirty.strip()!r}" )
173176
assert pre.lower() == "dkim-signature"
174177

175-
# This seems to corrupt the signature, unexpectedly...
176-
#sig_kvs = sig_dirty.split( ';' )
177-
#sig_k_v = list(
178-
# (k.strip(), ''.join(v.split())) # eliminates internal v whitespace
179-
# for k,v in ( kv.split( '=', 1 ) for kv in sig_kvs )
180-
#)
181-
#sig_clean = '; '.join( f"{k}={v}" for k,v in sig_k_v )
182-
#log.info( f"DKIM clean: {sig_clean!r}" )
183-
178+
# This seemed to corrupt the signature, unexpectedly... Ah, because we removed internal
179+
# whitespace from with the '; h=from : to', which is part of the signature. Only clean b/bh.
180+
sig_kvs = sig_dirty.split( ';' )
181+
sig_k_v = dict(
182+
(k.strip(), v.strip())
183+
for k,v in ( kv.split( '=', 1 ) for kv in sig_kvs )
184+
)
185+
for k in ('b', 'bh'):
186+
if k in sig_k_v: # eliminates internal whitespace
187+
sig_k_v[k] = ''.join( sig_k_v[k].split() )
188+
sig_clean = '; '.join( f"{k}={v}" for k,v in sig_k_v.items() )
189+
log.info( f"DKIM clean: {sig_clean!r}" )
184190
# add the dkim signature to the email message headers.
185-
msg["DKIM-Signature"] = sig_dirty.strip()
191+
msg["DKIM-Signature"] = sig_clean
186192

187193
return msg
188194

@@ -346,17 +352,33 @@ def __init__( self, reinject=None ):
346352
if reinject in (None, True):
347353
reinject = [ '/usr/sbin/sendmail', '-G', '-i', '-f' ]
348354
if is_listlike( reinject ):
349-
self.reinject = list( map( str, reinject ))
355+
self.reinject = list( map( str, reinject )) # [...]: command, eg. 'sendmail', ...
350356
elif not reinject:
351-
self.reinject = lambda *args: None # False, ''
357+
self.reinject = lambda *args: None # False, '': A NO-OP -- do not re-inject
352358
else:
353-
self.reinject = reinject # str, callable
354-
log.info( f"Mail reinjection: {self.reinject!r}" )
359+
self.reinject = reinject # str, callable: a Shell command, or a function
360+
self.reset()
355361

356-
def respond( self, from_addr, *to_addrs ):
357-
msg = self.message()
362+
def reset( self ):
363+
self.msg = None # The incoming original email.Message
364+
self.msg_peers = (None,[]) # and its (mail_from, [rcpt_to, ...])
365+
self.rsp = None # The intended response email.Message
366+
self.rsp_peers = (None,[]) # and its (mail_from, [rcpt_to, ...])
367+
368+
def __call__( self, from_addr, *to_addrs ):
369+
"""Respond to an incoming email.Message according to the filter. Returns an appropriate Filter exit
370+
status code (see above).
371+
372+
The base class simply re-injects the message, and does nothing else (does not even try to
373+
generate the self.rsp from self.response( msg ), which remains None.)
374+
375+
"""
376+
self.reset()
377+
self.msg = self.message()
378+
self.msg_peers = (from_addr, to_addrs)
358379
try:
359-
# Allow a command (list), a shell command or a function for reinject
380+
# Allow a command (list), a shell command or a function for reinject, logging
381+
# the results of the re-injection command/function.
360382
err = None
361383
if is_listlike( self.reinject ):
362384
with Popen(
@@ -365,7 +387,7 @@ def respond( self, from_addr, *to_addrs ):
365387
stdout = PIPE,
366388
stderr = PIPE
367389
) as process:
368-
out, err = process.communicate( msg.as_bytes() )
390+
out, err = process.communicate( self.msg.as_bytes() )
369391
out = out.decode( 'UTF-8' )
370392
err = err.decode( 'UTF-8' )
371393
elif isinstance( self.reinject, str ):
@@ -376,29 +398,39 @@ def respond( self, from_addr, *to_addrs ):
376398
stdout = PIPE,
377399
stderr = PIPE
378400
) as process:
379-
out, err = process.communicate( msg.as_bytes() )
401+
out, err = process.communicate( self.msg.as_bytes() )
380402
out = out.decode( 'UTF-8' )
381403
err = err.decode( 'UTF-8' )
382404
else:
383405
out = self.reinject( from_addr, *to_addrs )
384406
log.info( f"Results of reinjection: stdout: {out}, stderr: {err}" )
385407
except Exception as exc:
408+
# Something went wrong attempting re-injection
386409
log.warning( f"Re-injecting message From: {from_addr}, To: {commas( to_addrs )} via sendmail failed: {exc}" )
387-
raise
388-
return msg
410+
return 75 # tempfail
411+
return 0
389412

390413
def message( self ):
391-
"""Return (a possibly altered) email.Message as the auto-response. By default, an filter
392-
receives its email.Message from stdin, unaltered.
414+
"""Obtain and return the email.Message we are to process. By default, an filter receives its
415+
email.Message from stdin, unaltered.
393416
394417
"""
395-
msg = message_from_file( sys.stdin )
396-
return msg
418+
return message_from_file( sys.stdin )
397419

398-
def response( self, msg ):
399-
"""Prepare the response message. Normally, it is at least just a different message."""
400-
msg
401-
return msg
420+
def response( self, msg, new_id=None ):
421+
"""Prepare the auto-responder message. Copies the incoming email.Message, to avoid altering
422+
it. Optionally, give it a different Message-ID.
423+
424+
"""
425+
rsp = message_from_string( msg.as_string() )
426+
if new_id:
427+
domain = None
428+
# Prefer the sender field per RFC 2822:3.6.2.
429+
sender_m = matchaddr( rsp['Sender'] if 'Sender' in rsp else rsp['From'] )
430+
if sender_m:
431+
domain = sender_m.group( 3 )
432+
rsp['Message-ID'] = utils.make_msgid( domain=domain )
433+
return rsp
402434

403435

404436
class AutoResponder( PostQueue ):
@@ -415,25 +447,33 @@ def __init__( self, *args, address=None, server=None, port=None, **kwds ):
415447

416448
log.info( f"autoresponding to DKIM-signed emails To: {self.address}@{self.domain}" )
417449

418-
def respond( self, from_addr, *to_addrs ):
419-
"""Decide if we should auto-respond, and do so."""
450+
def __call__( self, from_addr, *to_addrs ):
451+
"""Decide if we should auto-respond, and do so. Return the email.Message, and an appropriate
452+
Postfix exit code.
453+
454+
"""
455+
456+
status = super().__call__( from_addr, *to_addrs )
457+
if status:
458+
return status
420459

421-
msg = super().respond( from_addr, *to_addrs )
422-
log.info( f"Filtered From: {msg['from']}, To: {msg['to']}"
460+
log.info( f"Filtered From: {self.msg['From']}, To: {self.msg['To']}"
423461
f" originally from {from_addr} to {len(to_addrs)} recipients: {commas( to_addrs, final='and' )}" )
424462

425-
if 'to' not in msg or not matchaddr( msg['to'], mailbox=self.mailbox, domain=self.domain ):
426-
log.warning( f"Message From: {msg['from']}, To: {msg['to']} expected To: {self.address}; not auto-responding" )
463+
# Detect if this is a message we are intended to autorespond to; if not, do nothing.
464+
if 'To' not in self.msg or not matchaddr( self.msg['To'], mailbox=self.mailbox, domain=self.domain ):
465+
log.warning( f"Message From: {self.msg['From']}, To: {self.msg['To']} expected To: {self.address}; not auto-responding" )
427466
return 0
428-
if 'dkim-signature' not in msg:
429-
log.warning( f"Message From: {msg['from']}, To: {msg['to']} is not DKIM signed; not auto-responding" )
467+
if 'dkim-signature' not in self.msg:
468+
log.warning( f"Message From: {self.msg['From']}, To: {self.msg['To']} is not DKIM signed; not auto-responding" )
430469
return 0
431-
if not dkim.verify( msg.as_bytes() ):
432-
log.warning( f"Message From: {msg['from']}, To: {msg['to']} DKIM signature fails; not auto-responding " )
470+
if not dkim.verify( self.msg.as_bytes() ):
471+
log.warning( f"Message From: {self.msg['From']}, To: {self.msg['To']} DKIM signature fails; not auto-responding " )
433472
return 0
434473

435-
# Normalize, uniqueify and filter the addresses (discarding invalids). Avoid sending it to
436-
# the designated self.address, as this would set up a mail loop.
474+
# This message is a target: Normalize, uniqueify and filter the addresses (discarding
475+
# invalids). Avoid sending it to the designated self.address, as this would set up a mail
476+
# loop.
437477
to_addrs_filt = [
438478
fa
439479
for fa in uniq( filter( None, (
@@ -443,36 +483,40 @@ def respond( self, from_addr, *to_addrs ):
443483
if fa != self.address
444484
]
445485

446-
rsp = self.response( msg )
447-
# Also use the Reply-To: address, if supplied
448-
if 'reply-to' in rsp:
449-
_,reply_to = utils.parseaddr( rsp['reply-to'] )
486+
# Get the outgoing message; also use the Reply-To: address, if supplied
487+
self.rsp = self.response( self.msg )
488+
if 'reply-to' in self.rsp:
489+
_,reply_to = utils.parseaddr( self.rsp['reply-to'] )
450490
if reply_to not in to_addrs_filt:
451491
to_addrs_filt.append( reply_to )
492+
self.rsp_peers = (from_addr, tuple( to_addrs_filt ))
452493

453-
log.info( f"Response From: {rsp['from']}, To: {rsp['to']}"
494+
log.info( f"Response From: {self.rsp['From']}, To: {self.rsp['To']}"
454495
f" autoresponding from {from_addr} to {len(to_addrs_filt)} recipients: {commas( to_addrs_filt, final='and' )}"
455-
+ ( f" (was {len(to_addrs)}: {commas( to_addrs, final='and' )})" if to_addrs != to_addrs_filt else "" ))
496+
+ ( "" if set( to_addrs ) == set( to_addrs_filt ) else f" (was {len(to_addrs)}: {commas( to_addrs, final='and' )})" ))
456497

457-
# Now, send the same message to all the supplied Reply-To, and Cc/Bcc address (already in
458-
# responder.to_addrs). If it is DKIM signed, since we're not adjusting the message -- just
459-
# send w/ new RCPT TO: envelope addresses. We'll use the same from_addr address (it must be
460-
# from our domain, or we wouldn't be processing it.
498+
# Now, send the same message to all the supplied Reply-To, and Cc/Bcc addresses (which were
499+
# already in to_addrs). If it is DKIM signed, it will remain so, since we're not adjusting
500+
# the message -- just send w/ new RCPT TO: envelope addresses. We'll use the same from_addr
501+
# address.
461502
try:
462503
send_message(
463-
rsp,
504+
self.rsp,
464505
from_addr = from_addr,
465506
to_addrs = to_addrs_filt,
466507
relay = self.relay,
467508
port = self.port,
468509
)
469510
except Exception as exc:
470-
log.warning( f"Message From: {rsp['from']}, To: {rsp['to']} autoresponder SMTP send failed: {exc}" )
511+
log.warning( f"Message From: {self.rsp['From']}, To: {self.rsp['To']} autoresponder SMTP send failed: {exc}" )
471512
return 75 # tempfail
472513

473514
return 0
474515

475516

517+
#
518+
# Provide a CLI to access the AutoResponder
519+
#
476520
@click.group()
477521
@click.option('-v', '--verbose', count=True)
478522
@click.option('-q', '--quiet', count=True)
@@ -508,20 +552,42 @@ def autoresponder( address, from_addr, to_addrs, server, port, reinject ):
508552
- We won't autorespond to copies forwarded from other email addresses
509553
510554
511-
Configure Postfix system as per: https://github.com/innovara/autoreply, except create
555+
Configure Postfix system as per: https://github.com/innovara/autoreply, eg.:
512556
513557
# autoresponder pipe
514558
autoreply unix - n n - - pipe
515559
flags= user=autoreply null_sender=
516-
argv=python -m slip39.email autoresponder [email protected] ${sender} ${recipient}
560+
argv=python3 -m slip39.invoice.communication autoresponder [email protected] ${sender} ${recipient}
517561
518562
"""
519-
AutoResponder(
563+
ar = AutoResponder(
520564
address = address,
521565
server = server,
522566
port = port,
523567
reinject = reinject,
524-
).respond( from_addr, *to_addrs )
568+
)
569+
status = ar( from_addr, *to_addrs )
570+
if cli.json:
571+
click.echo( json.dumps( dict(
572+
dict(
573+
dict(
574+
Original = ar.msg.as_string() if ar.msg else None,
575+
) if cli.verbosity > 1 else dict(),
576+
Response = ar.rsp.as_string() if ar.rsp else None,
577+
) if cli.verbosity > 0 else dict(),
578+
status = status,
579+
MAIL_FROM = ar.rsp_peers[0],
580+
RCPT_TOs = ar.rsp_peers[1],
581+
), indent=4 ))
582+
else:
583+
click.echo( f"status: {status}" )
584+
click.echo( f"MAIL FROM: {ar.rsp_peers[0]}" )
585+
click.echo( f"RCPT TOs: {commas( ar.rsp_peers[1] )}" )
586+
if cli.verbosity > 1:
587+
click.echo( f"Original:\n{ar.msg.as_string() if ar.msg else None}" )
588+
if cli.verbosity > 0:
589+
click.echo( f"Response:\n{ar.rsp.as_string() if ar.rsp else None}" )
590+
sys.exit( status )
525591

526592

527593
cli.add_command( autoresponder )

0 commit comments

Comments
 (0)