[Author Prev][Author Next][Thread Prev][Thread Next][Author Index][Thread Index]

[minion-cvs] Refactor keyring encryption and passwords into a separa...



Update of /home/minion/cvsroot/src/minion/lib/mixminion
In directory moria.mit.edu:/tmp/cvs-serv8130/lib/mixminion

Modified Files:
	ClientMain.py test.py 
Added Files:
	ClientUtils.py 
Log Message:
Refactor keyring encryption and passwords into a separate class.

--- NEW FILE: ClientUtils.py ---
# Copyright 2002-2003 Nick Mathewson.  See LICENSE for licensing information.
# Id: ClientMain.py,v 1.89 2003/06/05 18:41:40 nickm Exp $

"""mixminion.ClientUtils

   This module holds helper code not included in the Mixminion Client
   API, but useful for more than one user interface.
   """

__all__ = [ 'NoPassword', 'PasswordManager', 'getPassword_term',
            'getNewPassword_term', ]

import cPickle
import getpass
import os
import sys

from mixminion.Common import readFile, writeFile, MixError
import mixminion.Crypto

#----------------------------------------------------------------------
class BadPassword(MixError):
    pass

class PasswordManager:
    # passwords: name -> string
    def __init__(self):
        self.passwords = {}
    def _getPassword(self, name, prompt):
        raise NotImplemented()
    def _getNewPassword(self, name, prompt):
        raise NotImplemented()
    def setPassword(self, name, password):
        self.passwords[name] = password
    def getPassword(self, name, prompt, confirmFn, maxTries=-1):
        if self.passwords.has_key(name):
            return self.passwords[name]
        for othername, pwd in self.passwords.items():
            if self._confirm(name, pwd):
                self.passwords[name] = pwd
                return pwd
        pmt = prompt
        while maxTries:
            pwd = self._getPassword(name, pmt)
            if confirmFn(pwd):
                self.passwords[name] = pwd
                return pwd
            maxTries -= 1
            pmt = "Incorrect password. "+prompt

        raise BadPassword()
    def getNewPassword(self, name, prompt):
        self.passwords[name] = self._getNewPassword(name, prompt)

class CLIPasswordManager(PasswordManager):
    def __init__(self):
        PasswordManager.__init__(self)
    def _getPassword(self, name, prompt):
        return getPassword_term(prompt)

def getPassword_term(prompt):
    """Read a password from the console, then return it.  Use the string
    'message' as a prompt."""
    # getpass.getpass uses stdout by default .... but stdout may have
    # been redirected.  If stdout is not a terminal, write the message
    # to stderr instead.
    if os.isatty(sys.stdout.fileno()):
        f = sys.stdout
        nl = 0
    else:
        f = sys.stderr
        nl = 1
    f.write(prompt)
    f.flush()
    try:
        p = getpass.getpass("")
    except KeyboardInterrupt:
        if nl: print >>f
        raise UIError("Interrupted")
    if nl: print >>f
    return p


def getNewPassword_term(prompt):
    """Read a new password from the console, then return it."""
    s1 = "Enter new password for %s:"%which
    s2 = "Verify password:".rjust(len(s1))
    if os.isatty(sys.stdout.fileno()):
        f = sys.stdout
    else:
        f = sys.stderr
    while 1:
        p1 = self.getPassword_term(s1)
        p2 = self.getPassword_term(s2)
        if p1 == p2:
            return p1
        f.write("Passwords do not match.\n")
        f.flush()

#----------------------------------------------------------------------

def readEncryptedFile(fname, password, magic):
    """DOCDOC
       return None on failure; raise  MixError on corrupt file.
    """
    #  variable         [File specific magic]       "KEYRING1"
    #  8                [8 bytes of salt]
    #  variable         ENCRYPTED DATA:KEY=sha1(salt+password+salt)
    #                                  DATA=data+
    #                                                   sha1(data+salt+magic)
    s = readFile(fname, 1)
    if not s.startswith(magic):
        raise ValueError("Invalid versioning on %s"%fname)
    s = s[len(magic):]
    if len(s) < 28:
        raise MixError("File %s too short."%fname)
    salt = s[:8]
    s = s[8:]
    key = mixminion.Crypto.sha1(salt+password+salt)[:16]
    s = mixminion.Crypto.ctr_crypt(s, key)
    data = s[:-20]
    hash = s[-20:]
    if hash != mixminion.Crypto.sha1(data+salt+magic):
        raise BadPassword()
    return data

def writeEncryptedFile(fname, password, magic, data):
    salt = mixminion.Crypto.getCommonPRNG().getBytes(8)
    key = mixminion.Crypto.sha1(salt+password+salt)[:16]
    hash = mixminion.Crypto.sha1("".join([data+salt+magic]))
    encrypted = mixminion.Crypto.ctr_crypt(data+hash, key)
    writeFile(fname, "".join([magic,salt,encrypted]), binary=1)

def readEncryptedPickled(fname, password, magic):
    return cPickle.loads(readEncryptedFile(fname, password, magic))

def writeEncryptedPickled(fname, password, magic, obj):
    data = cPickle.dumps(obj, 1)
    writeEncryptedFile(fname, password, magic, data)

class LazyEncryptedPickled:
    def __init__(self, fname, pwdManager, pwdName, queryPrompt, newPrompt,
                 magic, initFn):
        self.fname = fname
        self.pwdManager = pwdManager
        self.pwdName = pwdName
        self.queryPrompt = queryPrompt
        self.newPrompt = newPrompt
        self.magic = magic
        self.object = None
        self.loaded = 0
        self.password = None
        self.initFn = initFn
    def load(self, create=0,password=None):
        if self.loaded:
            return 
        elif os.path.exists(self.fname):
            if not readFile(self.fname).startswith(self.magic):
                raise MixError("Unrecognized versioning on file %s"%self.fname)
            # ... see if we can load it with no password ...
            if self._loadWithPassword(""):
                return
            if password is not None:
                self._loadWithPassword(password)
                if not self.loaded:
                    raise BadPassword()
            else:
                # sets self.password on successs
                self.pwdManager.getPassword(self.pwdName, self.queryPrompt,
                                            self._loadWithPassword)
        elif create:
            if password is not None:
                self.password = password
            else:
                self.password = self.pwdManager.getNewPassword(
                    self.pwdName, self.newPrompt)
            self.object = self.initFn()
            self.loaded = 1
            self.save()
        else:
            return

    def _loadWithPassword(self, password):
        try:
            self.object = readEncryptedPickled(self.fname,password,self.magic)
            self.password = password
            self.loaded = 1
            return 1
        except MixError:
            return 0
    def isLoaded(self):
        return self.loaded
    def get(self):
        assert self.loaded
        return self.object
    def set(self, val):
        self.object = val
        self.loaded = 1
    def setPassword(self, pwd):
        self.password = pwd
    def save(self):
        assert self.loaded and self.password
        writeEncryptedPickled(self.fname, self.password, self.magic,
                              self.object)
        
        

Index: ClientMain.py
===================================================================
RCS file: /home/minion/cvsroot/src/minion/lib/mixminion/ClientMain.py,v
retrieving revision 1.112
retrieving revision 1.113
diff -u -d -r1.112 -r1.113
--- ClientMain.py	31 Aug 2003 19:29:29 -0000	1.112
+++ ClientMain.py	6 Sep 2003 21:49:48 -0000	1.113
@@ -45,6 +45,7 @@
 from mixminion.Packet import encodeMailHeaders, ParseError, parseMBOXInfo, \
      parseReplyBlocks, parseSMTPInfo, parseTextEncodedMessages, \
      parseTextReplyBlocks, ReplyBlock, MBOX_TYPE, SMTP_TYPE, DROP_TYPE
+import mixminion.ClientUtils
 
 # FFFF This should be made configurable and adjustable.
 MIXMINION_DIRECTORY_URL = "http://mixminion.net/directory/Directory.gz";
@@ -101,7 +102,7 @@
     ## Layout:
     # DIR/cache: A cPickled tuple of ("ClientKeystore-0.2",
     #         lastModified, lastDownload, clientVersions, serverlist,
-    #         fullServerList, digestMap)
+    #         fullServerList, digestMap) DOCDOC is this correct?
     # DIR/dir.gz *or* DIR/dir: A (possibly gzipped) directory file.
     # DIR/imported/: A directory of server descriptors.
     MAGIC = "ClientKeystore-0.3"
@@ -949,30 +950,21 @@
        is limited to a single SURB decryption key.  In the future, we may
        include more SURB keys, as well as end-to-end encryption keys.
     """
-    ## Fields:
-    # keyDir: The directory where we store our keys.
-    # keyring: Dict to map from strings of the form "SURB-keyname" to SURB
-    #     secrets.
-    # keyringPassword: The password for our encrypted keyfile
-    ## Format:
-    # We store keys in a file holding:
-    #  variable         [File specific magic]       "KEYRING1"
-    #  8                [8 bytes of salt]
-    #  variable         ENCRYPTED DATA:KEY=sha1(salt+password+salt)
-    #                                  DATA=encrypted_pickled_data+
-    #                                                   sha1(data+salt+magic)
-    
-    # XXXX There needs to be some way to rotate and expire SURB secrets.
-    # XXXX Otherwise, we're very vulnerable to compromise.
-    
-    def __init__(self, keyDir):
-        """Create a new ClientKeyring to store data in keyDir"""
-        self.keyDir = keyDir
-        createPrivateDir(self.keyDir)
-        self.keyring = None
-        self.keyringPassword = None
+    # XXXX006 Are the error messages here still reasonable?
+    def __init__(self, keyDir, passwordManager=None):
+        """DOCDOC"""
+        if passwordManager is None:
+            passwordManager = mixminion.ClientUtils.CLIPasswordManager()
+        createPrivateDir(keyDir)
+        fn = os.path.join(keyDir, "keyring")
+        self.keyring = mixminion.ClientUtils.LazyEncryptedPickled(
+            fn, passwordManager, pwdName="ClientKeyring",
+            queryPrompt="Enter password for keyring:",
+            newPrompt="keyring",
+            magic="KEYRING1",
+            initFn=lambda:{})
 
-    def getKey(self, keyid, create=0, createFn=None, password=None):
+    def _getKey(self, keyid, create=0, createFn=None, password=None):
         """Helper function. Return a key for a given keyid.
 
            keyid -- the name of the key.
@@ -980,176 +972,50 @@
            createFn -- a callback to return a new key.
            password -- Optionally, a password for the keyring.
         """
-        if self.keyring is None:
-            self.getKeyring(create=create,password=password)
-            if self.keyring is None:
+        if not self.keyring.isLoaded():
+            try:
+                self.keyring.load(create=create,password=password)
+            except mixminion.ClientUtils.BadPassword:
+                LOG.error("Incorrect password")
+                return None
+            if not self.keyring.isLoaded():
                 return None
         try:
-            return self.keyring[keyid]
+            return self.keyring.get()[keyid]
         except KeyError:
             if not create:
                 return None
             else:
                 LOG.info("Creating new key for identity %r", keyid)
                 key = createFn()
-                self.keyring[keyid] = key
-                self._saveKeyring()
+                self.keyring.get()[keyid] = key
+                self.keyring.save()
                 return key
 
-    def getKeyring(self, create=0, password=None):
-        """Return a the current keyring, loading it if necessary.
-
-           create -- if true, create a new keyring if none is found.
-           password -- optionally, a password for the keyring.
-        """
-        if self.keyring is not None:
-            return self.keyring
-        fn = os.path.join(self.keyDir, "keyring")
-        magic = "KEYRING1"
-        if os.path.exists(fn):
-            # If the keyring exists, make sure the magic is correct.
-            self._checkMagic(fn, magic)
-            # ...then see if we can load it without a password...
-            try:
-                data = self._load(fn, magic, "")
-                self.keyring = cPickle.loads(data)
-                self.keyringPassword = ""
-                return self.keyring
-            except MixError, e:
-                pass
-            # ...then ask the user for a password 'till it loads.
-            while 1:
-                if password is not None:
-                    p = password
-                else:
-                    p = self._getPassword("Enter password for keyring:")
-                try:
-                    data = self._load(fn, magic, p)
-                    self.keyring = cPickle.loads(data)
-                    self.keyringPassword = p
-                    return self.keyring
-                except (MixError, cPickle.UnpicklingError), e:
-                    LOG.error("Cannot load keyring: %s", e)
-                    if password is not None: return None
-        elif create:
-            # If the key file doesn't exist, and 'create' is set, create it.
-            LOG.warn("No keyring found; generating.")
-            if password is not None:
-                self.keyringPassword = password
-            else:
-                self.keyringPassword = self._getNewPassword("keyring")
-            self.keyring = {}
-            self._saveKeyring()
-            return self.keyring
-        else:
-            return {}
-
-    def _saveKeyring(self):
-        """Save the current keyring to disk."""
-        assert self.keyringPassword is not None
-        fn = os.path.join(self.keyDir, "keyring")
-        LOG.trace("Saving keyring to %s", fn)
-        self._save(fn+"_tmp",
-                   cPickle.dumps(self.keyring,1),
-                   "KEYRING1", self.keyringPassword)
-        replaceFile(fn+"_tmp", fn)
-
     def getSURBKey(self, name="", create=0, password=None):
         """Return the key for a given SURB identity."""
-        k = self.getKey("SURB-"+name,
+        k = self._getKey("SURB-"+name,
                         create=create, createFn=lambda: trng(20),
                         password=password)
         if k is not None and len(k) != 20:
             raise MixError("Bad length on SURB key")
         return k
 
-    def getSURBKeys(self,password=None):
-        """Return the keys for _all_ SURB identities as a map from name
-           to key."""
-        self.getKeyring(create=0,password=password)
-        if not self.keyring: return {}
+    def getSURBKeys(self, name="", password=None):
+        """Return the keys for _all_ SURB identities as a map from
+           name to key."""
+        try:
+            self.keyring.load(create=0,password=password)
+        except mixminion.ClientUtils.BadPassword:
+            LOG.error("Incorrect password")
+        if not self.keyring.isLoaded(): return {}
         r = {}
-        for k in self.keyring.keys():
+        d = self.keyring.get()
+        for k,v in d.items():
             if k.startswith("SURB-"):
-                r[k[5:]] = self.keyring[k]
+                r[k[5:]] = v
         return r
 
-    def _checkMagic(self, fn, magic):
-        """Make sure that the magic string on a given key file %s starts with
-           is equal to 'magic'.  Raise MixError if it isn't."""
-        if not readFile(fn, 1).startswith(magic):
-            raise MixError("Invalid versioning on key file")
-
-    def _save(self, fn, data, magic, password):
-        """Save the key data 'data' into the file 'fn' using the magic string
-           'magic' and the password 'password'."""
-        salt = mixminion.Crypto.getCommonPRNG().getBytes(8)
-        key = sha1(salt+password+salt)[:16]
-        f = open(fn, 'wb')
-        f.write(magic)
-        f.write(salt)
-        f.write(ctr_crypt(data+sha1(data+salt+magic), key))
-        f.close()
-
-    def _load(self, fn, magic, password):
-        """Load and return the key stored in 'fn' using the magic string
-           'magic' and the password 'password'.  Raise MixError on failure."""
-        s = readFile(fn, 1)
-        if not s.startswith(magic):
-            raise MixError("Invalid versioning on key file")
-
-        s = s[len(magic):]
-        if len(s) < 8:
-            raise MixError("Key file too short")
-        salt = s[:8]
-        s = s[8:]
-        if len(s) < 20:
-            raise MixError("Key file too short")
-        key = sha1(salt+password+salt)[:16]
-        s = ctr_crypt(s, key)
-        data, hash = s[:-20], s[-20:]
-        if hash != sha1(data+salt+magic):
-            raise MixError("Incorrect password")
-        return data
-
-    def _getPassword(self, message):
-        """Read a password from the console, then return it.  Use the string
-           'message' as a prompt."""
-        # getpass.getpass uses stdout by default .... but stdout may have
-        # been redirected.  If stdout is not a terminal, write the message
-        # to stderr instead.
-        if os.isatty(sys.stdout.fileno()):
-            f = sys.stdout
-            nl = 0
-        else:
-            f = sys.stderr
-            nl = 1
-        f.write(message)
-        f.flush()
-        try:
-            p = getpass.getpass("")
-        except KeyboardInterrupt:
-            if nl: print >>f
-            raise UIError("Interrupted")
-        if nl: print >>f
-        return p
-
-    def _getNewPassword(self, which):
-        """Read a new password from the console, then return it."""
-        s1 = "Enter new password for %s:"%which
-        s2 = "Verify password:".rjust(len(s1))
-        if os.isatty(sys.stdout.fileno()):
-            f = sys.stdout
-        else:
-            f = sys.stderr
-        while 1:
-            p1 = self._getPassword(s1)
-            p2 = self._getPassword(s2)
-            if p1 == p2:
-                return p1
-            f.write("Passwords do not match.\n")
-            f.flush()
-
 def installDefaultConfig(fname):
     """Create a default, 'fail-safe' configuration in a given file"""
     LOG.warn("No configuration file found. Installing default file in %s",
@@ -1424,7 +1290,8 @@
         userdir = self.config['User']['UserDir']
         createPrivateDir(userdir)
         keyDir = os.path.join(userdir, "keys")
-        self.keys = ClientKeyring(keyDir)
+        self.pwdManager = mixminion.ClientUtils.CLIPasswordManager()
+        self.keys = ClientKeyring(keyDir, self.pwdManager)
         self.surbLogFilename = os.path.join(userdir, "surbs", "log")
 
         # Initialize PRNG

Index: test.py
===================================================================
RCS file: /home/minion/cvsroot/src/minion/lib/mixminion/test.py,v
retrieving revision 1.152
retrieving revision 1.153
diff -u -d -r1.152 -r1.153
--- test.py	5 Sep 2003 00:46:24 -0000	1.152
+++ test.py	6 Sep 2003 21:49:48 -0000	1.153
@@ -40,6 +40,7 @@
 
 import mixminion.BuildMessage as BuildMessage
 import mixminion.ClientMain
+import mixminion.ClientUtils
 import mixminion.Config
 import mixminion.Crypto as Crypto
 import mixminion.Filestore
@@ -5872,6 +5873,27 @@
 # variable to hold the latest instance of FakeBCC.
 BCC_INSTANCE = None
 
+
+class ClientUtilTests(TestCase):
+    def testEncryptedFiles(self):
+        CU = mixminion.ClientUtils
+        d = mix_mktemp()
+        createPrivateDir(d)
+        f1 = os.path.join(d, "foo")
+        CU.writeEncryptedFile(f1, password="x", magic="ABC", data="xyzzyxyzzy")
+        contents = readFile(f1)
+        self.assertEquals(contents[:3], "ABC")
+        salt = contents[3:11]
+        key = mixminion.Crypto.sha1(salt+"x"+salt)[:16]
+        decrypted = mixminion.Crypto.ctr_crypt(contents[11:], key)
+        self.assertEquals(decrypted, "xyzzyxyzzy"+mixminion.Crypto.sha1(
+            "xyzzyxyzzy"+salt+"ABC"))
+        
+        self.assertEquals("xyzzyxyzzy",
+              CU.readEncryptedFile(f1, "x", "ABC"))
+
+        #XXXX006 finish testing corner cases and pickles.
+
 class ClientMainTests(TestCase):
     def testClientDirectory(self):
         """Check out ClientMain's directory implementation"""
@@ -6406,7 +6428,6 @@
         keyring = mixminion.ClientMain.ClientKeyring(keydir)
         # Check for some nonexistent keys.
         self.assertEquals({}, keyring.getSURBKeys(password="pwd"))
-
         self.assertEquals(None, keyring.getSURBKey(create=0))
         # Reload, try again:
         kfirst = None
@@ -6414,17 +6435,10 @@
             keyring = mixminion.ClientMain.ClientKeyring(keydir)
             self.assertEquals(kfirst, keyring.getSURBKey(
                 create=0,password="pwd"))
-            try:
-                suspendLog()
-                k1 = keyring.getSURBKey(create=1,password="pwd")
-            finally:
-                s = resumeLog()
+            k1 = keyring.getSURBKey(create=1,password="pwd")
             if kfirst:
-                self.assertEquals(s, "")
                 self.assertEquals(k1, kfirst)
-            else:
-                self.assert_(stringContains(s, "No keyring found"))
-                kfirst = k1
+            kfirst = k1
             self.assertEquals(20, len(k1))
             k2 = keyring.getSURBKey(name="Bob",create=1)
             self.assertEquals(20, len(k2))
@@ -6764,7 +6778,7 @@
     tc = loader.loadTestsFromTestCase
 
     if 0:
-        suite.addTest(tc(ServerInfoTests))
+        suite.addTest(tc(ClientMainTests))
         return suite
     testClasses = [MiscTests,
                    MinionlibCryptoTests,