Improvements in Python support and installation of Python and includes
[tlspool] / tool / ircproxy-privmsg-starttls.py
1 #!/usr/bin/env python
2 #
3 # ircproxy-privmsg-starttls.py -- Enforce STARTTLS over PRIVMSG
4 #
5 # This is an intermediate hop between an IRC client and an IRC server.  It
6 # monitors the flow of commands, and recognises a few and treats them
7 # specially.
8 #
9 # The reasoning is that group chat is public inasfar as the server cannot
10 # provide encryption; private messaging however, should be properly
11 # protected against eavesdropping, including form channel operators.
12 #
13 # To this end, the named commands, which are intended as end-to-end commands,
14 # are wrapped into TLS, and communicated under its cloak.  The resulting TLS
15 # protection is end-to-end, and it is rigidly applied to the said commands.
16 #
17 # The TLS data is sent as base64 without line breaks; lines may be split but
18 # these are then separately base64-encoded.  Each such line is prefixed with
19 # the word "TLS".
20 #
21 # To establish which party serves as client and which as server, the end points
22 # send a series of STARTxxx words, including "STARTTLS", ending with a random
23 # string of 1 to 32 bytes/characters in the range 0x21 to 0x7e, inclusive.
24 # The strings are compared with basic strcmp(); equality means that the TLS
25 # attempt is cancelled; when unequal, the lower value becomes the client and
26 # the higher the server (think "client" < "server").  To enforce the client role,
27 # issue the lowest string "!" and to enforce the server role, issue the highest
28 # string "~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~" -- in both cases, the risk is that
29 # the other side is equally stubborn and TLS cannot be negotiated.  When both
30 # sides have sent one of these commands (as PRIVMSG), the TLS can be further
31 # setup, which is done through the TLS Pool.
32 #
33 # Note that a future TLS extension might implement the same negotiation in TLS,
34 # in which case this external negotiation will be deprecated and the argument
35 # to STARTTLS may be made optional, or it may be ignored in whole or in part.
36 #
37 # From: Rick van Rein <rick@openfortress.nl>
38
39
40 import sys
41 import string
42 import time
43 import socket
44 import select
45 import threading
46 import random
47
48 import base64
49 import struct
50
51 import SocketServer
52
53
54 prng = random.Random ()
55
56
57 try:
58         import fdsend
59 except:
60         sys.stderr.write ('This utility assumes you have the fdsend python library installed\n')
61         sys.exit (1)
62
63
64 #
65 # Script parameters
66 #
67 ircserver = ( ( 'irc.arpa2.org', 6667) )
68 ircproxy  = ( ( '::1',           6667) )
69
70
71 #
72 # Check if the string contains a '\0' character, which is illegal in
73 # IRC message according to RFC 1459.
74 #
75 def hasnul (irctext):
76         return chr (0) in irctext
77
78
79 #
80 # Split an IRC text into lines, split by CR-LF or either, and ignoring
81 # empty lines.  Note that the last portion is not ready for processing
82 # yet; it will need expansion with upcoming IRC text.  In fact, if this
83 # appears not to be the case then the last portion will be '' and can
84 # still be safely prefixed without immediate processing to upcoming
85 # IRC text.
86 #
87 def irctext2lines (txt):
88         if hasnul (txt):
89                 return None
90         txt.replace ('\r', '\n')
91         lines = txt.split ('\n')
92         return (lines [:-1], lines [-1])
93
94
95 #
96 # Map a (prefix,command,arguments) structure back to IRC command line syntax
97 #
98 def cmd2ircline ( (pfix,cmd,args) ):
99         if pfix:
100                 ircline = ':' + pfix + ' '
101         else:
102                 ircline = ''
103         ircline = ircline + cmd
104         for a in args [:-1]:
105                 ircline = ircline + ' ' + a
106         for a in args [-1:]:
107                 ircline = ircline + ' :' + a
108         ircline = ircline + '\r\n'
109         return ircline
110
111 #
112 # Parse an IRC line, meaning something split by CR-LF (or either, really,
113 # since empty lines are ignored and separate CR or LF are ill-defined).
114 #
115 # RFC 1459 defines the NUL character (code 0) as illegal, on grounds of
116 # programming problems in C.
117 #
118 # Returns (prefix-or-None, command, ircwords-or-middles)
119 #
120 def ircline2words (ircline):
121         i = ircline.find (' :')
122         if i >= 0:
123                 trailing = ircline [i+2:]
124                 ircline = ircline [:i]
125         else:
126                 trailing = None
127         ircwords = ircline.split (' ')
128         if len (ircwords) > 1 and ircwords [0] [:1] == ':':
129                 prefix = ircwords [0] [1:]
130                 ircwords = ircwords [1:]
131         else:
132                 prefix = None
133         command = ircwords [0].upper ()
134         args = ircwords [1:]
135         if trailing:
136                 args.append (trailing)
137         return (prefix, command, args)
138
139 #
140 # Map a nick-name-format to a user@domain, as in nick@name.format
141 # (where the first dash becomes an @, later dashes become a dot, but
142 # multiple-dash is mapped to one dash less).
143 #
144 def nick2nai (nick, dash1map='@'):
145         dashwillbe = dash1map
146         parts = nick.split ('-')
147         retval = parts [0]
148         for part in parts [1:]:
149                 if part [:1] == '-':
150                         retval = retval + part
151                 else:
152                         retval = retval + dashwillbe + part
153                         dashwillbe = '.'
154         return retval
155
156
157 #
158 # The PrivateChannel is a channel through which TLS-encrypted messages
159 # are transferred between peers.  Specific interactions, such as the
160 # PRIVMSG to something that is not a channel (channel names start with
161 # a '#' char) pass through this intermediate, and are transformed into
162 # other messages with privacy protection and/or authentication.
163 # Each PrivateChannel object is dedicated to a particular remote peer,
164 # and is able to send messages, as well as to swallow them.
165 #
166 # In later versions, we may decide to split into an abstract level of
167 # PrivateChannel and specific mechanisms, such as TLS.  This would
168 # enable other transports like GSS-API and SSH to be used as well.
169 #
170 class PrivateChannel ():
171
172         def __init__ (self, ircprox, localid, remotid):
173                 """Start a new PrivateChannel.  The server can be
174                    reached over ircprox.send_to_server(), which will
175                    ensure proper locking.  The communication is
176                    between the given localid and remotid (both in
177                    nick form, see nick2nai(), and anything passing
178                    over the PrivateChannel will be sent as a PRIVMSG
179                    targeted at remotid, followed by TLS and one line
180                    of base64-encoded TLS binary data.  Large TLS data
181                    portions may be split over multiple lines, but
182                    each is separately base64-encoded, meaning, each
183                    line has its own "=" sign trailer.
184                 """
185                 assert (remotid [:1] != '#')
186                 self.ircprox = ircprox
187                 self.localid = localid
188                 self.remotid = remotid
189                 self.localid_nai = nick2nai (localid)
190                 self.remotid_nai = nick2nai (remotid)
191                 self.localhs = None
192                 self.remoths = None
193                 self.plaindownbuf = ''
194                 self.poolcrypt, self.chancrypt = socket.socketpair ()
195                 self.poolplain, self.chanplain = socket.socketpair ()
196                 print 'PrivateChannel crypt @pool =', self.poolcrypt, '@chan =', self.chancrypt
197                 print 'PrivateChannel plain @pool =', self.poolplain, '@chan =', self.chanplain
198                 self.insecure = 0
199                 #TODO# Following should not return before done
200                 # self.start ()
201                 self.initiate_starttls_handshake ()
202
203         def cleanup (self):
204                 pass
205
206         def handle_upload_plain_cmd (self, (pfix,cmd,args)):
207                 #TODO#
208                 pass
209
210         def handle_download_crypt_cmd (self, (pfix,cmd,args)):
211                 """Process a command in triple form (pfix,cmd,args) that
212                    arrived from the server, and has been determined to
213                    fit the scope of this PrivateChannel.
214                 """
215                 if len (args) != 3 or cmd.upper () != 'PRIVMSG':
216                         pass    # Exceptional cases handled below
217                 if 'STARTTLS' in args [1].upper ().split (','):
218                         handle_download_starttls_handshake ( (pfix,cmd,args) )
219                         return
220                 elif args [1].upper () in ['TLS'] and self.poolcrypt is not None:
221                         tlsdata = base64.b64decode (''.join (args [2:]))
222                         self.poolcrypt.write (tlsdata)
223                         tmpbuf = self.plaindownbuf
224                         while select.select ([self.poolplain], [], [], 0.0) != ([],[],[]):
225                                 #TODO:TEST# tmpbuf = tmpbuf + self.poolplain.recv (1024)
226                                 tmpbuf = tmpbuf + self.poolplain.recv (1)
227                         (lines, self.plaindownbuf) = irctext2lines (tmpbuf)
228                         for req in lines:
229                                 #
230                                 # Prefix the *authenticated* remote identity
231                                 # Ignore pfix, rather use TLS-authenticated remotid
232                                 # Ignore args [0], which is how they're calling us
233                                 realcmd = ':' + self.remotid + ' ' + req
234                                 self.ircprox.cli.write (realcmd + '\r\n')
235                         return
236                 #
237                 # TODO:OPTION: Refuse the attempt to do a non-PRIVMSG exchange
238                 # self.ircprox.upload_cmd ( (None,
239                 #       '404',  # Cannot send to channel (not without TLS!)
240                 #       [self.localid]) )
241                 # Optionally deliver insecurely
242                 self.insecure = self.insecure + 1
243                 self.ircprox.cli.write (cmd2ircline ( (pfix,cmd,args) ))
244                 #
245                 # Ensure that STARTTLS handshake is being proposed (again)
246                 self.starttls_tlspool_attempt ()
247                 return
248
249         def handle_download_starttls_handshake (self, (pfix,cmd,args)):
250                 """Receive a STARTTLS handshake from the download direction.
251                 """
252                 # Ignore pfix, rather rely on TLS-authenticated remotid
253                 assert (cmd.upper () == 'PRIVMSG')
254                 assert (len (args) == 3)
255                 assert (args [1].upper () == 'STARTTLS')
256                 self.remoths = args [2]
257                 self.starttls_tlspool_attempt ()
258
259         def upload_tls_encrypted_cmdline (self, enc_cmdline):
260                 """Process an encrypted IRC command line and forward it to
261                    the IRC server.  Note that the localid is not sent as
262                    part of the command; the receiving end will be better
263                    off adding the remotid that it has authenticated through
264                    the TLS Pool.
265                 """
266                 cmdln64 = base64.b64encode (enc_cmdline)
267                 cmd = (None,
268                         'PRIVMSG',
269                         # ' '.join (
270                                 [ self.remotid,
271                                 'TLS',
272                                  cmdln64 ]
273                         # )
274                 )
275                 ircprox.upload_cmd (cmd)
276
277         def initiate_starttls_handshake (self):
278                 """Send a STARTTLS handshake in the upload direction.
279                 """
280                 global prng
281                 self.localhs = ''.join (prng.sample (string.uppercase, 10))
282                 triple = (
283                         None,
284                         'PRIVMSG',
285                         [ self.remotid, 'STARTTLS', self.localhs ]
286                 )
287                 self.ircprox.upload_cmd (triple)
288                 self.starttls_tlspool_attempt ()
289
290         def starttls_tlspool_attempt (self):
291                 """After a STARTTLS handshake is sent or received, test
292                    if the handshake is complete and if so, proceed by
293                    starting the TLS Pool process.
294                 """
295                 if self.localhs is None:
296                         #
297                         # Local side has not sent STARTTLS yet
298                         self.initiate_starttls_handshake ()
299                 if self.remoths is None:
300                         #
301                         # Remote has not sent STARTTLS yet
302                         return
303                 if self.localhs == self.remoths:
304                         #
305                         # Explicit cancellation of the handshake
306                         self.ircprox.download_cmd (
307                                 (':' + self.remotid,
308                                 'PRIVMSG',
309                                 [self.localid,
310                                         'STARTTLS cancelled (you might try again though)']) )
311                 #
312                 # Now initiate the TLS Pool connection
313                 pingstr = tlspool.TLSPOOL_IDENTITY_V2
314                 print 'Sending ping', pingstr
315                 pingstr = tlspool.ping (pingstr)
316                 print 'Received ping', pingstr
317                 if self.server:
318                         roles = tlspool.PIOF_STARTTLS_LOCALROLE_SERVER | tlspool.PIOF_STARTTLS_REMOTEROLE_CLIENT | tlspool.PIOF_STARTTLS_DETACH
319                 else:
320                         roles = tlspool.PIOF_STARTTLS_LOCALROLE_CLIENT | tlspool.PIOF_STARTTLS_REMOTEROLE_SERVER | tlspool.PIOF_STARTTLS_DETACH
321                 print 'Requesting STARTTLS'
322                 tlsdata = {
323                         'flags': roles,
324                         'localid': self.localid_nai,
325                         'remoteid': self.remotid_nai,
326                         'ipproto': socket.IPPROTO_TCP,
327                         'plainfd': self.poolplain
328                 }
329                 privdata = { }
330                 print 'DEBUG: Calling starttls with tlsdata =', tlsdata
331                 if tlspool.starttls (self.poolcrypt, tlsdata, privdata) != 0:
332                         print 'STARTTLS was mutually agreed, but it failed during setup'
333                         self.localhs = None
334                         self.remoths = None
335                         return
336                 print 'Successfully returned from STARTTLS; localid =', tlsdata.localid, 'and remoteid =', tlsdata.remoteid
337                 #
338                 # Success -- we are now protected by TLS
339                 self.handle_download_cmd ( (None, 'PRIVMSG', self.localid + ' :This private chat is protected by TLS') )
340                 if tlsdata.remoteid != '':
341                         self.handle_download_cmd ( (None, 'PRIVMSG', self.localid + ' :Remote party authenticated as ' + tlsdata.remoteid) )
342                 else:
343                         self.handle_download_cmd ( (None, 'PRIVMSG', self.localid + ' :CAUTION, the remote party was not authenticated') )
344                 if tlsdata.localid != '':
345                         self.handle_download_cmd ( (None, 'PRIVMSG', self.localid + ' :We identified ourselves to them as ' + tlsdata.localid) )
346                 else:
347                         self.handle_download_cmd ( (None, 'PRIVMSG', self.localid + ' :NOTE, we did not authenticate to the other side') )
348                 if self.insecure > 0:
349                         #
350                         # Report (undelivered) insecure messages loudly
351                         warn = 'WARNING: ' + self.remotid + ' sent ' + str (self.insecure) + ' messages sent before TLS protection!'
352                         self.extsox.send ('PRIVMSG ' + self.remotid + ' :' + warn + '\r\n')
353                         self.handle_download_cmd (ircline2words (warn))
354                         self.insecure = 0
355
356         #
357         # Terminate the activities in this object
358         #
359         def stoptls (self):
360                 #NONEX# self.stop ()
361                 raise NotImplementedError ()
362                 self.cleanup ()
363
364
365 #
366 # The IRC Server is a pretty simple TCP Server
367 #
368 # It splits lines and interprets commands in both directions
369 #
370 class IRCHandler (SocketServer.BaseRequestHandler):
371
372         def __init__ (self, x,y,z):
373                 self.nick = None
374                 self.oldnick = None
375                 self.peerprivchan = { }
376                 self.srvlock = threading.Lock ()
377                 print 'IRC Handler initialised'
378                 print 'Calling super...'
379                 SocketServer.BaseRequestHandler.__init__ (self, x,y,z)
380                 print 'Called  super...'
381
382         def upload_cmd (self, triple):
383                 req = cmd2ircline (triple)
384                 print '>>>', req.strip ()
385                 self.srvlock.acquire ()
386                 try:
387                         self.srv.send (req)
388                 finally:
389                         self.srvlock.release ()
390
391         def download_cmd (self, triple):
392                 rsp = cmd2ircline (triple)
393                 print '<<<', rsp.strip ()
394                 self.cli.send (rsp)
395
396         def have_privchan (self, rid):
397                 if not self.peerprivchan.has_key (rid):
398                         print 'Initiating TLS via PRIVMSG to', rid
399                         privchan = PrivateChannel (self, self.nick, rid + '-tlspool-arpa2-lab') #TODO#FIXED#
400                         self.peerprivchan [rid] = privchan
401                         print 'Initiated  TLS via PRIVMSG to', rid
402                 privchan = self.peerprivchan [rid]
403                 print 'Returning self.peerprivchan ["' + rid + '"] ==', privchan
404                 return privchan
405
406         def handle_upload (self, (pfix,cmd,args) ):
407                 if cmd == 'NICK' and len (args) >= 1:
408                         self.oldnick = self.nick
409                         self.nick = args [0]
410                         print 'Set old NICK to', self.oldnick, 'and new NICK to', self.nick
411                         self.upload_cmd ( (pfix,cmd,args) )
412                 elif cmd == 'PRIVMSG' and len (args) >= 2:
413                         alldst = args [0].split (',')
414                         dirdst = [ d for d in alldst if d [:1] == '#' ]
415                         tlsdst = [ d for d in alldst if d [:1] != '#' and d != '' ]
416                         if dirdst != []:
417                                 print 'Sending plaintext PRIVMSG to', ' '.join (dirdst)
418                                 argsup = [','.join (dirdst)] + args [1:]
419                                 self.upload_cmd ( (pfix,cmd,argsup) )
420                         #DONOTHIDEPLAINTEXT# args = [','.join (tlsdst)] + args [1:]
421                         for td in tlsdst:
422                                 print 'Requiring TLS for PRIVMSG to', td
423                                 privchan = self.have_privchan (td)
424                                 print 'privchan ==', privchan
425                                 privchan.handle_upload_plain_cmd ( (pfix,cmd,args) )
426                 else:
427                         self.upload_cmd ( (pfix,cmd,args) )
428
429         def handle_download (self, (pfix,cmd,args) ):
430                 cmdu = cmd.upper ()
431                 if cmd in ['432','433','436']:          # NICK disapproved
432                         self.nick = self.oldnick
433                 elif cmdu == 'PRIVMSG' and len (args) >= 2:
434                         alldst = args [0].split (',')
435                         dirdst = [ d for d in alldst if d [:1] == '#' ]
436                         tlsdst = [ d for d in alldst if d [:1] != '#' and d != '' ]
437                         rid = pfix.split ('!') [0]
438                         if dirdst != []:
439                                 print 'Sending plaintext PRIVMSG from', rid
440                                 argsup = [','.join (dirdst)] + args [1:]
441                                 self.download_cmd ( (pfix,cmd,argsup) )
442                         #DONOTHIDEPLAINTEXT# args = [','.join (tlsdst)] + args [1:]
443                         for td in tlsdst:
444                                 print 'Requiring TLS for PRIVMSG from', rid
445                                 privchan = self.have_privchan (rid)
446                                 print 'privchan ==', privchan
447                                 privchan.handle_download_crypt_cmd ( (pfix,cmd,args) )
448                 elif cmdu == 'PRIVMSG':
449                         # ALT/OPTION: Permit old-mode PRIVMSG until /STARTTLS or /SECRET
450                         print 'Rejecting PRIVMSG without TLS formatting'
451                         self.upload_cmd ( (None, '404', 'TODO:CHANNAME :Private chat enforces STARTTLS') )
452                 else:
453                         self.download_cmd ( (pfix,cmd,args) )
454
455         def handle (self):
456                 """self.request is a TCP socket for IRC"""
457                 self.cli = self.request
458                 self.srv = socket.socket (socket.AF_INET6, socket.SOCK_STREAM, 0)
459                 self.srv.connect (ircserver)
460                 sox = [self.cli, self.srv]
461                 more = True
462                 reqbuf = ''
463                 rspbuf = ''
464                 while more:
465                         rsox, _, _ = select.select (sox, [], [])
466                         if self.cli in rsox:
467                                 req = self.cli.recv (1024)
468                                 if req == '':
469                                         more = False
470                                 reqbuf = reqbuf + req
471                                 (lines,reqbuf) = irctext2lines (reqbuf)
472                                 for req in lines:
473                                         if req == '':
474                                                 continue
475                                         self.handle_upload (ircline2words (req))
476                         if self.srv in rsox:
477                                 rsp = self.srv.recv (1024)
478                                 if rsp == '':
479                                         more = False
480                                 rspbuf = rspbuf + rsp
481                                 (lines,rspbuf) = irctext2lines (rspbuf)
482                                 for rsp in lines:
483                                         if rsp == '':
484                                                 continue
485                                         self.handle_download (ircline2words (rsp))
486                 print 'Disconnected'
487
488
489
490 class IRCServer (SocketServer.TCPServer):
491         address_family = socket.AF_INET6
492
493
494
495 #
496 # Setup the IRC server, bind to ircproxy and connect to ircserver
497 #
498
499 print 'SORRY -- THIS DEMO IS NOT WORKING YET'
500
501 retry = time.time () + 60
502 srv = None
503 while True:
504         try:
505                 srv = IRCServer (ircproxy, IRCHandler)
506                 print 'Connections welcomed'
507                 srv.serve_forever ()
508         except IOError, ioe:
509                 if time.time () < retry:
510                         if ioe.errno in [48, 98]:
511                                 sys.stdout.write ('Found socket locked...')
512                                 sys.stdout.flush ()
513                                 time.sleep (5)
514                                 sys.stdout.write (' retrying\n')
515                                 sys.stdout.flush ()
516                                 continue
517                 raise
518         break
519 if srv:
520         srv.server_close ()
521
522