Update from samosa: be less conservative when looking for an encrypted message
[mirror/userdir-ldap.git] / userdir_gpg.py
1  #!/usr/bin/env python
2 # -*- mode: python -*-
3
4 # GPG issues - 
5 #  - gpgm with a status FD being fed keymaterial and other interesting
6 #    things does nothing.. If it could ID the keys and stuff over the
7 #    status-fd I could decide what to do with them. I would also like it
8 #    to report which key it selected for encryption (also if there 
9 #    were multi-matches..) Being able to detect a key-revoke cert would be
10 #    good too.
11 #  - I would like to be able to fetch the comment and version fields from the 
12 #    packets so I can tell if a signature is made by pgp2 to enable the
13 #    pgp2 encrypting mode.
14
15 import string, mimetools, multifile, sys, StringIO, os, tempfile, re;
16 import rfc822, time, fcntl, FCNTL, anydbm
17
18 # General GPG options
19 GPGPath = "gpg"
20 # "--load-extension","rsa",
21 GPGBasicOptions = ["--no-options","--batch",
22           "--no-default-keyring","--always-trust"];
23 GPGKeyRings = [];
24 GPGSigOptions = ["--output","-"];
25 GPGSearchOptions = ["--dry-run","--with-colons","--fingerprint"];
26 GPGEncryptOptions = ["--output","-","--quiet","--always-trust",\
27                      "--armor","--encrypt"];
28 GPGEncryptPGP2Options = ["--set-filename","","--rfc1991",\
29                          "--load-extension","idea",\
30                          "--cipher-algo","idea"] + GPGEncryptOptions;
31
32 # Replay cutoff times in seconds
33 CleanCutOff = 7*24*60*60;
34 AgeCutOff = 4*24*60*60;
35 FutureCutOff = 3*24*60*60;
36
37 # Set the keyrings, the input is a list of keyrings
38 def SetKeyrings(Rings):
39    for x in Rings:
40       GPGKeyRings.append("--keyring");
41       GPGKeyRings.append(x);           
42
43 # GetClearSig takes an un-seekable email message stream (mimetools.Message) 
44 # and returns a standard PGP '---BEGIN PGP SIGNED MESSAGE---' bounded 
45 # clear signed text.
46 # If this is fed to gpg/pgp it will verify the signature and spit out the
47 # signed text component. Email headers and PGP mime (RFC 2015) is understood
48 # but no effort is made to cull any information outside the PGP boundaries
49 # Please note that in the event of a mime decode the mime headers will be
50 # present in the signature text! The return result is a tuple, the first
51 # element is the text itself the second is a mime flag indicating if the
52 # result should be mime processed after sig checking.
53 #
54 # Paranoid will check the message text to make sure that all the plaintext is 
55 # in fact signed (bounded by a PGP packet)
56 def GetClearSig(Msg,Paranoid = 0):
57    Error = 'MIME Error';
58    # See if this is a MIME encoded multipart signed message
59    if Msg.gettype() == "multipart/signed":
60       Boundary = Msg.getparam("boundary");
61       if not Boundary:
62          raise Error, "multipart/* without a boundary parameter";
63
64       # Create the multipart handler. Regrettably their implementation 
65       # Needs seeking..
66       SkMessage = StringIO.StringIO();
67       SkMessage.write(Msg.fp.read());
68       SkMessage.seek(0);
69       mf = multifile.MultiFile(SkMessage)
70       mf.push(Msg.getparam("boundary"));
71
72       # Check the first bit of the message..
73       if Paranoid != 0:
74          Pos = mf.tell();
75          while 1:
76              x = mf.readline();
77              if not x: break;
78              if len(string.strip(x)) != 0:
79                 raise Error,"Unsigned text in message (at start)";
80          mf.seek(Pos);
81       
82       # Get the first part of the multipart message
83       if not mf.next():
84          raise Error, "Invalid pgp/mime encoding [no section]";
85
86       # Get the part as a safe seekable stream
87       Signed = StringIO.StringIO();
88       Signed.write(mf.read());
89       InnerMsg = mimetools.Message(Signed);
90       
91       # Make sure it is the right type
92       if InnerMsg.gettype() != "text/plain":
93          raise Error, "Invalid pgp/mime encoding [wrong plaintext type]";
94    
95       # Get the next part of the multipart message
96       if not mf.next():
97          raise Error, "Invalid pgp/mime encoding [no section]";
98       InnerMsg = mimetools.Message(mf);
99       if InnerMsg.gettype() != "application/pgp-signature":
100          raise Error, "Invalid pgp/mime encoding [wrong signature type]";
101       Signature = string.joinfields(mf.readlines(),'');
102
103       # Check the last bit of the message..
104       if Paranoid != 0:
105          mf.pop();
106          Pos = mf.tell();
107          while 1:
108              x = mf.readline();
109              if not x: break; 
110              if len(string.strip(x)) != 0:
111                 raise Error,"Unsigned text in message (at end)";
112          mf.seek(Pos);
113       
114       # Append the PGP boundary header and the signature text to re-form the
115       # original signed block [needs to convert to \r\n]
116       Output = "-----BEGIN PGP SIGNED MESSAGE-----\r\n";
117       # Semi-evil hack to get the proper hash type inserted in the message
118       if Msg.getparam('micalg') != None:
119           Output = Output + "Hash: MD5,SHA1,%s\r\n"%(string.upper(Msg.getparam('micalg')[4:]));
120       Output = Output + "\r\n";
121       Output = Output +  string.replace(Signed.getvalue(),"\n-","\n- -") + Signature;
122       return (Output,1);
123    else:
124       if Paranoid == 0:
125          # Just return the message body
126          return (string.joinfields(Msg.fp.readlines(),''),0);
127      
128       Body = "";
129       State = 1;
130       for x in Msg.fp.readlines():
131           Body = Body + x;
132           Tmp = string.strip(x);
133           if len(Tmp) == 0:
134              continue;
135          
136           # Leading up to the signature
137           if State == 1:
138              if Tmp == "-----BEGIN PGP SIGNED MESSAGE-----":
139                 State = 2;
140              else:
141                 raise Error,"Unsigned text in message (at start)";
142              continue;
143          
144           # In the signature plain text
145           if State == 2:
146              if Tmp == "-----BEGIN PGP SIGNATURE-----":
147                 State = 3;
148              continue;
149                 
150           # In the signature
151           if State == 3:
152              if Tmp == "-----END PGP SIGNATURE-----":
153                 State = 4;
154              continue;
155                 
156           # Past the end
157           if State == 4:
158              raise Error,"Unsigned text in message (at end)";
159       return (Body,0);
160
161 # This opens GPG in 'write filter' mode. It takes Message and sends it
162 # to GPGs standard input, pipes the standard output to a temp file along
163 # with the status FD. The two tempfiles are passed to GPG by fd and are
164 # accessible from the filesystem for only a short period. Message may be
165 # None in which case GPGs stdin is closed directly after forking. This
166 # is best used for sig checking and encryption.
167 # The return result is a tuple (Exit,StatusFD,OutputFD), both fds are
168 # fully rewound and readable.
169 def GPGWriteFilter(Program,Options,Message):
170    # Make sure the tmp files we open are unreadable, there is a short race
171    # between when the temp file is opened and unlinked that some one else
172    # could open it or hard link it. This is not important however as no 
173    # Secure data is fed through the temp files.
174    OldMask = os.umask(0777);
175    try:
176       Output = tempfile.TemporaryFile("w+b");
177       GPGText = tempfile.TemporaryFile("w+b");
178       InPipe = os.pipe();
179       InPipe = [InPipe[0],InPipe[1]];
180    finally:
181       os.umask(OldMask);
182       
183    try:
184       # Fork off GPG in a horrible way, we redirect most of its FDs
185       # Input comes from a pipe and its two outputs are spooled to unlinked
186       # temp files (ie private)
187       Child = os.fork();
188       if Child == 0:
189          try:
190             os.dup2(InPipe[0],0);
191             os.close(InPipe[1]);
192             os.dup2(Output.fileno(),1);
193             os.dup2(os.open("/dev/null",os.O_WRONLY),2);
194             os.dup2(GPGText.fileno(),3);
195             
196             Args = [Program,"--status-fd","3"] + GPGBasicOptions + GPGKeyRings + Options
197             os.execvp(Program,Args);
198          finally:
199             os._exit(100);
200       
201       # Get rid of the other end of the pipe
202       os.close(InPipe[0])
203       InPipe[0] = -1;
204
205       # Send the message
206       if Message != None:
207          try:
208             os.write(InPipe[1],Message);
209          except:
210            pass;
211       os.close(InPipe[1]);
212       InPipe[1] = -1;
213
214       # Wait for GPG to finish
215       Exit = os.waitpid(Child,0);
216
217       # Create the result including the new readable file descriptors
218       Result = (Exit,os.fdopen(os.dup(GPGText.fileno()),"r"), \
219                 os.fdopen(os.dup(Output.fileno()),"r"));
220       Result[1].seek(0);
221       Result[2].seek(0);
222
223       Output.close();
224       GPGText.close();
225       return Result;
226    finally:
227       if InPipe[0] != -1:
228          os.close(InPipe[0]);
229       if InPipe[1] != -1:
230          os.close(InPipe[1]);
231       Output.close();
232       GPGText.close();
233
234 # This takes a text passage, a destination and a flag indicating the 
235 # compatibility to use and returns an encrypted message to the recipient.
236 # It is best if the recipient is specified using the hex key fingerprint
237 # of the target, ie 0x64BE1319CCF6D393BF87FF9358A6D4EE
238 def GPGEncrypt(Message,To,PGP2):
239    # Encrypt using the PGP5 block encoding and with the PGP5 option set.
240    # This will handle either RSA or DSA/DH asymetric keys.
241    # In PGP2 compatible mode IDEA and rfc1991 encoding are used so that
242    # PGP2 can read the result. RSA keys do not need PGP2 to be set, as GPG
243    # can read a message encrypted with blowfish and RSA.
244    if PGP2 == 0:
245       try:
246          Res = None;
247          Res = GPGWriteFilter(GPGPath,["-r",To]+GPGEncryptOptions,Message);
248          if Res[0][1] != 0:
249             return None;
250          Text = Res[2].read();
251          return Text;
252       finally:
253          if Res != None:
254             Res[1].close();
255             Res[2].close();
256    else:
257       # We have to call gpg with a filename or it will create a packet that
258       # PGP2 cannot understand.
259       TmpName = tempfile.mktemp();
260       try:
261          Res = None;
262          MsgFile = open(TmpName,"wc");
263          MsgFile.write(Message);
264          MsgFile.close();
265          Res = GPGWriteFilter(GPGPath,["-r",To]+GPGEncryptPGP2Options+[TmpName],None);
266          if Res[0][1] != 0:
267             return None;
268          Text = Res[2].read();
269          return Text;
270       finally:
271          try:
272             os.unlink(TmpName);
273          except:
274             pass;
275          if Res != None:
276             Res[1].close();
277             Res[2].close();
278
279 # Checks the signature of a standard PGP message, like that returned by
280 # GetClearSig. It returns a large tuple of the form:
281 #   (Why,(SigId,Date,KeyFinger),(KeyID,KeyFinger,Owner,Length,PGP2),Text);
282 # Where,
283 #  Why = None if checking was OK otherwise an error string. 
284 #  SigID+Date represent something suitable for use in a replay cache. The
285 #             date is returned as the number of seconds since the UTC epoch.
286 #             The keyID is also in this tuple for easy use of the replay 
287 #             cache
288 #  KeyID, KeyFinger and Owner represent the Key used to sign this message
289 #         PGP2 indicates if the message was created using PGP 2.x 
290 #  Text is the full byte-for-byte signed text in a string
291 def GPGCheckSig(Message):
292    Res = None;
293    try:
294       Res = GPGWriteFilter(GPGPath,GPGSigOptions,Message);
295       Exit = Res[0];
296
297       # Parse the GPG answer
298       Strm = Res[1];
299       GoodSig = 0;
300       SigId = None;
301       KeyFinger = None;
302       KeyID = None;
303       Owner = None;
304       Date = None;
305       Why = None;
306       TagMap = {};
307       while(1):
308          # Grab and split up line
309          Line = Strm.readline();
310          if Line == "":
311             break;
312          Split = re.split("[ \n]",Line);
313          if Split[0] != "[GNUPG:]":
314             continue;
315
316          # We only process the first occurance of any tag.
317          if TagMap.has_key(Split[1]):
318             continue;
319          TagMap[Split[1]] = None;
320
321          # Good signature response
322          if Split[1] == "GOODSIG":
323             # Just in case GPG returned a bad signal before this (bug?)
324             if Why == None:
325                GoodSig = 1;
326             KeyID = Split[2];
327             Owner = string.join(Split[3:],' ');
328             
329          # Bad signature response
330          if Split[1] == "BADSIG":
331             GoodSig = 0;
332             KeyID = Split[2];
333             Why = "Verification of signature failed";
334
335          # Bad signature response
336          if Split[1] == "ERRSIG":
337             GoodSig = 0;
338             KeyID = Split[2];
339             if len(Split) <= 7:
340                Why = "GPG error, ERRSIG status tag is invalid";
341             elif Split[7] == '9':
342                Why = "Unable to verify signature, signing key missing.";
343             elif Split[7] == '4':
344                Why = "Unable to verify signature, unknown packet format/key type";
345             else:   
346                Why = "Unable to verify signature, unknown reason";
347
348          if Split[1] == "NO_PUBKEY":
349             GoodSig = 0;
350             Why = "Unable to verify signature, signing key missing.";
351
352          # Expired signature
353          if Split[1] == "SIGEXPIRED":
354             GoodSig = 0;
355             Why = "Signature has expired";
356             
357          # Revoked key
358          if Split[1] == "KEYREVOKED":
359             GoodSig = 0;
360             Why = "Signing key has been revoked";
361
362          # Corrupted packet
363          if Split[1] == "NODATA" or Split[1] == "BADARMOR":
364             GoodSig = 0;
365             Why = "The packet was corrupted or contained no data";
366             
367          # Signature ID
368          if Split[1] == "SIG_ID":
369             SigId = Split[2];
370             Date = long(Split[4]);
371
372          # ValidSig has the key finger print
373          if Split[1] == "VALIDSIG":
374             KeyFinger = Split[2];
375
376       # Reopen the stream as a readable stream
377       Text = Res[2].read();
378
379       # A gpg failure is an automatic bad signature
380       if Exit[1] != 0 and Why == None:
381          GoodSig = 0;
382          Why = "GPG execution failed " + str(Exit[0]);
383
384       if GoodSig == 0 and (Why == None or len(Why) == 0):
385          Why = "Checking Failed";
386
387       # Try to decide if this message was sent using PGP2
388       PGP2Message = 0;
389       if (re.search("-----[\n\r][\n\r]?Version: 2\\.",Message) != None):
390          PGP2Message = 1;
391
392       return (Why,(SigId,Date,KeyFinger),(KeyID,KeyFinger,Owner,0,PGP2Message),Text);
393    finally:
394       if Res != None:
395          Res[1].close();
396          Res[2].close();
397
398 # Search for keys given a search pattern. The pattern is passed directly
399 # to GPG for processing. The result is a list of tuples of the form:
400 #   (KeyID,KeyFinger,Owner,Length)
401 # Which is similar to the key identification tuple output by GPGChecksig
402 def GPGKeySearch(SearchCriteria):
403    Args = [GPGPath] + GPGBasicOptions + GPGKeyRings + GPGSearchOptions + \
404           [SearchCriteria," 2> /dev/null"]
405    Strm = None;
406    Result = [];
407    Owner = "";
408    KeyID = "";
409    Hits = {};
410    try:
411       Strm = os.popen(string.join(Args," "),"r");
412       
413       while(1):
414          # Grab and split up line
415          Line = Strm.readline();
416          if Line == "":
417             break;
418          Split = string.split(Line,":");
419          
420          # Store some of the key fields
421          if Split[0] == 'pub':
422             KeyID = Split[4];
423             Owner = Split[9];
424             Length = int(Split[2]);
425
426          # Output the key
427          if Split[0] == 'fpr':
428             if Hits.has_key(Split[9]):
429                continue;
430             Hits[Split[9]] = None;
431             Result.append( (KeyID,Split[9],Owner,Length) );
432    finally:
433       if Strm != None:
434          Strm.close();
435    return Result;
436
437 # Print the available key information in a format similar to GPG's output
438 # We do not know the values of all the feilds so they are just replaced
439 # with ?'s
440 def GPGPrintKeyInfo(Ident):
441    print "pub  %u?/%s ??-??-?? %s" % (Ident[3],Ident[0][-8:],Ident[2]);
442    print "     key fingerprint = 0x%s" % (Ident[1]);
443
444 # Perform a substition of template 
445 def TemplateSubst(Map,Template):
446    for x in Map.keys():
447       Template = string.replace(Template,x,Map[x]);
448    return Template;
449
450 # The replay class uses a python DB (BSD db if avail) to implement
451 # protection against replay. Replay is an attacker capturing the
452 # plain text signed message and sending it back to the victim at some
453 # later date. Each signature has a unique signature ID (and signing 
454 # Key Fingerprint) as well as a timestamp. The first stage of replay
455 # protection is to ensure that the timestamp is reasonable, in particular
456 # not to far ahead or too far behind the current system time. The next
457 # step is to look up the signature + key fingerprint in the replay database
458 # and determine if it has been recived. The database is cleaned out 
459 # periodically and old signatures are discarded. By using a timestamp the
460 # database size is bounded to being within the range of the allowed times
461 # plus a little fuzz. The cache is serialized with a flocked lock file
462 class ReplayCache:
463    def __init__(self,Database):
464       self.Lock = open(Database + ".lock","w",0600);
465       fcntl.flock(self.Lock.fileno(),FCNTL.LOCK_EX);
466       self.DB = anydbm.open(Database,"c",0600);
467       self.CleanCutOff = CleanCutOff;
468       self.AgeCutOff = AgeCutOff;
469       self.FutureCutOff = FutureCutOff;
470       
471    # Close the cache and lock
472    def __del__(self):
473       self.close();
474    def close(self):
475       self.DB.close();
476       self.Lock.close();
477       
478    # Clean out any old signatures
479    def Clean(self):
480       CutOff = time.time() - self.CleanCutOff;
481       for x in self.DB.keys():
482          if int(self.DB[x]) <= CutOff:
483             del self.DB[x];
484     
485    # Check a signature. 'sig' is a 3 tuple that has the sigId, date and
486    # key ID
487    def Check(self,Sig):
488       if Sig[0] == None or Sig[1] == None or Sig[2] == None:
489          return "Invalid signature";
490       if int(Sig[1]) > time.time() + self.FutureCutOff:
491          return "Signature has a time too far in the future";
492       if self.DB.has_key(Sig[0] + '-' + Sig[2]):
493          return "Signature has already been received";
494       if int(Sig[1]) < time.time() - self.AgeCutOff:
495          return "Signature has passed the age cut off ";
496       # + str(int(Sig[1])) + ',' + str(time.time()) + "," + str(Sig);
497       return None;
498            
499    # Add a signature, the sig is the same as is given to Check
500    def Add(self,Sig):
501       if Sig[0] == None or Sig[1] == None:
502          raise RuntimeError,"Invalid signature";
503       if Sig[1] < time.time() - self.CleanCutOff:
504          return;
505       Key = Sig[0] + '-' + Sig[2]
506       if self.DB.has_key(Key):
507          if int(self.DB[Key]) < Sig[1]:
508             self.DB[Key] = str(int(Sig[1]));
509       else:
510          self.DB[Key] = str(int(Sig[1]));
511