From 494b719c8638d0fc30f13670e1c24fd14f9f0ce9 Mon Sep 17 00:00:00 2001 Message-Id: <494b719c8638d0fc30f13670e1c24fd14f9f0ce9.1717734343.git.mdw@distorted.org.uk> From: Mark Wooding Date: Sun, 24 May 2015 18:49:44 +0100 Subject: [PATCH] catacomb/pwsafe.py: Factor database handling out into a StorageBackend. Organization: Straylight/Edgeware From: Mark Wooding This doesn't (currently) affect the external interface. --- catacomb/pwsafe.py | 268 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 203 insertions(+), 65 deletions(-) diff --git a/catacomb/pwsafe.py b/catacomb/pwsafe.py index a9e0605..b728e57 100644 --- a/catacomb/pwsafe.py +++ b/catacomb/pwsafe.py @@ -26,6 +26,8 @@ ###-------------------------------------------------------------------------- ### Imported modules. +from __future__ import with_statement + import catacomb as _C import gdbm as _G @@ -123,6 +125,159 @@ class PPK (Crypto): h().hash('mac:' + tag).done()) me.salt = salt +###-------------------------------------------------------------------------- +### Backend storage. + +class StorageBackend (object): + """ + I provide a backend for password and metadata storage. + + Backends are responsible for storing and retrieving stuff, but not for the + cryptographic details. Backends need to store two kinds of information: + + * metadata, consisting of a number of property names and their values; + and + + * password mappings, consisting of a number of binary labels and + payloads. + """ + + FAIL = ['FAIL'] + + ## Life cycle methods. + + @classmethod + def create(cls, file): + """Create a new database in the named FILE, using this backend.""" + return cls(writep = True, _magic = lambda me: me._create(file)) + def _create(me, file): + me._db = _G.open(file, 'n', 0600) + + def __init__(me, file = None, writep = False, _magic = None, *args, **kw): + """ + Main constructor. + """ + super(StorageBackend, me).__init__(*args, **kw) + if _magic is not None: _magic(me) + elif file is None: raise ValueError, 'missing file parameter' + else: me._db = _G.open(file, writep and 'w' or 'r') + me._writep = writep + me._livep = True + + def close(me): + """ + Close the database. + + It is harmless to attempt to close a database which has been closed + already. + """ + if me._livep: + me._livep = False + me._db.close() + + ## Utilities. + + def _check_live(me): + """Raise an error if the receiver has been closed.""" + if not me._livep: raise ValueError, 'database is closed' + + def _check_write(me): + """Raise an error if the receiver is not open for writing.""" + me._check_live() + if not me._writep: raise ValueError, 'database is read-only' + + def _check_meta_name(me, name): + """ + Raise an error unless NAME is a valid name for a metadata item. + + Metadata names may not start with `$': such names are reserved for + password storage. + """ + if name.startswith('$'): + raise ValueError, "invalid metadata key `%s'" % name + + ## Context protocol. + + def __enter__(me): + """Context protocol: make sure the database is closed on exit.""" + return me + def __exit__(me, exctype, excvalue, exctb): + """Context protocol: see `__enter__'.""" + me.close() + + ## Metadata. + + def get_meta(me, name, default = FAIL): + """ + Fetch the value for the metadata item NAME. + + If no such item exists, then return DEFAULT if that was set; otherwise + raise a `KeyError'. + """ + me._check_meta_name(name) + me._check_live() + try: value = me._db[name] + except KeyError: value = default + if value is StorageBackend.FAIL: raise KeyError, name + return value + + def put_meta(me, name, value): + """Store VALUE in the metadata item called NAME.""" + me._check_meta_name(name) + me._check_write() + me._db[name] = value + + def del_meta(me, name): + """Forget about the metadata item with the given NAME.""" + me._check_meta_name(name) + me._check_write() + del me._db[name] + + def iter_meta(me): + """Return an iterator over the name/value metadata items.""" + me._check_live() + k = me._db.firstkey() + while k is not None: + if not k.startswith('$'): yield k, me._db[k] + k = me._db.nextkey(k) + + ## Passwords. + + def get_passwd(me, label): + """ + Fetch and return the payload stored with the (opaque, binary) LABEL. + + If there is no such payload then raise `KeyError'. + """ + me._check_live() + return me._db['$' + label] + + def put_passwd(me, label, payload): + """ + Associate the (opaque, binary) PAYLOAD with the (opaque, binary) LABEL. + + Any previous payload for LABEL is forgotten. + """ + me._check_write() + me._db['$' + label] = payload + + def del_passwd(me, label): + """ + Forget any PAYLOAD associated with the (opaque, binary) LABEL. + + If there is no such payload then raise `KeyError'. + """ + me._check_write() + del me._db['$' + label] + + def iter_passwds(me): + """Return an iterator over the stored password label/payload pairs.""" + me._check_live() + k = me._db.firstkey() + while k is not None: + if k.startswith('$'): yield k[1:], me._db[k] + k = me._db.nextkey(k) + ###-------------------------------------------------------------------------- ### Password storage. @@ -135,9 +290,9 @@ class PW (object): I implement (some of) the Python mapping protocol. - Here's how we use the underlying GDBM key/value storage to keep track of - the necessary things. Password entries have keys whose name begins with - `$'; other keys have specific meanings, as follows. + I keep track of everything using a StorageBackend object. This contains + password entries, identified by cryptographic labels, and a number of + metadata items. cipher Names the Catacomb cipher selected. @@ -155,34 +310,34 @@ class PW (object): tag The master passphrase's tag, for the Pixie's benefit. - Password entries are assigned keys of the form `$' || H(MAGIC || TAG); the - corresponding value consists of a pair (TAG, PASSWD), prefixed with 16-bit - lengths, concatenated, padded to a multiple of 256 octets, and encrypted - using the stored keys. + Password entries are assigned labels of the form `$' || H(MAGIC || TAG); + the corresponding value consists of a pair (TAG, PASSWD), prefixed with + 16-bit lengths, concatenated, padded to a multiple of 256 octets, and + encrypted using the stored keys. """ def __init__(me, file, writep = False): """ - Initialize a PW object from the GDBM database in FILE. + Initialize a PW object from the database in FILE. - If WRITEP is true, then allow write-access to the database; otherwise - allow read access only. Requests the database password from the Pixie, - which may cause interaction. + If WRITEP is false (the default) then the database is opened read-only; + if true then it may be written. Requests the database password from the + Pixie, which may cause interaction. """ ## Open the database. - me.db = _G.open(file, writep and 'w' or 'r') + me.db = StorageBackend(file, writep) ## Find out what crypto to use. - c = _C.gcciphers[me.db['cipher']] - h = _C.gchashes[me.db['hash']] - m = _C.gcmacs[me.db['mac']] + c = _C.gcciphers[me.db.get_meta('cipher')] + h = _C.gchashes[me.db.get_meta('hash')] + m = _C.gcmacs[me.db.get_meta('mac')] ## Request the passphrase and extract the master keys. - tag = me.db['tag'] - ppk = PPK(_C.ppread(tag), c, h, m, me.db['salt']) + tag = me.db.get_meta('tag') + ppk = PPK(_C.ppread(tag), c, h, m, me.db.get_meta('salt')) try: - b = _C.ReadBuffer(ppk.decrypt(me.db['key'])) + b = _C.ReadBuffer(ppk.decrypt(me.db.get_meta('key'))) except DecryptError: _C.ppcancel(tag) raise @@ -192,12 +347,12 @@ class PW (object): ## Set the key, and stash it and the tag-hashing secret. me.k = Crypto(c, h, m, me.ck, me.mk) - me.magic = me.k.decrypt(me.db['magic']) + me.magic = me.k.decrypt(me.db.get_meta('magic')) @classmethod - def create(cls, file, c, h, m, tag): + def create(cls, file, tag, c, h, m): """ - Create and initialize a new, empty, database FILE. + Create and initialize a new database FILE. We want a GCipher subclass C, a GHash subclass H, and a GMAC subclass M; and a Pixie passphrase TAG. @@ -214,20 +369,19 @@ class PW (object): k = Crypto(c, h, m, ck, mk) ## Set up and initialize the database. - db = _G.open(file, 'n', 0600) - db['tag'] = tag - db['salt'] = ppk.salt - db['cipher'] = c.name - db['hash'] = h.name - db['mac'] = m.name - db['key'] = ppk.encrypt(_C.WriteBuffer().putblk16(ck).putblk16(mk)) - db['magic'] = k.encrypt(_C.rand.block(h.hashsz)) + kct = ppk.encrypt(_C.WriteBuffer().putblk16(ck).putblk16(mk)) + with StorageBackend.create(file) as db: + db.put_meta('tag', tag) + db.put_meta('salt', ppk.salt) + db.put_meta('cipher', c.name) + db.put_meta('hash', h.name) + db.put_meta('mac', m.name) + db.put_meta('key', kct) + db.put_meta('magic', k.encrypt(_C.rand.block(h.hashsz))) def keyxform(me, key): - """ - Transform the KEY (actually a password tag) into a GDBM record key. - """ - return '$' + me.k.h().hash(me.magic).hash(key).done() + """Transform the KEY (actually a password tag) into a password label.""" + return me.k.h().hash(me.magic).hash(key).done() def changepp(me): """ @@ -236,18 +390,16 @@ class PW (object): Requests the new password from the Pixie, which will probably cause interaction. """ - tag = me.db['tag'] + tag = me.db.get_meta('tag') _C.ppcancel(tag) ppk = PPK(_C.ppread(tag, _C.PMODE_VERIFY), me.k.c.__class__, me.k.h, me.k.m.__class__) - me.db['key'] = \ - ppk.encrypt(_C.WriteBuffer().putblk16(me.ck).putblk16(me.mk)) - me.db['salt'] = ppk.salt + kct = ppk.encrypt(_C.WriteBuffer().putblk16(me.ck).putblk16(me.mk)) + me.db.put_meta('key', kct) + me.db.put_meta('salt', ppk.salt) def pack(me, key, value): - """ - Pack the KEY and VALUE into a ciphertext, and return it. - """ + """Pack the KEY and VALUE into a ciphertext, and return it.""" b = _C.WriteBuffer() b.putblk16(key).putblk16(value) b.zero(((b.size + 255) & ~255) - b.size) @@ -267,37 +419,23 @@ class PW (object): ## Mapping protocol. def __getitem__(me, key): - """ - Return the password for the given KEY. - """ - try: - return me.unpack(me.db[me.keyxform(key)])[1] - except KeyError: - raise KeyError, key + """Return the password for the given KEY.""" + try: return me.unpack(me.db.get_passwd(me.keyxform(key)))[1] + except KeyError: raise KeyError, key def __setitem__(me, key, value): - """ - Associate the password VALUE with the KEY. - """ - me.db[me.keyxform(key)] = me.pack(key, value) + """Associate the password VALUE with the KEY.""" + me.db.put_passwd(me.keyxform(key), me.pack(key, value)) def __delitem__(me, key): - """ - Forget all about the KEY. - """ - try: - del me.db[me.keyxform(key)] - except KeyError: - raise KeyError, key + """Forget all about the KEY.""" + try: me.db.del_passwd(me.keyxform(key)) + except KeyError: raise KeyError, key def __iter__(me): - """ - Iterate over the known password tags. - """ - k = me.db.firstkey() - while k is not None: - if k[0] == '$': yield me.unpack(me.db[k])[0] - k = me.db.nextkey(k) + """Iterate over the known password tags.""" + for _, pld in me.db.iter_passwds(): + yield me.unpack(pld)[0] ## Context protocol. -- [mdw]