3 # ircproxy-privmsg-starttls.py -- Enforce STARTTLS over PRIVMSG
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
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.
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.
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
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.
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.
37 # From: Rick van Rein <rick@openfortress.nl>
54 prng = random.Random ()
60 sys.stderr.write ('This utility assumes you have the fdsend python library installed\n')
67 ircserver = ( ( 'irc.arpa2.org', 6667) )
68 ircproxy = ( ( '::1', 6667) )
72 # Check if the string contains a '\0' character, which is illegal in
73 # IRC message according to RFC 1459.
76 return chr (0) in irctext
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
87 def irctext2lines (txt):
90 txt.replace ('\r', '\n')
91 lines = txt.split ('\n')
92 return (lines [:-1], lines [-1])
96 # Map a (prefix,command,arguments) structure back to IRC command line syntax
98 def cmd2ircline ( (pfix,cmd,args) ):
100 ircline = ':' + pfix + ' '
103 ircline = ircline + cmd
105 ircline = ircline + ' ' + a
107 ircline = ircline + ' :' + a
108 ircline = ircline + '\r\n'
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).
115 # RFC 1459 defines the NUL character (code 0) as illegal, on grounds of
116 # programming problems in C.
118 # Returns (prefix-or-None, command, ircwords-or-middles)
120 def ircline2words (ircline):
121 i = ircline.find (' :')
123 trailing = ircline [i+2:]
124 ircline = ircline [:i]
127 ircwords = ircline.split (' ')
128 if len (ircwords) > 1 and ircwords [0] [:1] == ':':
129 prefix = ircwords [0] [1:]
130 ircwords = ircwords [1:]
133 command = ircwords [0].upper ()
136 args.append (trailing)
137 return (prefix, command, args)
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).
144 def nick2nai (nick, dash1map='@'):
145 dashwillbe = dash1map
146 parts = nick.split ('-')
148 for part in parts [1:]:
150 retval = retval + part
152 retval = retval + dashwillbe + part
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.
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.
170 class PrivateChannel ():
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.
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)
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
199 #TODO# Following should not return before done
201 self.initiate_starttls_handshake ()
206 def handle_upload_plain_cmd (self, (pfix,cmd,args)):
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.
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) )
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)
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')
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!)
241 # Optionally deliver insecurely
242 self.insecure = self.insecure + 1
243 self.ircprox.cli.write (cmd2ircline ( (pfix,cmd,args) ))
245 # Ensure that STARTTLS handshake is being proposed (again)
246 self.starttls_tlspool_attempt ()
249 def handle_download_starttls_handshake (self, (pfix,cmd,args)):
250 """Receive a STARTTLS handshake from the download direction.
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 ()
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
266 cmdln64 = base64.b64encode (enc_cmdline)
275 ircprox.upload_cmd (cmd)
277 def initiate_starttls_handshake (self):
278 """Send a STARTTLS handshake in the upload direction.
281 self.localhs = ''.join (prng.sample (string.uppercase, 10))
285 [ self.remotid, 'STARTTLS', self.localhs ]
287 self.ircprox.upload_cmd (triple)
288 self.starttls_tlspool_attempt ()
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.
295 if self.localhs is None:
297 # Local side has not sent STARTTLS yet
298 self.initiate_starttls_handshake ()
299 if self.remoths is None:
301 # Remote has not sent STARTTLS yet
303 if self.localhs == self.remoths:
305 # Explicit cancellation of the handshake
306 self.ircprox.download_cmd (
310 'STARTTLS cancelled (you might try again though)']) )
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
318 roles = tlspool.PIOF_STARTTLS_LOCALROLE_SERVER | tlspool.PIOF_STARTTLS_REMOTEROLE_CLIENT | tlspool.PIOF_STARTTLS_DETACH
320 roles = tlspool.PIOF_STARTTLS_LOCALROLE_CLIENT | tlspool.PIOF_STARTTLS_REMOTEROLE_SERVER | tlspool.PIOF_STARTTLS_DETACH
321 print 'Requesting STARTTLS'
324 'localid': self.localid_nai,
325 'remoteid': self.remotid_nai,
326 'ipproto': socket.IPPROTO_TCP,
327 'plainfd': self.poolplain
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'
336 print 'Successfully returned from STARTTLS; localid =', tlsdata.localid, 'and remoteid =', tlsdata.remoteid
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) )
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) )
347 self.handle_download_cmd ( (None, 'PRIVMSG', self.localid + ' :NOTE, we did not authenticate to the other side') )
348 if self.insecure > 0:
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))
357 # Terminate the activities in this object
361 raise NotImplementedError ()
366 # The IRC Server is a pretty simple TCP Server
368 # It splits lines and interprets commands in both directions
370 class IRCHandler (SocketServer.BaseRequestHandler):
372 def __init__ (self, x,y,z):
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...'
382 def upload_cmd (self, triple):
383 req = cmd2ircline (triple)
384 print '>>>', req.strip ()
385 self.srvlock.acquire ()
389 self.srvlock.release ()
391 def download_cmd (self, triple):
392 rsp = cmd2ircline (triple)
393 print '<<<', rsp.strip ()
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
406 def handle_upload (self, (pfix,cmd,args) ):
407 if cmd == 'NICK' and len (args) >= 1:
408 self.oldnick = self.nick
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 != '' ]
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:]
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) )
427 self.upload_cmd ( (pfix,cmd,args) )
429 def handle_download (self, (pfix,cmd,args) ):
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]
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:]
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') )
453 self.download_cmd ( (pfix,cmd,args) )
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]
465 rsox, _, _ = select.select (sox, [], [])
467 req = self.cli.recv (1024)
470 reqbuf = reqbuf + req
471 (lines,reqbuf) = irctext2lines (reqbuf)
475 self.handle_upload (ircline2words (req))
477 rsp = self.srv.recv (1024)
480 rspbuf = rspbuf + rsp
481 (lines,rspbuf) = irctext2lines (rspbuf)
485 self.handle_download (ircline2words (rsp))
490 class IRCServer (SocketServer.TCPServer):
491 address_family = socket.AF_INET6
496 # Setup the IRC server, bind to ircproxy and connect to ircserver
499 print 'SORRY -- THIS DEMO IS NOT WORKING YET'
501 retry = time.time () + 60
505 srv = IRCServer (ircproxy, IRCHandler)
506 print 'Connections welcomed'
509 if time.time () < retry:
510 if ioe.errno in [48, 98]:
511 sys.stdout.write ('Found socket locked...')
514 sys.stdout.write (' retrying\n')