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